Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ without trusting the server with the contents.
- Email sharing at creation time, one message with recipients in blind copy
- Cloudflare Turnstile, per-IP and per-account rate limiting, automatic probe banning
- Account deletion with full cascade (GDPR right to erasure)
- Legal pages — privacy policy, terms, legal notice — written in Markdown from the admin dashboard, with templates included
- English and French

## Deploy
Expand Down
61 changes: 33 additions & 28 deletions apps/app/app/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,34 +34,39 @@ useHead({
<template>
<UApp>
<NuxtRouteAnnouncer />
<!-- Upstream project and its documentation, not the running instance: a fork is free to repoint
both, but the links are attribution. "GitHub" stays out of i18n — a brand name reads the
same in every locale — while "Documentation" does not. -->
<div class="fixed top-4 left-4 z-50 flex items-center gap-1">
<UButton
variant="ghost"
size="sm"
icon="i-lucide-github"
label="GitHub"
href="https://github.com/thoda-dev/shhh"
target="_blank"
rel="noopener noreferrer"
/>
<UButton
variant="ghost"
size="sm"
icon="i-lucide-book-open"
:label="$t('nav.documentation')"
href="https://shhh-docs.thoda.dev"
target="_blank"
rel="noopener noreferrer"
/>
<div class="flex min-h-screen flex-col">
<!-- Part of the flow rather than fixed over it: on a screen shorter than the content, a floating bar has scrolled text passing behind it. Upstream project and its documentation, not the running instance — a fork is free to repoint both, but the links are attribution. "GitHub" stays out of i18n, a brand name reads the same in every locale, while "Documentation" does not. -->
<header class="flex items-center justify-between gap-2 p-4">
<div class="flex items-center gap-1">
<UButton
variant="ghost"
size="sm"
icon="i-lucide-github"
label="GitHub"
href="https://github.com/thoda-dev/shhh"
target="_blank"
rel="noopener noreferrer"
/>
<UButton
variant="ghost"
size="sm"
icon="i-lucide-book-open"
:label="$t('nav.documentation')"
href="https://shhh-docs.thoda.dev"
target="_blank"
rel="noopener noreferrer"
/>
</div>
<div class="flex items-center gap-2">
<LanguageSwitcher />
<UColorModeButton />
<AuthButton />
</div>
</header>
<main class="flex flex-1 flex-col">
<NuxtPage />
</main>
<AppFooter />
</div>
<div class="fixed top-4 right-4 z-50 flex items-center gap-2">
<LanguageSwitcher />
<UColorModeButton />
<AuthButton />
</div>
<NuxtPage />
</UApp>
</template>
3 changes: 2 additions & 1 deletion apps/app/app/components/AdminNav.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ const links = computed(() => {
{ to: '/admin/allowed-ips', icon: 'i-lucide-shield', label: t('admin.allowedIps.title') },
{ to: '/admin/banned-ips', icon: 'i-lucide-ban', label: t('admin.bannedIps.title') },
{ to: '/admin/users', icon: 'i-lucide-users', label: t('admin.users.title') },
{ to: '/admin/invitations', icon: 'i-lucide-mail-plus', label: t('admin.invitations.title') }
{ to: '/admin/invitations', icon: 'i-lucide-mail-plus', label: t('admin.invitations.title') },
{ to: '/admin/legal', icon: 'i-lucide-scale', label: t('admin.legal.title'), superAdminOnly: true }
]
return all.filter(link => !link.superAdminOnly || user.value?.role === 'super_admin')
})
Expand Down
23 changes: 23 additions & 0 deletions apps/app/app/components/AppFooter.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<script setup lang="ts">
const { t } = useI18n()
const localePath = useLocalePath()

const settings = await ensurePublicSettingsLoaded()
const documents = computed(() => settings.value?.legalDocuments ?? [])
</script>

<template>
<footer
v-if="documents.length"
class="flex flex-wrap items-center justify-center gap-x-4 gap-y-1 px-4 pt-4 pb-6 text-xs text-muted"
>
<NuxtLink
v-for="slug in documents"
:key="slug"
:to="localePath(`/legal/${slug}`)"
class="hover:text-default"
>
{{ t(`legal.documents.${slug}`) }}
</NuxtLink>
</footer>
</template>
160 changes: 160 additions & 0 deletions apps/app/app/components/LegalDocument.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
<script setup lang="ts">
// `MarkdownDocument` is registered globally by the @comark/nuxt module.
import type { MarkdownDocument as MarkdownDocumentType } from 'comark'

defineProps<{ value: MarkdownDocumentType }>()
</script>

<template>
<div class="legal-document">
<MarkdownDocument :value="value" />
</div>
</template>

<style scoped>
/* The only prose in the app; the tag set is fixed by shared/utils/legal-markdown.ts. */
.legal-document {
font-size: 0.9375rem;
line-height: 1.7;
}

.legal-document :deep(> *:first-child) {
margin-top: 0;
}

.legal-document :deep(h1) {
font-size: 1.75rem;
font-weight: 600;
letter-spacing: -0.015em;
color: var(--ui-text-highlighted);
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--ui-border);
}

.legal-document :deep(h2) {
font-size: 1.1875rem;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--ui-text-highlighted);
margin-top: 2.5rem;
margin-bottom: 0.75rem;
}

.legal-document :deep(h3),
.legal-document :deep(h4),
.legal-document :deep(h5),
.legal-document :deep(h6) {
font-size: 1rem;
font-weight: 600;
color: var(--ui-text-highlighted);
margin-top: 1.75rem;
margin-bottom: 0.5rem;
}

.legal-document :deep(p) {
margin-bottom: 1rem;
}

.legal-document :deep(strong) {
font-weight: 600;
color: var(--ui-text-highlighted);
}

.legal-document :deep(a) {
color: var(--ui-primary);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-thickness: 1px;
}

.legal-document :deep(a:hover) {
text-decoration-thickness: 2px;
}

.legal-document :deep(ul),
.legal-document :deep(ol) {
margin-bottom: 1rem;
padding-left: 1.5rem;
}

.legal-document :deep(ul) {
list-style: disc;
}

.legal-document :deep(ol) {
list-style: decimal;
}

.legal-document :deep(li) {
margin-bottom: 0.375rem;
}

.legal-document :deep(li::marker) {
color: var(--ui-text-dimmed);
}

.legal-document :deep(blockquote) {
border-left: 2px solid var(--ui-border-accented);
padding-left: 1rem;
margin: 1.25rem 0;
color: var(--ui-text-muted);
}

/* Retention and cookie tables are the reason these documents need tables at all, and they overflow
on a phone rather than squeezing the columns. */
.legal-document :deep(table) {
display: block;
width: 100%;
overflow-x: auto;
border-collapse: collapse;
margin-bottom: 1.25rem;
font-size: 0.875rem;
}

.legal-document :deep(th),
.legal-document :deep(td) {
border: 1px solid var(--ui-border);
padding: 0.5rem 0.75rem;
text-align: left;
vertical-align: top;
}

.legal-document :deep(th) {
background-color: var(--ui-bg-elevated);
font-weight: 600;
color: var(--ui-text-highlighted);
white-space: nowrap;
}

.legal-document :deep(code) {
font-family: var(--font-mono, ui-monospace, monospace);
font-size: 0.875em;
background-color: var(--ui-bg-elevated);
border-radius: var(--ui-radius);
padding: 0.125rem 0.25rem;
}

.legal-document :deep(pre) {
background-color: var(--ui-bg-elevated);
border-radius: var(--ui-radius);
padding: 1rem;
overflow-x: auto;
margin-bottom: 1.25rem;
}

.legal-document :deep(pre code) {
background-color: transparent;
padding: 0;
}

.legal-document :deep(hr) {
border-top: 1px solid var(--ui-border);
margin: 2rem 0;
}

.legal-document :deep(img) {
max-width: 100%;
border-radius: var(--ui-radius);
}
</style>
Loading