First commit
This commit is contained in:
123
frontend/app/components/AccountActionsMenu.vue
Normal file
123
frontend/app/components/AccountActionsMenu.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
<script setup lang="ts">
|
||||
/* One account's actions on the dashboard, folded into a quiet ⋮: give the admin
|
||||
role or take it back, and delete the account. The menu behaves like the
|
||||
article's (see ArticleActionsMenu) — closed by a click outside, by esc, by
|
||||
navigating, and by search, settings or the profile popup opening over it —
|
||||
and only one row's menu is open at a time, which is what the shared state
|
||||
keys off. */
|
||||
import type { AuthRole } from '~/composables/useAuth'
|
||||
|
||||
const props = defineProps<{
|
||||
/** the account this row belongs to */
|
||||
accountId: string
|
||||
/** whose account it is — the menu says so where the ⋮ waits for a hover */
|
||||
username: string
|
||||
/** what that account may do right now */
|
||||
role: AuthRole
|
||||
/** a request about this account is in flight: the menu waits with it */
|
||||
busy: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'set-role': [role: AuthRole]
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const { openId, show, hide } = useAccountActionsMenu()
|
||||
const { open: searchOpen } = useSearch()
|
||||
const { open: settingsOpen } = useSettings()
|
||||
const { open: profileOpen } = useProfileMenu()
|
||||
|
||||
const open = computed(() => openId.value === props.accountId)
|
||||
|
||||
function toggle() {
|
||||
if (open.value) hide()
|
||||
else show(props.accountId)
|
||||
}
|
||||
|
||||
const wrapEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (open.value && !wrapEl.value?.contains(e.target as Node)) hide()
|
||||
}
|
||||
|
||||
/* Captured so this runs before the page's own Esc handler sees the key: while a
|
||||
menu is open, esc folds it away rather than what the page would do with it. */
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape' || !open.value) return
|
||||
hide()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocClick)
|
||||
window.addEventListener('keydown', onKeydown, true)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocClick)
|
||||
window.removeEventListener('keydown', onKeydown, true)
|
||||
})
|
||||
|
||||
watch(() => route.fullPath, hide)
|
||||
watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) => {
|
||||
if (search || settings || profile) hide()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapEl" class="kebab">
|
||||
<button
|
||||
type="button"
|
||||
class="kebab-toggle"
|
||||
:class="{ open }"
|
||||
:aria-expanded="String(open)"
|
||||
aria-haspopup="menu"
|
||||
:title="t('accountActions.forUser', { username })"
|
||||
:aria-label="t('accountActions.aria')"
|
||||
@click="toggle"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="12" cy="5" r="0.6" />
|
||||
<circle cx="12" cy="12" r="0.6" />
|
||||
<circle cx="12" cy="19" r="0.6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="open" class="kebab-dropdown" role="menu" :aria-label="t('accountActions.aria')">
|
||||
<button
|
||||
type="button"
|
||||
class="kebab-item"
|
||||
role="menuitem"
|
||||
:disabled="busy"
|
||||
@click="hide(); emit('set-role', role === 'admin' ? 'member' : 'admin')"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 22s8-4.5 8-10V5.5L12 2 4 5.5V12c0 5.5 8 10 8 10z" />
|
||||
<path v-if="role === 'admin'" d="m9 12 2 2 4-4" />
|
||||
<path v-else d="M9 12h6" />
|
||||
</svg>
|
||||
{{ role === 'admin' ? t('accountActions.removeAdmin') : t('accountActions.makeAdmin') }}
|
||||
</button>
|
||||
|
||||
<div class="kebab-sep" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="kebab-item kebab-danger"
|
||||
role="menuitem"
|
||||
:disabled="busy"
|
||||
@click="hide(); emit('remove')"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
193
frontend/app/components/AppHeader.vue
Normal file
193
frontend/app/components/AppHeader.vue
Normal file
@@ -0,0 +1,193 @@
|
||||
<script setup lang="ts">
|
||||
const { collapsed, toggleSidebar, restoreSidebar } = useSidebar()
|
||||
const { show: openSearch } = useSearch()
|
||||
const { show: openSettings } = useSettings()
|
||||
const { tabs, lastSlug } = useArticleTabs()
|
||||
const { user, isAdmin, signOut } = useAuth()
|
||||
const { show: openSignIn } = useSignInPopup()
|
||||
const { show: openProfile } = useProfileMenu()
|
||||
const { t } = useI18n()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
/* The open articles live in the reading surface: the book ArticleBookStack
|
||||
lays their pages side by side, older ones parked with only their spines
|
||||
showing. The stack stays open all session, but the view of it is only drawn
|
||||
in the reader — so this button is the way back: press it wherever you are
|
||||
(the graph, the list, the editor) and every article you have open comes back
|
||||
into view, stacked as you left it. */
|
||||
const inReader = computed(() => wikiSlugFromPath(route.path) !== null)
|
||||
|
||||
function showOpenArticles() {
|
||||
if (inReader.value) return // the pages are already spread out in view
|
||||
/* turn back to the page that was last in view — every tab knows its own
|
||||
address, language versions included */
|
||||
const target =
|
||||
tabs.value.find((t) => t.slug === lastSlug.value) ?? tabs.value[tabs.value.length - 1]
|
||||
if (target) void router.push(tabHref(target))
|
||||
}
|
||||
|
||||
/* the shortcut hint beside the search bar — "/" keeps focus on the home
|
||||
page's inline box there, so the popup's universal key is Ctrl/⌘+K */
|
||||
const kbdHint = ref('/')
|
||||
onMounted(() => {
|
||||
kbdHint.value = /Mac|iPhone|iPad/i.test(navigator.userAgent) ? '⌘K' : 'Ctrl K'
|
||||
restoreSidebar()
|
||||
})
|
||||
|
||||
/* the account row wears an initial in a circle, so it still reads as a person
|
||||
once the collapsed rail has dropped the name beside it */
|
||||
const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperCase())
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="site-sidebar" :class="{ collapsed }">
|
||||
<NuxtLink to="/" class="logo" :aria-label="t('sidebar.home')">
|
||||
<span class="logo-mark">∑</span>
|
||||
<span class="hide-collapsed">math<span class="logo-accent">ew</span></span>
|
||||
</NuxtLink>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<button
|
||||
type="button"
|
||||
class="sidebar-search"
|
||||
:aria-label="t('common.searchArticles')"
|
||||
@click="openSearch()"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.2-3.2" />
|
||||
</svg>
|
||||
<span class="sidebar-search-label hide-collapsed">{{ t('sidebar.search') }}</span>
|
||||
<kbd class="kbd-hint hide-collapsed">{{ kbdHint }}</kbd>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="tabs.length"
|
||||
type="button"
|
||||
class="nav-link sidebar-open"
|
||||
:class="{ 'is-current': inReader }"
|
||||
:aria-label="t('sidebar.openAria', tabs.length)"
|
||||
:title="t('sidebar.openTitle', tabs.length)"
|
||||
@click="showOpenArticles()"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 2 2 7l10 5 10-5-10-5z" />
|
||||
<path d="m2 12 10 5 10-5" />
|
||||
<path d="m2 17 10 5 10-5" />
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('sidebar.open') }}</span>
|
||||
<span class="tabs-count hide-collapsed">{{ tabs.length }}</span>
|
||||
</button>
|
||||
|
||||
<NuxtLink to="/articles" class="nav-link" :title="t('sidebar.articles')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('sidebar.articles') }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/graph" class="nav-link" :title="t('sidebar.graph')">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="18" cy="5" r="3" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="19" r="3" />
|
||||
<path d="m8.6 10.5 6.8-4M8.6 13.5l6.8 4" />
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('sidebar.graph') }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink
|
||||
v-if="isAdmin"
|
||||
to="/admin"
|
||||
class="nav-link"
|
||||
:title="t('sidebar.adminHint')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 22s8-4.5 8-10V5.5L12 2 4 5.5V12c0 5.5 8 10 8 10z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('sidebar.admin') }}</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/new" class="btn btn-primary btn-sm" :title="t('sidebar.newArticle')">
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('sidebar.newArticle') }}</span>
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<!-- who is using the wiki. Reading needs no account, so a visitor is given
|
||||
the door in; a signed-in person sees who writes as themselves and can
|
||||
open their profile — the picture, the account, the way out. -->
|
||||
<div v-if="user" class="account-row">
|
||||
<button
|
||||
type="button"
|
||||
class="account-open"
|
||||
:title="t('sidebar.signedInAs', { name: user.username })"
|
||||
@click="openProfile()"
|
||||
>
|
||||
<span class="account-avatar" aria-hidden="true">
|
||||
<img v-if="user.avatar" :src="user.avatar" alt="" />
|
||||
<template v-else>{{ initials }}</template>
|
||||
</span>
|
||||
<span class="hide-collapsed account-name">{{ user.username }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="account-exit hide-collapsed"
|
||||
:aria-label="t('common.signOut')"
|
||||
:title="t('common.signOut')"
|
||||
@click="signOut()"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="m16 17 5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:title="t('sidebar.signInHint')"
|
||||
@click="openSignIn()"
|
||||
>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="8.5" cy="15.5" r="4" />
|
||||
<path d="m11.6 12.4 8.4-8.4 2 2-2 2 1.5 1.5-2 2-1.5-1.5-1.9 1.9" />
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('auth.signin') }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:aria-label="t('settings.title')"
|
||||
:title="t('settings.title')"
|
||||
@click="openSettings()"
|
||||
>
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z" />
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('settings.title') }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="panel-icon-btn sidebar-collapse-btn"
|
||||
:aria-label="collapsed ? t('sidebar.expand') : t('sidebar.collapse')"
|
||||
:title="collapsed ? t('sidebar.expand') : t('sidebar.collapse')"
|
||||
@click="toggleSidebar()"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m15 18-6-6 6-6" />
|
||||
</svg>
|
||||
<span class="hide-collapsed">{{ t('sidebar.collapseLabel') }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
127
frontend/app/components/ArticleActionsMenu.vue
Normal file
127
frontend/app/components/ArticleActionsMenu.vue
Normal file
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
/* The article's actions folded into one ⋮ button: Edit, the language menu,
|
||||
and Delete — so the title row keeps its space to itself. The menu behaves
|
||||
like the app's other popups: closed by a click outside, by esc (unless the
|
||||
language submenu is open, since that claims the key first), by navigating,
|
||||
and by search or settings opening over it. The language menu rides inside
|
||||
as a menu item of its own — its list drops over the rest of this menu. */
|
||||
import type { ArticleVersion } from './ArticleCard.vue'
|
||||
|
||||
defineProps<{
|
||||
/** the article the versions belong to — its canonical slug */
|
||||
identity: string
|
||||
versions: ArticleVersion[]
|
||||
/** the version in view and the language it is written in */
|
||||
currentSlug: string
|
||||
language: string
|
||||
/** the slug the editor answers to */
|
||||
editorSlug: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const { open, hide } = useArticleActionsMenu()
|
||||
const { open: langMenuOpen } = useLanguageMenu()
|
||||
const { open: searchOpen } = useSearch()
|
||||
const { open: settingsOpen } = useSettings()
|
||||
const { open: profileOpen } = useProfileMenu()
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
const wrapEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (open.value && !wrapEl.value?.contains(e.target as Node)) hide()
|
||||
}
|
||||
|
||||
/* Captured so this runs before the book's own Esc handler sees the key: while
|
||||
the menu is open, esc folds it away instead of closing the page. The
|
||||
language submenu sits on top of this menu, so it gets the key first. */
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape' || !open.value) return
|
||||
if (langMenuOpen.value) return
|
||||
hide()
|
||||
e.stopPropagation()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocClick)
|
||||
window.addEventListener('keydown', onKeydown, true)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocClick)
|
||||
window.removeEventListener('keydown', onKeydown, true)
|
||||
})
|
||||
|
||||
watch(() => route.fullPath, hide)
|
||||
watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) => {
|
||||
if (search || settings || profile) hide()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapEl" class="kebab">
|
||||
<button
|
||||
type="button"
|
||||
class="kebab-toggle"
|
||||
:class="{ open }"
|
||||
:aria-expanded="String(open)"
|
||||
:title="t('articleActions.aria')"
|
||||
:aria-label="t('articleActions.aria')"
|
||||
@click="toggle"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="12" cy="5" r="0.6" />
|
||||
<circle cx="12" cy="12" r="0.6" />
|
||||
<circle cx="12" cy="19" r="0.6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="open" class="kebab-dropdown" role="menu" :aria-label="t('articleActions.aria')">
|
||||
<NuxtLink
|
||||
:to="{ path: `/wiki/${editorSlug}/edit`, query: { lang: language } }"
|
||||
class="kebab-item"
|
||||
role="menuitem"
|
||||
@click="hide"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
{{ t('common.edit') }}
|
||||
</NuxtLink>
|
||||
|
||||
<div class="kebab-sep" />
|
||||
|
||||
<ArticleLanguageMenu
|
||||
:identity="identity"
|
||||
:versions="versions"
|
||||
:current-slug="currentSlug"
|
||||
:language="language"
|
||||
:editor-slug="editorSlug"
|
||||
/>
|
||||
|
||||
<div class="kebab-sep" />
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="kebab-item kebab-danger"
|
||||
role="menuitem"
|
||||
@click="hide(); emit('remove')"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 6h18" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6" />
|
||||
<path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
<path d="M10 11v6M14 11v6" />
|
||||
</svg>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
257
frontend/app/components/ArticleBookStack.vue
Normal file
257
frontend/app/components/ArticleBookStack.vue
Normal file
@@ -0,0 +1,257 @@
|
||||
<script setup lang="ts">
|
||||
/* The reading surface: an open book of the articles from this session. Each
|
||||
one is a page laid out beside the last, and you move along them sideways.
|
||||
A page turned to slides over the older ones, which park along the left
|
||||
edge with only their spines showing; click anywhere on a page — spine or
|
||||
body — to turn back to it, or × to close it. Routes stay the source of
|
||||
truth — every page is a plain /wiki/<slug> URL — and useArticleTabs keeps
|
||||
the stack of pages.
|
||||
|
||||
Pages further along the book than the one in view wait off the right edge
|
||||
of the desk, where you would have to scroll sideways to reach them. Their
|
||||
spines are copied into a stack docked at the right edge of the desk instead
|
||||
— turning one takes you to the page as if you had scrolled to it — and a
|
||||
spine leaves the stack as its page is brought into view.
|
||||
|
||||
Narrow screens fold the book to the one page in view, with its neighbours
|
||||
as chips along the top. */
|
||||
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
|
||||
const { tabs, closeTab } = useArticleTabs()
|
||||
const { load, forget } = useArticleLibrary()
|
||||
const { open: searchOpen } = useSearch()
|
||||
const { open: settingsOpen } = useSettings()
|
||||
const { open: profileOpen } = useProfileMenu()
|
||||
const { open: langMenuOpen } = useLanguageMenu()
|
||||
const { open: actionsMenuOpen } = useArticleActionsMenu()
|
||||
const { collapsed } = useSidebar()
|
||||
|
||||
const activeTab = computed(() => tabAt(tabs.value, route.path))
|
||||
const activeSlug = computed(() => activeTab.value?.slug ?? null)
|
||||
const visible = computed(() => isReaderPath(route.path) && tabs.value.length > 0)
|
||||
|
||||
const trackEl = ref<HTMLElement | null>(null)
|
||||
|
||||
/** Whether the book is open flat on the desk (wide) or folded (narrow). */
|
||||
function liesFlat() {
|
||||
return import.meta.client && !window.matchMedia('(max-width: 900px)').matches
|
||||
}
|
||||
|
||||
/** Which page of the book is the one in view, or -1 when the route names
|
||||
* none of them. Only the pages beyond it can be waiting for you. */
|
||||
const openIndex = computed(() => {
|
||||
const slug = activeSlug.value
|
||||
return slug === null ? -1 : tabs.value.findIndex((t) => t.slug === slug)
|
||||
})
|
||||
|
||||
/**
|
||||
* Bring the page in view back to its place in the stack. Pages are laid out
|
||||
* a page-width apart and each one parks `--i` spines from the left edge, so
|
||||
* scrolling to i * (page width − spine width) sets the whole row: older
|
||||
* pages fanned into spines on the left, the rest waiting off the right edge.
|
||||
*/
|
||||
async function alignToActive(behavior: ScrollBehavior = 'smooth') {
|
||||
await nextTick()
|
||||
const track = trackEl.value
|
||||
const slug = activeSlug.value
|
||||
if (!track || !slug || !liesFlat()) return
|
||||
|
||||
const page = track.querySelector<HTMLElement>(`[data-slug="${CSS.escape(slug)}"]`)
|
||||
const i = Number(page?.dataset.index)
|
||||
if (!page || !Number.isFinite(i)) return
|
||||
|
||||
const spine = parseFloat(getComputedStyle(track).getPropertyValue('--spine-w')) || 0
|
||||
track.scrollTo({ left: i * (page.offsetWidth - spine), behavior })
|
||||
measureDock()
|
||||
}
|
||||
|
||||
/* ---------- the dock: pages waiting beyond the right edge of the desk -----
|
||||
A page further along the book than the one in view has its spine off the
|
||||
right hand side of the screen, and only a sideways scroll reaches it. While
|
||||
it is there its spine is kept at the right edge of the desk, so the page is
|
||||
a click away rather than a drag away. Which pages are out there is measured
|
||||
off the row, not guessed at: slide the book and a spine leaves the stack as
|
||||
its page comes into view, and joins it again as the page slides out. */
|
||||
|
||||
/** The slugs of the pages whose spines are in the dock. */
|
||||
const waitingSlugs = ref<string[]>([])
|
||||
/** Those pages, in the order they were opened. */
|
||||
const waiting = computed(() => tabs.value.filter((t) => waitingSlugs.value.includes(t.slug)))
|
||||
|
||||
/**
|
||||
* Fill the dock with the pages beyond the one in view whose spines have no
|
||||
* room left on the desk. A page keeps its place while it straddles the edge,
|
||||
* so a page sliding past doesn't flicker in and out of the stack.
|
||||
*/
|
||||
function measureDock() {
|
||||
const track = trackEl.value
|
||||
const open = openIndex.value
|
||||
if (!track || open === -1 || !liesFlat()) {
|
||||
waitingSlugs.value = []
|
||||
return
|
||||
}
|
||||
|
||||
const edge = track.getBoundingClientRect().right
|
||||
const spine = parseFloat(getComputedStyle(track).getPropertyValue('--spine-w')) || 0
|
||||
const held = new Set(waitingSlugs.value)
|
||||
for (const page of Array.from(track.querySelectorAll<HTMLElement>('.book-pane'))) {
|
||||
const i = Number(page.dataset.index)
|
||||
const slug = page.dataset.slug
|
||||
if (!slug || !Number.isFinite(i) || i <= open) continue
|
||||
const left = page.getBoundingClientRect().left
|
||||
if (left > edge - 6) held.add(slug)
|
||||
else if (left < edge - spine - 10) held.delete(slug)
|
||||
}
|
||||
waitingSlugs.value = tabs.value.filter((t, i) => i > open && held.has(t.slug)).map((t) => t.slug)
|
||||
}
|
||||
|
||||
/* the row moves on scroll, so the dock is measured once a frame while it does */
|
||||
let measuring = 0
|
||||
function watchDock() {
|
||||
if (measuring) return
|
||||
measuring = requestAnimationFrame(() => {
|
||||
measuring = 0
|
||||
measureDock()
|
||||
})
|
||||
}
|
||||
onBeforeUnmount(() => cancelAnimationFrame(measuring))
|
||||
|
||||
/* turning to an article slides the book round to it */
|
||||
watch(activeSlug, () => void alignToActive('smooth'))
|
||||
/* and the row changing shape — however it changed — changes which pages are
|
||||
waiting out beyond the edge of the desk */
|
||||
watch(tabs, () => void nextTick(measureDock))
|
||||
|
||||
/* a page opened or closed changes how wide every page is — settle the row
|
||||
again so the one in view doesn't drift */
|
||||
watch(
|
||||
() => tabs.value.length,
|
||||
() => void alignToActive('auto')
|
||||
)
|
||||
|
||||
/* pages are sized against the window, so keep them honest as it moves */
|
||||
function onResize() {
|
||||
void alignToActive('auto')
|
||||
measureDock()
|
||||
}
|
||||
onMounted(() => {
|
||||
void alignToActive('auto')
|
||||
void nextTick(measureDock)
|
||||
window.addEventListener('resize', onResize)
|
||||
})
|
||||
onBeforeUnmount(() => window.removeEventListener('resize', onResize))
|
||||
|
||||
/* collapsing the sidebar moves the reading surface's left edge, which
|
||||
resizes every page — settle the row again once the rail has finished
|
||||
sliding */
|
||||
watch(collapsed, () => {
|
||||
setTimeout(() => void alignToActive('auto'), 260)
|
||||
})
|
||||
|
||||
/* every page of the book should hold its article: pull the ones the route
|
||||
host hasn't already written in (background pages, client-side only) */
|
||||
watch(
|
||||
tabs,
|
||||
() => {
|
||||
for (const t of tabs.value) void load(t.slug)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function turnTo(slug: string) {
|
||||
const tab = tabs.value.find((t) => t.slug === slug)
|
||||
if (tab && slug !== activeSlug.value) void router.push(tabHref(tab))
|
||||
}
|
||||
|
||||
async function removeArticle(slug: string) {
|
||||
const title = tabs.value.find((t) => t.slug === slug)?.title ?? slug
|
||||
if (!window.confirm(t('book.deleteConfirm', { title }))) return
|
||||
try {
|
||||
await $fetch(`${api}/articles/slug/${encodeURIComponent(slug)}`, { method: 'DELETE' })
|
||||
forget(slug)
|
||||
closeTab(slug)
|
||||
} catch {
|
||||
window.alert(t('book.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
/* Esc closes the page in view — unless a popup is open, since those
|
||||
claim the key first */
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key !== 'Escape') return
|
||||
if (searchOpen.value || settingsOpen.value || profileOpen.value || langMenuOpen.value || actionsMenuOpen.value)
|
||||
return
|
||||
const current = tabAt(tabs.value, route.path)?.slug
|
||||
if (current) closeTab(current)
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="book-layer" :style="{ '--panes': tabs.length }">
|
||||
<nav
|
||||
v-if="tabs.length > 1"
|
||||
class="book-chips"
|
||||
role="tablist"
|
||||
:aria-label="t('book.openAria')"
|
||||
>
|
||||
<div
|
||||
v-for="tab in tabs"
|
||||
:key="tab.slug"
|
||||
class="book-chip"
|
||||
:class="{ active: tab.slug === activeSlug }"
|
||||
role="tab"
|
||||
tabindex="0"
|
||||
:aria-selected="tab.slug === activeSlug"
|
||||
:title="t('book.chipHint', { title: tab.title })"
|
||||
@click="turnTo(tab.slug)"
|
||||
@keydown.enter.prevent="turnTo(tab.slug)"
|
||||
>
|
||||
<span class="chip-title">{{ tab.title }}</span>
|
||||
<button type="button" class="chip-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.8" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div ref="trackEl" class="book-track" @scroll.passive="watchDock">
|
||||
<ArticlePane
|
||||
v-for="(tab, i) in tabs"
|
||||
:key="tab.slug"
|
||||
:slug="tab.slug"
|
||||
:index="i"
|
||||
:active="tab.slug === activeSlug"
|
||||
@activate="turnTo(tab.slug)"
|
||||
@close="closeTab(tab.slug)"
|
||||
@remove="removeArticle(tab.slug)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav v-if="waiting.length" class="book-dock" :aria-label="t('book.dockAria')">
|
||||
<div
|
||||
v-for="tab in waiting"
|
||||
:key="tab.slug"
|
||||
class="dock-spine"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:title="t('book.dockHint', { title: tab.title })"
|
||||
@click="turnTo(tab.slug)"
|
||||
@keydown.enter.prevent="turnTo(tab.slug)"
|
||||
>
|
||||
<button type="button" class="spine-close" :aria-label="t('common.closeTab')" @click.stop="closeTab(tab.slug)">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="spine-title">{{ tab.title }}</span>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
77
frontend/app/components/ArticleCard.vue
Normal file
77
frontend/app/components/ArticleCard.vue
Normal file
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
/* One language version of an article, as the version switcher lists them. */
|
||||
export interface ArticleVersion {
|
||||
language: string
|
||||
slug: string
|
||||
title: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
export interface ArticleMeta {
|
||||
_id: string
|
||||
slug: string
|
||||
title: string
|
||||
tags?: string[]
|
||||
language?: string
|
||||
/* set on translations: the canonical (original) version's slug */
|
||||
baseSlug?: string | null
|
||||
/* every language version the article exists in, from the single-article GET */
|
||||
versions?: ArticleVersion[]
|
||||
createdAt?: string
|
||||
updatedAt?: string
|
||||
}
|
||||
|
||||
const props = defineProps<{ article: ArticleMeta }>()
|
||||
|
||||
const { t, locale } = useI18n()
|
||||
|
||||
/* shown only when an article steps off the English default — a quiet badge
|
||||
so the shelf's other languages are visible at a glance; when the article
|
||||
exists in several languages, its codes ride together instead */
|
||||
const language = computed(() => articleLanguage(props.article))
|
||||
const languageLabel = computed(() => languageName(language.value))
|
||||
const langs = computed(() => props.article.versions ?? [])
|
||||
const multiLang = computed(() => langs.value.length > 1)
|
||||
const langCodes = computed(() => langs.value.map((v) => v.language.toUpperCase()).join(' · '))
|
||||
const langNames = computed(() => langs.value.map((v) => languageName(v.language)).join(', '))
|
||||
/* where the card leads: the language address of the version it shows */
|
||||
const href = computed(() =>
|
||||
versionHref(identityOf(props.article), { language: language.value, slug: props.article.slug })
|
||||
)
|
||||
|
||||
const updated = computed(() => {
|
||||
const d = props.article.updatedAt ? new Date(props.article.updatedAt) : null
|
||||
if (!d || Number.isNaN(d.getTime())) return ''
|
||||
return d.toLocaleDateString(locale.value, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink :to="href" class="article-card">
|
||||
<div class="card-top">
|
||||
<span class="card-kicker">{{ article.baseSlug ? t('card.kickerTranslation') : t('card.kickerArticle') }}</span>
|
||||
<span v-if="multiLang" class="card-langs" :title="t('card.existsIn', { names: langNames })">
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
{{ langCodes }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="language !== 'en'"
|
||||
class="lang-badge"
|
||||
:title="t('common.writtenIn', { lang: languageLabel })"
|
||||
>{{ languageLabel }}</span
|
||||
>
|
||||
</div>
|
||||
<h3 class="card-title" :lang="language">{{ article.title }}</h3>
|
||||
<div v-if="article.tags?.length" class="tags">
|
||||
<span v-for="tag in article.tags.slice(0, 3)" :key="tag" class="tag">{{ tag }}</span>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<span>{{ href }}</span>
|
||||
<span v-if="updated">{{ t('card.updated', { date: updated }) }}</span>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
786
frontend/app/components/ArticleForm.vue
Normal file
786
frontend/app/components/ArticleForm.vue
Normal file
@@ -0,0 +1,786 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleMeta } from './ArticleCard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
mode: 'create' | 'edit'
|
||||
article?: ArticleMeta & { content?: string }
|
||||
/* Translating: the version being translated. The form opens with its text
|
||||
and publishes a new language version of the same article. */
|
||||
translateFrom?: ArticleMeta & { content?: string }
|
||||
/* Languages the article being translated already exists in */
|
||||
takenLanguages?: string[]
|
||||
}>()
|
||||
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
/* Writing is the one thing on this page that takes an account: the save carries
|
||||
the session token, and a save the API refuses is the editor's own signal that
|
||||
the session has gone while this draft was open. */
|
||||
const { authHeaders } = useAuth()
|
||||
const { show: openSignIn } = useSignInPopup()
|
||||
const { t } = useI18n()
|
||||
|
||||
const original = props.translateFrom ?? null
|
||||
/* the version group the new document joins, shared by every translation */
|
||||
const baseSlug = original ? original.baseSlug || original.slug : ''
|
||||
|
||||
/* an edit is always an article — every language version of it answers to the
|
||||
same identity, which is what its reader and editor addresses speak */
|
||||
const isVersioned = computed(() => props.mode === 'edit' && !!props.article)
|
||||
const identity = props.article ? props.article.baseSlug || props.article.slug : ''
|
||||
|
||||
/* versions under edit -------------------------------------------------
|
||||
Editing an article happens one language at a time: each version keeps its
|
||||
own draft beside the others, so typing French does not overwrite English —
|
||||
switching turns the page, it does not clear it. */
|
||||
interface VersionDraft {
|
||||
language: string
|
||||
/** saved document's slug; null for a version not written yet */
|
||||
slug: string | null
|
||||
title: string
|
||||
tagsText: string
|
||||
content: string
|
||||
/** the text as it was loaded or last saved — what "dirty" compares against */
|
||||
saved: { title: string; tagsText: string; content: string }
|
||||
/** whether the version's full text has arrived from the API */
|
||||
loaded: boolean
|
||||
}
|
||||
|
||||
const drafts = ref<Record<string, VersionDraft>>({})
|
||||
|
||||
function seedDraft(language: string, slug: string | null, doc?: ArticleMeta & { content?: string }): VersionDraft {
|
||||
return {
|
||||
language,
|
||||
slug,
|
||||
title: doc?.title ?? '',
|
||||
tagsText: (doc?.tags ?? []).join(', '),
|
||||
content: doc?.content ?? '',
|
||||
saved: {
|
||||
title: doc?.title ?? '',
|
||||
tagsText: (doc?.tags ?? []).join(', '),
|
||||
content: doc?.content ?? '',
|
||||
},
|
||||
loaded: !!doc,
|
||||
}
|
||||
}
|
||||
|
||||
if (isVersioned.value && props.article) {
|
||||
const doc = props.article
|
||||
const list = doc.versions?.length
|
||||
? doc.versions
|
||||
: [{ language: doc.language ?? 'en', slug: doc.slug, title: doc.title }]
|
||||
for (const v of list) {
|
||||
/* the document already in hand needs no fetch; the rest arrive on demand
|
||||
with only their titles known up front — enough to fill the field while
|
||||
the text comes over */
|
||||
const draft = seedDraft(v.language, v.slug, v.slug === doc.slug ? doc : undefined)
|
||||
if (!draft.loaded && v.title) draft.title = v.title
|
||||
drafts.value[v.language] = draft
|
||||
}
|
||||
}
|
||||
|
||||
/* the language this editor is showing — its address is ?lang= on the route */
|
||||
const initialLang = String(route.query.lang ?? '').trim().toLowerCase()
|
||||
const activeLang = ref(
|
||||
drafts.value[initialLang]?.language ?? props.article?.language ?? 'en'
|
||||
)
|
||||
|
||||
/* one draft is in view and edited through these; the rest wait in `drafts` */
|
||||
const title = ref(activeDraft()?.title ?? original?.title ?? '')
|
||||
const tagsText = ref(activeDraft()?.tagsText ?? (original?.tags ?? []).join(', '))
|
||||
const content = ref(activeDraft()?.content ?? original?.content ?? '')
|
||||
/* the language a fresh (non-versioned) article is written in — the reader's
|
||||
language menu asks for one on the route (/new?translate=x&lang=de): start
|
||||
there, unless the article is already written in it */
|
||||
const requestedLang = String(route.query.lang ?? '').trim().toLowerCase()
|
||||
const preselected =
|
||||
LANGUAGES.some((l) => l.code === requestedLang) &&
|
||||
!(props.takenLanguages ?? []).includes(requestedLang)
|
||||
? requestedLang
|
||||
: ''
|
||||
const language = ref(
|
||||
props.article?.language?.trim().toLowerCase() ||
|
||||
preselected ||
|
||||
nextFreeLanguage(props.takenLanguages ?? [])
|
||||
)
|
||||
|
||||
function activeDraft(): VersionDraft | undefined {
|
||||
return drafts.value[activeLang.value]
|
||||
}
|
||||
|
||||
const versionList = computed(() => Object.values(drafts.value))
|
||||
const freeLanguages = computed(() => LANGUAGES.filter((l) => !drafts.value[l.code]))
|
||||
|
||||
function draftCurrent(d: VersionDraft) {
|
||||
return d.language === activeLang.value
|
||||
? { title: title.value, tagsText: tagsText.value, content: content.value }
|
||||
: { title: d.title, tagsText: d.tagsText, content: d.content }
|
||||
}
|
||||
|
||||
function draftChanged(d: VersionDraft) {
|
||||
if (!d.loaded) return false
|
||||
return JSON.stringify(draftCurrent(d)) !== JSON.stringify(d.saved)
|
||||
}
|
||||
|
||||
const dirtyCount = computed(
|
||||
() => (isVersioned.value ? versionList.value.filter(draftChanged).length : singleDirty.value ? 1 : 0)
|
||||
)
|
||||
|
||||
/* fetch a version's text the first time the editor turns to it */
|
||||
async function hydrate(d: VersionDraft) {
|
||||
if (!d.slug) return
|
||||
/* remember what the fields held on arrival: if anything was typed while the
|
||||
fetch was on its way, the typing wins over what comes back */
|
||||
const untouched = { title: title.value, tagsText: tagsText.value, content: content.value }
|
||||
try {
|
||||
const doc = await $fetch<ArticleMeta & { content?: string }>(
|
||||
`${api}/articles/slug/${encodeURIComponent(d.slug)}`
|
||||
)
|
||||
d.title = doc.title ?? ''
|
||||
d.tagsText = (doc.tags ?? []).join(', ')
|
||||
d.content = doc.content ?? ''
|
||||
d.saved = { title: d.title, tagsText: d.tagsText, content: d.content }
|
||||
d.loaded = true
|
||||
/* if the editor still rests on this version, and nothing was typed into
|
||||
it since, show what arrived */
|
||||
if (
|
||||
d.language === activeLang.value &&
|
||||
JSON.stringify(untouched) ===
|
||||
JSON.stringify({ title: title.value, tagsText: tagsText.value, content: content.value })
|
||||
) {
|
||||
title.value = d.title
|
||||
tagsText.value = d.tagsText
|
||||
content.value = d.content
|
||||
}
|
||||
} catch {
|
||||
/* an unreadable version stays a blank page; saving will say why it failed */
|
||||
}
|
||||
}
|
||||
|
||||
function putDraftFields(d: VersionDraft | undefined) {
|
||||
if (!d) return
|
||||
title.value = d.title
|
||||
tagsText.value = d.tagsText
|
||||
content.value = d.content
|
||||
}
|
||||
|
||||
function stashActive() {
|
||||
const d = activeDraft()
|
||||
if (!d || !d.loaded) return
|
||||
d.title = title.value
|
||||
d.tagsText = tagsText.value
|
||||
d.content = content.value
|
||||
}
|
||||
|
||||
async function switchLang(lang: string) {
|
||||
if (lang === activeLang.value) return
|
||||
stashActive()
|
||||
activeLang.value = lang
|
||||
const d = drafts.value[lang]
|
||||
putDraftFields(d)
|
||||
if (d && !d.loaded) await hydrate(d)
|
||||
void router.replace({ query: { ...route.query, lang } })
|
||||
}
|
||||
|
||||
/* the language dropdown beside the chips -------------------------------- */
|
||||
const addOpen = ref(false)
|
||||
const addWrapEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (addOpen.value && !addWrapEl.value?.contains(e.target as Node)) addOpen.value = false
|
||||
}
|
||||
onMounted(() => document.addEventListener('click', onDocClick))
|
||||
onBeforeUnmount(() => document.removeEventListener('click', onDocClick))
|
||||
|
||||
function addLanguage(code: string) {
|
||||
addOpen.value = false
|
||||
stashActive()
|
||||
/* a fresh version starts empty under the hood and carries the article's
|
||||
tags across — they describe the topic, not the language */
|
||||
drafts.value[code] = seedDraft(code, null, {
|
||||
_id: '',
|
||||
slug: '',
|
||||
title: '',
|
||||
tags: tagsText.value
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(Boolean),
|
||||
content: '',
|
||||
} as ArticleMeta)
|
||||
activeLang.value = code
|
||||
title.value = ''
|
||||
tagsText.value = drafts.value[code]!.tagsText
|
||||
content.value = ''
|
||||
void router.replace({ query: { ...route.query, lang: code } })
|
||||
}
|
||||
|
||||
/* the version in view is fetched on demand, even on first arrival (?lang=fr) */
|
||||
onMounted(() => {
|
||||
const d = activeDraft()
|
||||
if (isVersioned.value && d && !d.loaded) void hydrate(d)
|
||||
})
|
||||
|
||||
const languages = computed(() =>
|
||||
LANGUAGES.map((l) => ({
|
||||
...l,
|
||||
taken: !!original && (props.takenLanguages ?? []).includes(l.code),
|
||||
}))
|
||||
)
|
||||
|
||||
function slugifyText(text: string): string {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
}
|
||||
|
||||
/* the address being edited or published: versions live at /wiki/<lang>/<slug>,
|
||||
the canonical article at /wiki/<slug> — where a canonical retitle moves it */
|
||||
const slugPreview = computed(() => {
|
||||
if (isVersioned.value) {
|
||||
const d = activeDraft()
|
||||
if (d && d.slug && d.slug !== identity) return `/wiki/${d.language}/${identity}`
|
||||
return `/wiki/${slugifyText(title.value) || savedSlug}`
|
||||
}
|
||||
if (original) return `/wiki/${language.value}/${baseSlug}`
|
||||
return `/wiki/${slugifyText(title.value) || '…'}`
|
||||
})
|
||||
|
||||
const savedSlug = props.article?.slug ?? ''
|
||||
|
||||
/* dirty tracking -------------------------------------------------
|
||||
A versioned edit is dirty when any version's text differs from what it was
|
||||
loaded or saved as; the plain create form compares against what it opened
|
||||
with. */
|
||||
interface FormState {
|
||||
title: string
|
||||
tagsText: string
|
||||
content: string
|
||||
}
|
||||
|
||||
function currentState(): FormState {
|
||||
return { title: title.value, tagsText: tagsText.value, content: content.value }
|
||||
}
|
||||
|
||||
const savedState = ref<FormState>(currentState())
|
||||
const singleDirty = computed(
|
||||
() => !isVersioned.value && JSON.stringify(currentState()) !== JSON.stringify(savedState.value)
|
||||
)
|
||||
const dirty = computed(() =>
|
||||
isVersioned.value ? versionList.value.some(draftChanged) : singleDirty.value
|
||||
)
|
||||
|
||||
function markSaved() {
|
||||
savedState.value = currentState()
|
||||
for (const d of versionList.value) if (draftChanged(d)) d.saved = draftCurrent(d)
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
if (!dirty.value) return true
|
||||
return window.confirm(t('editor.leaveGuard'))
|
||||
})
|
||||
|
||||
/* preview ---------------------------------------------------------- */
|
||||
/* The textarea's seed stays partly in code: the LaTeX snippet cannot live
|
||||
verbatim in a message catalog (`{` would read as a parameter), so only the
|
||||
words are translated and the maths rides along here. */
|
||||
const SEED_MATH = '$$ \\zeta(s) = \\sum_{n=1}^{\\infty} \\frac{1}{n^s} $$'
|
||||
const contentPlaceholder = computed(
|
||||
() => `# ${t('editor.seedHeading')}\n\n${t('editor.seedTerms')}\n\n${SEED_MATH}`
|
||||
)
|
||||
|
||||
type Mode = 'write' | 'split' | 'preview'
|
||||
const mode = ref<Mode>('split')
|
||||
const previewContent = computed(() => content.value)
|
||||
|
||||
/* fullscreen -------------------------------------------------------- */
|
||||
const fullscreen = ref(false)
|
||||
const editorBodyEl = ref<HTMLElement | null>(null)
|
||||
let fullscreenAnim: Animation | null = null
|
||||
|
||||
/* FLIP the toggle: measure where the editor currently sits, flip the
|
||||
class (static box -> fixed full-screen box), then animate the old
|
||||
rect into the new one so the editor grows into the screen edges —
|
||||
or shrinks back to its place — instead of snapping. */
|
||||
async function toggleFullscreen() {
|
||||
const el = editorBodyEl.value
|
||||
if (!el) return
|
||||
|
||||
const first = el.getBoundingClientRect() // current visual rect, even mid-animation
|
||||
fullscreenAnim?.cancel()
|
||||
|
||||
fullscreen.value = !fullscreen.value
|
||||
await nextTick()
|
||||
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return
|
||||
const last = el.getBoundingClientRect()
|
||||
|
||||
const entering = fullscreen.value
|
||||
fullscreenAnim = el.animate(
|
||||
[
|
||||
{
|
||||
transformOrigin: 'top left',
|
||||
transform: `translate(${first.left - last.left}px, ${first.top - last.top}px) scale(${first.width / last.width}, ${first.height / last.height})`,
|
||||
borderRadius: entering ? '15px' : '0px',
|
||||
},
|
||||
{ transformOrigin: 'top left', transform: 'none', borderRadius: entering ? '0px' : '15px' },
|
||||
],
|
||||
{ duration: 300, easing: 'cubic-bezier(0.32, 0.72, 0, 1)' }
|
||||
)
|
||||
fullscreenAnim.onfinish = () => {
|
||||
fullscreenAnim = null
|
||||
}
|
||||
}
|
||||
|
||||
/* cheatsheet --------------------------------------------------------- */
|
||||
/* Popups don't stack in this app: SearchModal and SettingsModal hide
|
||||
each other on open. Search, settings and the profile can also be
|
||||
opened from anywhere (header, shortcuts) while the editor shows, so
|
||||
the cheatsheet steps aside whenever one of them opens. */
|
||||
const { open: searchOpen } = useSearch()
|
||||
const { open: settingsOpen } = useSettings()
|
||||
const { open: profileOpen } = useProfileMenu()
|
||||
|
||||
const cheatsheet = ref(false)
|
||||
|
||||
watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) => {
|
||||
if (search || settings || profile) cheatsheet.value = false
|
||||
})
|
||||
|
||||
// keep the page behind from scrolling while the editor fills the screen
|
||||
watch(fullscreen, (on) => {
|
||||
document.body.style.overflow = on ? 'hidden' : ''
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.body.style.overflow = ''
|
||||
})
|
||||
|
||||
/* submit ----------------------------------------------------------- */
|
||||
const saving = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
function parseTags(text: string): string[] {
|
||||
return text
|
||||
.split(',')
|
||||
.map((t) => t.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
async function request(body: Record<string, unknown>, method: 'POST' | 'PUT', url: string) {
|
||||
return $fetch<ArticleMeta>(url, { method, body, headers: authHeaders() })
|
||||
}
|
||||
|
||||
async function save() {
|
||||
errorMsg.value = ''
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
if (isVersioned.value) {
|
||||
/* every language version edited since the last save goes up together;
|
||||
brand-new ones join the article through its identity */
|
||||
const pendingDrafts = versionList.value.filter(draftChanged)
|
||||
if (!pendingDrafts.length) {
|
||||
await router.push(slugPreview.value)
|
||||
return
|
||||
}
|
||||
const missing = pendingDrafts.find((d) => !draftCurrent(d).title.trim())
|
||||
if (missing) {
|
||||
errorMsg.value = t('editor.versionNeedsTitle', { name: languageName(missing.language) })
|
||||
void switchLang(missing.language)
|
||||
return
|
||||
}
|
||||
let savedActive: ArticleMeta | null = null
|
||||
let lastSaved: ArticleMeta | null = null
|
||||
for (const d of pendingDrafts) {
|
||||
const cur = draftCurrent(d)
|
||||
const body = {
|
||||
title: cur.title.trim(),
|
||||
content: cur.content,
|
||||
tags: parseTags(cur.tagsText),
|
||||
/* a version written here joins the article it belongs to */
|
||||
...(d.slug ? {} : { baseSlug: identity, language: d.language }),
|
||||
}
|
||||
const saved = d.slug
|
||||
? await request(body, 'PUT', `${api}/articles/slug/${encodeURIComponent(d.slug)}`)
|
||||
: await request(body, 'POST', `${api}/articles`)
|
||||
d.slug = saved.slug
|
||||
d.saved = cur
|
||||
lastSaved = saved
|
||||
if (d.language === activeLang.value) savedActive = saved
|
||||
}
|
||||
/* leave on the page that was in view — the answer itself carries where
|
||||
it lives now, so a canonical retitle follows it instead of stranding */
|
||||
const target = savedActive ?? lastSaved
|
||||
await router.push(target ? versionHref(identityOf(target), target) : '/')
|
||||
return
|
||||
}
|
||||
|
||||
if (!title.value.trim()) {
|
||||
errorMsg.value = t('editor.titleRequired')
|
||||
return
|
||||
}
|
||||
const body = {
|
||||
title: title.value.trim(),
|
||||
content: content.value,
|
||||
language: language.value,
|
||||
/* a translation joins the article it translates rather than standing alone */
|
||||
...(original ? { baseSlug } : {}),
|
||||
tags: parseTags(tagsText.value),
|
||||
}
|
||||
const url =
|
||||
props.mode === 'create'
|
||||
? `${api}/articles`
|
||||
: `${api}/articles/slug/${encodeURIComponent(savedSlug)}`
|
||||
const saved = await request(body, props.mode === 'create' ? 'POST' : 'PUT', url)
|
||||
// prevent the leave-guard from firing on our own navigation
|
||||
markSaved()
|
||||
await router.push(versionHref(identityOf(saved), saved))
|
||||
} catch (err: any) {
|
||||
/* A session the API won't take any more. Signing out here would unmount this
|
||||
very form — and the draft in it — so instead the sign-in popup opens over
|
||||
the editor: whatever is written stays put, and signing back in replaces the
|
||||
dead token so the next save goes through. */
|
||||
if (err?.status === 401 || err?.statusCode === 401) {
|
||||
errorMsg.value = ''
|
||||
openSignIn({
|
||||
notice: err?.data?.message || t('editor.sessionExpired'),
|
||||
})
|
||||
return
|
||||
}
|
||||
errorMsg.value = err?.data?.message || t('editor.saveFailed')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* discard ---------------------------------------------------------- */
|
||||
/* The destructive step lives behind the same kind of popup as settings and
|
||||
the cheatsheet, not a browser alert. */
|
||||
const discardOpen = ref(false)
|
||||
const discardButtonEl = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
const discardMessage = computed(() =>
|
||||
props.mode === 'create'
|
||||
? t('editor.discardNew')
|
||||
: dirtyCount.value > 1
|
||||
? t('editor.discardVersions', { n: dirtyCount.value })
|
||||
: t('editor.discardOne', {
|
||||
title: activeDraft()?.saved.title || savedState.value.title || t('editor.thisArticle'),
|
||||
})
|
||||
)
|
||||
|
||||
function askDiscard() {
|
||||
cheatsheet.value = false // popups don't stack in this app
|
||||
discardOpen.value = true
|
||||
}
|
||||
|
||||
function closeDiscardPrompt() {
|
||||
discardOpen.value = false
|
||||
discardButtonEl.value?.focus() // the popup it replaces was removed from the page
|
||||
}
|
||||
|
||||
/* Throw away everything typed since the last save and leave the editor — in
|
||||
every version, and versions added since the last save come off entirely.
|
||||
The baseline is what the form opened with, restoring which keeps our own
|
||||
leave-guard quiet; the way out leads back to the article or the search page. */
|
||||
async function discard() {
|
||||
discardOpen.value = false
|
||||
if (isVersioned.value) {
|
||||
for (const [lang, d] of Object.entries(drafts.value)) {
|
||||
if (!d.slug) delete drafts.value[lang] // a version never saved: gone whole
|
||||
else putDraftFieldsBack(d) // the rest return to their saved text
|
||||
}
|
||||
const back = activeDraft() ?? props.article
|
||||
if (activeDraft()) putDraftFields(activeDraft()) // the fields follow the version in view
|
||||
errorMsg.value = ''
|
||||
const target = back
|
||||
? versionHref(identity, { language: back.language ?? 'en', slug: back.slug })
|
||||
: '/'
|
||||
await router.push(target)
|
||||
return
|
||||
}
|
||||
const base = savedState.value
|
||||
title.value = base.title
|
||||
tagsText.value = base.tagsText
|
||||
content.value = base.content
|
||||
errorMsg.value = ''
|
||||
await router.push(
|
||||
props.mode === 'create' ? (original ? versionHref(baseSlug, original) : '/') : `/wiki/${savedSlug}`
|
||||
)
|
||||
}
|
||||
|
||||
function putDraftFieldsBack(d: VersionDraft) {
|
||||
// reset a draft's working copy to its saved text (the fields follow if active)
|
||||
d.title = d.saved.title
|
||||
d.tagsText = d.saved.tagsText
|
||||
d.content = d.saved.content
|
||||
}
|
||||
|
||||
/* Tab inserts spaces in the textarea ------------------------------- */
|
||||
function onTabKeydown(e: KeyboardEvent) {
|
||||
const el = e.target as HTMLTextAreaElement
|
||||
if (e.key !== 'Tab') return
|
||||
e.preventDefault()
|
||||
const { selectionStart: s, selectionEnd: end } = el
|
||||
content.value = content.value.slice(0, s) + ' ' + content.value.slice(end)
|
||||
nextTick(() => {
|
||||
el.selectionStart = el.selectionEnd = s + 2
|
||||
})
|
||||
}
|
||||
|
||||
// Ctrl/Cmd+S saves; Esc closes the topmost popup first, then leaves fullscreen
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
if (addOpen.value) {
|
||||
addOpen.value = false
|
||||
return
|
||||
}
|
||||
if (discardOpen.value) {
|
||||
closeDiscardPrompt()
|
||||
return
|
||||
}
|
||||
if (cheatsheet.value) {
|
||||
cheatsheet.value = false
|
||||
return
|
||||
}
|
||||
if (fullscreen.value) {
|
||||
void toggleFullscreen()
|
||||
return
|
||||
}
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
|
||||
e.preventDefault()
|
||||
if (!saving.value) save()
|
||||
}
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form class="editor-page" @submit.prevent="save">
|
||||
<div class="editor-head">
|
||||
<h1>{{ mode === 'create' ? (original ? t('editor.translateTitle') : t('editor.newTitle')) : t('editor.editTitle') }}</h1>
|
||||
<div class="editor-head-actions">
|
||||
<button
|
||||
ref="discardButtonEl"
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
:disabled="!dirty"
|
||||
:title="t('editor.discardHint')"
|
||||
@click="askDiscard"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
</svg>
|
||||
{{ t('editor.discard') }}
|
||||
</button>
|
||||
<NuxtLink
|
||||
v-if="mode === 'edit' && savedSlug"
|
||||
:to="versionHref(identity, { language: article?.language, slug: savedSlug })"
|
||||
class="btn btn-ghost"
|
||||
>
|
||||
{{ t('common.cancel') }}
|
||||
</NuxtLink>
|
||||
<button class="btn btn-primary" type="submit" :disabled="saving">
|
||||
{{ saving
|
||||
? t('editor.saving')
|
||||
: original
|
||||
? t('editor.publishTranslation')
|
||||
: mode === 'create'
|
||||
? t('editor.publishArticle')
|
||||
: dirtyCount > 1
|
||||
? t('editor.saveVersions', { n: dirtyCount })
|
||||
: t('editor.saveChanges') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="original" class="translate-banner">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
<span>
|
||||
{{ t('editor.translating') }}
|
||||
<NuxtLink :to="versionHref(baseSlug, original)">{{ original.title }}</NuxtLink>
|
||||
{{ t('editor.translatingTail') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- the article's language versions: turn the editor to another language,
|
||||
or add one — unsaved text waits on its own version's page -->
|
||||
<div v-if="isVersioned" class="version-bar">
|
||||
<span class="version-bar-label">{{ t('editor.languages') }}</span>
|
||||
<button
|
||||
v-for="d in versionList"
|
||||
:key="d.language"
|
||||
type="button"
|
||||
class="lang-chip"
|
||||
:class="{ active: d.language === activeLang }"
|
||||
:lang="d.language"
|
||||
:aria-pressed="d.language === activeLang"
|
||||
:title="t('editor.editVersion', { name: languageName(d.language) })"
|
||||
@click="switchLang(d.language)"
|
||||
>
|
||||
{{ languageName(d.language) }}
|
||||
<span v-if="draftChanged(d)" class="dirty-dot" :title="t('editor.unsavedChanges')" />
|
||||
</button>
|
||||
<div v-if="freeLanguages.length" ref="addWrapEl" class="version-add">
|
||||
<button
|
||||
type="button"
|
||||
class="lang-chip version-add-btn"
|
||||
:aria-expanded="String(addOpen)"
|
||||
:title="t('editor.addLanguageHint')"
|
||||
@click="addOpen = !addOpen"
|
||||
>
|
||||
{{ t('editor.addLanguage') }}
|
||||
</button>
|
||||
<div v-if="addOpen" class="version-dropdown" role="listbox">
|
||||
<button
|
||||
v-for="l in freeLanguages"
|
||||
:key="l.code"
|
||||
type="button"
|
||||
class="topic-option"
|
||||
role="option"
|
||||
:lang="l.code"
|
||||
@click="addLanguage(l.code)"
|
||||
>
|
||||
<span>{{ l.name }}</span>
|
||||
<span class="topic-count">{{ l.code.toUpperCase() }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="article-title">{{ t('editor.titleLabel') }}</label>
|
||||
<input
|
||||
id="article-title"
|
||||
v-model="title"
|
||||
type="text"
|
||||
class="title-input"
|
||||
:placeholder="t('editor.titlePlaceholder')"
|
||||
autofocus
|
||||
/>
|
||||
<div v-if="title.trim()" class="slug-preview">
|
||||
{{ t('editor.urlLabel') }} <code>{{ slugPreview }}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<div class="field">
|
||||
<label for="article-tags">
|
||||
{{ t('editor.tagsLabel') }} <span class="hint">{{ t('editor.tagsHint') }}</span>
|
||||
</label>
|
||||
<input id="article-tags" v-model="tagsText" type="text" :placeholder="t('editor.tagsPlaceholder')" />
|
||||
</div>
|
||||
|
||||
<div v-if="!isVersioned" class="field">
|
||||
<label for="article-language">
|
||||
{{ t('editor.languageLabel') }} <span class="hint">{{ t('editor.languageHint') }}</span>
|
||||
</label>
|
||||
<select id="article-language" v-model="language">
|
||||
<option
|
||||
v-for="l in languages"
|
||||
:key="l.code"
|
||||
:value="l.code"
|
||||
:lang="l.code"
|
||||
:disabled="l.taken"
|
||||
:title="l.taken ? t('editor.languageTaken') : undefined"
|
||||
>
|
||||
{{ l.name }}{{ l.taken ? ` ${t('editor.alreadyWritten')}` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="article-content" :lang="isVersioned ? activeLang : undefined">
|
||||
{{ t('editor.contentLabel') }} <span class="hint">{{ t('editor.contentHint') }}</span>
|
||||
</label>
|
||||
<div ref="editorBodyEl" class="editor-body" :class="{ 'is-fullscreen': fullscreen }">
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" class="mode-btn" :class="{ active: mode === 'write' }" @click="mode = 'write'">{{ t('editor.modeWrite') }}</button>
|
||||
<button type="button" class="mode-btn" :class="{ active: mode === 'split' }" @click="mode = 'split'">{{ t('editor.modeSplit') }}</button>
|
||||
<button type="button" class="mode-btn" :class="{ active: mode === 'preview' }" @click="mode = 'preview'">{{ t('editor.modePreview') }}</button>
|
||||
<div class="toolbar-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="mode-btn toolbar-icon-btn"
|
||||
:class="{ active: cheatsheet }"
|
||||
:title="t('editor.cheatsheetHint')"
|
||||
:aria-label="t('editor.cheatsheet')"
|
||||
:aria-pressed="cheatsheet"
|
||||
@click="cheatsheet = !cheatsheet"
|
||||
>
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" />
|
||||
<path d="M12 17h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mode-btn toolbar-icon-btn"
|
||||
:title="fullscreen ? t('editor.exitFullscreenHint') : t('editor.fullscreen')"
|
||||
:aria-label="fullscreen ? t('editor.exitFullscreen') : t('editor.enterFullscreen')"
|
||||
:aria-pressed="fullscreen"
|
||||
@click="toggleFullscreen"
|
||||
>
|
||||
<svg v-if="!fullscreen" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
||||
<path d="M21 8V5a2 2 0 0 0-2-2h-3" />
|
||||
<path d="M3 16v3a2 2 0 0 0 2 2h3" />
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
||||
</svg>
|
||||
<svg v-else width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M8 3v3a2 2 0 0 1-2 2H3" />
|
||||
<path d="M21 8h-3a2 2 0 0 1-2-2V3" />
|
||||
<path d="M3 16h3a2 2 0 0 1 2 2v3" />
|
||||
<path d="M16 21v-3a2 2 0 0 1 2-2h3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="editor-grid" :class="`mode-${mode}`">
|
||||
<div class="write-pane">
|
||||
<textarea
|
||||
id="article-content"
|
||||
v-model="content"
|
||||
class="md-input"
|
||||
:lang="isVersioned ? activeLang : undefined"
|
||||
:placeholder="contentPlaceholder"
|
||||
spellcheck="false"
|
||||
@keydown="onTabKeydown"
|
||||
/>
|
||||
</div>
|
||||
<div class="preview-pane">
|
||||
<div class="preview-label">{{ t('editor.livePreview') }}</div>
|
||||
<MarkdownView :content="previewContent || `*${t('editor.noPreview')}*`" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="form-error">{{ errorMsg }}</p>
|
||||
|
||||
<EditorCheatsheet :open="cheatsheet" @close="cheatsheet = false" />
|
||||
|
||||
<ConfirmModal
|
||||
:open="discardOpen"
|
||||
:title="t('editor.discardConfirm')"
|
||||
:message="discardMessage"
|
||||
:confirm-label="t('editor.discard')"
|
||||
:cancel-label="t('editor.keepEditing')"
|
||||
@confirm="discard"
|
||||
@cancel="closeDiscardPrompt"
|
||||
/>
|
||||
</form>
|
||||
</template>
|
||||
143
frontend/app/components/ArticleLanguageMenu.vue
Normal file
143
frontend/app/components/ArticleLanguageMenu.vue
Normal file
@@ -0,0 +1,143 @@
|
||||
<script setup lang="ts">
|
||||
/* The reader's language menu: one control for turning the article to another
|
||||
language — and for writing one it doesn't exist in yet. Every language the
|
||||
wiki keeps is listed: the ones this article speaks navigate to that
|
||||
version, the rest lead straight into the translate editor with the language
|
||||
already chosen. */
|
||||
import type { ArticleVersion } from './ArticleCard.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
/** the article the versions belong to — its canonical slug */
|
||||
identity: string
|
||||
versions: ArticleVersion[]
|
||||
/** the version in view and the language it is written in */
|
||||
currentSlug: string
|
||||
language: string
|
||||
/** the slug the translate flow answers to: the canonical document's, or
|
||||
this version's own when the canonical one is gone */
|
||||
editorSlug: string
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const { t } = useI18n()
|
||||
const { open, hide } = useLanguageMenu()
|
||||
const { open: searchOpen } = useSearch()
|
||||
const { open: settingsOpen } = useSettings()
|
||||
const { open: profileOpen } = useProfileMenu()
|
||||
const { loggedIn } = useAuth()
|
||||
|
||||
/* the languages this article exists in, and the ones still left to write —
|
||||
writing takes an account, so a visitor who isn't signed in only ever sees
|
||||
the versions there are to read */
|
||||
const existing = computed(() => props.versions)
|
||||
const missing = computed(() =>
|
||||
loggedIn.value
|
||||
? LANGUAGES.filter((l) => !props.versions.some((v) => (v.language || 'en') === l.code))
|
||||
: []
|
||||
)
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
/* the menu is one of the app's popups: closed by a click outside, by esc, by
|
||||
navigating, and by search or settings opening over it */
|
||||
const wrapEl = ref<HTMLElement | null>(null)
|
||||
|
||||
function onDocClick(e: MouseEvent) {
|
||||
if (open.value && !wrapEl.value?.contains(e.target as Node)) hide()
|
||||
}
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open.value) hide()
|
||||
}
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', onDocClick)
|
||||
window.addEventListener('keydown', onKeydown)
|
||||
})
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', onDocClick)
|
||||
window.removeEventListener('keydown', onKeydown)
|
||||
})
|
||||
|
||||
watch(() => route.fullPath, hide)
|
||||
watch([searchOpen, settingsOpen, profileOpen], ([search, settings, profile]) => {
|
||||
if (search || settings || profile) hide()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapEl" class="lang-menu">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
:aria-expanded="String(open)"
|
||||
:title="loggedIn ? t('langMenu.hintSignedIn') : t('langMenu.hint')"
|
||||
@click="toggle"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
<span :lang="language">{{ languageName(language) }}</span>
|
||||
<svg class="lang-menu-caret" width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="open" class="lang-menu-dropdown" role="menu" :aria-label="t('langMenu.aria')">
|
||||
<template v-if="existing.length">
|
||||
<div class="lang-menu-section">{{ t('langMenu.readIn') }}</div>
|
||||
<NuxtLink
|
||||
v-for="v in existing"
|
||||
:key="v.slug"
|
||||
:to="versionHref(identity, v)"
|
||||
class="lang-menu-item"
|
||||
:lang="v.language"
|
||||
role="menuitem"
|
||||
:aria-current="v.slug === currentSlug ? 'true' : undefined"
|
||||
@click="hide"
|
||||
>
|
||||
<span class="lmi-name">{{ languageName(v.language) }}</span>
|
||||
<span class="lmi-sub">{{ v.title }}</span>
|
||||
<svg
|
||||
v-if="v.slug === currentSlug"
|
||||
class="lmi-check"
|
||||
width="13"
|
||||
height="13"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2.6"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 6 9 17l-5-5" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
|
||||
<template v-if="missing.length">
|
||||
<div class="lang-menu-section" :class="{ 'lang-menu-gap': existing.length }">
|
||||
{{ t('langMenu.writeIn') }}
|
||||
</div>
|
||||
<NuxtLink
|
||||
v-for="l in missing"
|
||||
:key="l.code"
|
||||
:to="{ path: '/new', query: { translate: editorSlug, lang: l.code } }"
|
||||
class="lang-menu-item lang-menu-write"
|
||||
:lang="l.code"
|
||||
role="menuitem"
|
||||
:title="t('langMenu.writeArticleIn', { name: l.name })"
|
||||
@click="hide"
|
||||
>
|
||||
<span class="lmi-name">{{ l.name }}</span>
|
||||
<svg class="lmi-pencil" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
146
frontend/app/components/ArticlePane.vue
Normal file
146
frontend/app/components/ArticlePane.vue
Normal file
@@ -0,0 +1,146 @@
|
||||
<script setup lang="ts">
|
||||
/* One page of the open book. Its left edge is a spine — the title, set
|
||||
vertically, with the × that closes the page — and the article itself
|
||||
scrolls on its own inside the page. A newer page slides over this one and
|
||||
leaves only the spine showing, the way a stack of read pages does; click
|
||||
anywhere on the page — or its spine — to turn back to it.
|
||||
|
||||
Everything but the spine of a page that isn't in view is `inert`: it can
|
||||
still be seen while you slide the stack sideways, but it doesn't take
|
||||
focus or clicks — the pane's own click handler catches those instead, so
|
||||
one click turns to the page before its links do anything. */
|
||||
const props = defineProps<{
|
||||
slug: string
|
||||
index: number
|
||||
active: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
activate: []
|
||||
close: []
|
||||
remove: []
|
||||
}>()
|
||||
|
||||
const { get, unreadable } = useArticleLibrary()
|
||||
const { t, locale } = useI18n()
|
||||
const { th } = useAppLocale()
|
||||
|
||||
const article = computed(() => get(props.slug))
|
||||
const failed = computed(() => props.slug in unreadable.value)
|
||||
const title = computed(() => article.value?.title ?? props.slug)
|
||||
const language = computed(() => articleLanguage(article.value ?? {}))
|
||||
const languageLabel = computed(() => languageName(language.value))
|
||||
/* every language this article exists in, the one in view included */
|
||||
const versions = computed(() => article.value?.versions ?? [])
|
||||
/* the article behind this document, and the slug its editor answers to:
|
||||
the identity when the canonical version is still around, this document's
|
||||
own slug when only translations of it remain */
|
||||
const identity = computed(() => identityOf(article.value ?? { slug: props.slug }))
|
||||
const editorSlug = computed(() =>
|
||||
versions.value.some((v) => v.slug === identity.value) ? identity.value : props.slug
|
||||
)
|
||||
|
||||
const updated = computed(() => {
|
||||
const d = article.value?.updatedAt ? new Date(article.value.updatedAt) : null
|
||||
if (!d || Number.isNaN(d.getTime())) return ''
|
||||
return d.toLocaleDateString(locale.value, { dateStyle: 'medium' })
|
||||
})
|
||||
|
||||
function turnToThisPage() {
|
||||
if (!props.active) emit('activate')
|
||||
}
|
||||
|
||||
/* a tag read at the foot of the article: “probability” → “Probability” */
|
||||
function topicLabel(tag: string) {
|
||||
const t = tag.trim()
|
||||
return t.charAt(0).toUpperCase() + t.slice(1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section
|
||||
class="book-pane"
|
||||
:class="{ active }"
|
||||
:data-slug="slug"
|
||||
:data-index="index"
|
||||
:style="{ '--i': index }"
|
||||
:aria-label="t('pane.aria', { title })"
|
||||
@click="turnToThisPage"
|
||||
>
|
||||
<div
|
||||
class="pane-spine"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-current="active ? 'page' : undefined"
|
||||
:aria-disabled="active"
|
||||
:title="t('pane.spineHint', { title })"
|
||||
@click.stop="turnToThisPage"
|
||||
@keydown.enter.prevent="turnToThisPage"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="spine-close"
|
||||
:aria-label="t('common.closeTab')"
|
||||
@click.stop="emit('close')"
|
||||
>
|
||||
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="spine-title">{{ title }}</span>
|
||||
</div>
|
||||
|
||||
<div class="pane-body" :inert="active ? null : ''">
|
||||
<div class="pane-scroll">
|
||||
<div v-if="failed && !article" class="empty">
|
||||
<div class="glyph">!</div>
|
||||
<h3>{{ t('pane.readFailTitle') }}</h3>
|
||||
<p v-html="th('pane.readFailText', { path: `/wiki/${slug}` })"></p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!article" class="pane-skeleton">
|
||||
<div class="skeleton" style="height: 36px; width: 52%; margin-bottom: 14px" />
|
||||
<div class="skeleton" style="height: 18px; width: 78%; margin-bottom: 36px; border-radius: 8px" />
|
||||
<div class="skeleton" style="height: 220px" />
|
||||
</div>
|
||||
|
||||
<article v-else class="article-page">
|
||||
<header class="article-head">
|
||||
<div class="article-eyebrow">
|
||||
<span v-if="updated">{{ t('pane.lastEdited', { date: updated }) }}</span>
|
||||
</div>
|
||||
<div class="article-title-row">
|
||||
<h1 class="article-title" :lang="language">{{ article.title }}</h1>
|
||||
<div v-if="active" class="article-actions">
|
||||
<ArticleActionsMenu
|
||||
:identity="identity"
|
||||
:versions="versions"
|
||||
:current-slug="slug"
|
||||
:language="language"
|
||||
:editor-slug="editorSlug"
|
||||
@remove="emit('remove')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="article-body">
|
||||
<MarkdownView :content="article.content ?? ''" :namespace="slug" intercept-links />
|
||||
</div>
|
||||
|
||||
<footer v-if="article.tags?.length" class="article-topics">
|
||||
<span class="article-topics-label">{{ t('pane.topicsLabel') }}</span>
|
||||
<NuxtLink
|
||||
v-for="t in article.tags"
|
||||
:key="t"
|
||||
:to="{ path: '/articles/', query: { tag: t } }"
|
||||
class="topic-link"
|
||||
>
|
||||
{{ topicLabel(t) }}
|
||||
</NuxtLink>
|
||||
</footer>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
44
frontend/app/components/AuthGate.vue
Normal file
44
frontend/app/components/AuthGate.vue
Normal file
@@ -0,0 +1,44 @@
|
||||
<script setup lang="ts">
|
||||
/* Reading Mathew is open to everyone; writing it takes an account. The editor
|
||||
pages wrap their form in this, so a visitor who follows “New article” without
|
||||
one lands somewhere that says what is missing and offers the way in, instead
|
||||
of a form that quietly refuses to save. Nothing is fetched or mounted behind
|
||||
the gate until there is a session. */
|
||||
const { loggedIn } = useAuth()
|
||||
const { show } = useSignInPopup()
|
||||
const { t } = useI18n()
|
||||
|
||||
const props = defineProps<{
|
||||
heading?: string
|
||||
text?: string
|
||||
}>()
|
||||
|
||||
/* the defaults live here rather than in withDefaults so they follow the
|
||||
interface language as it changes */
|
||||
const headingText = computed(() => props.heading ?? t('gate.heading'))
|
||||
const textText = computed(() => props.text ?? t('gate.text'))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot v-if="loggedIn" />
|
||||
|
||||
<section v-else class="auth-gate" aria-labelledby="auth-gate-heading">
|
||||
<span class="auth-gate-mark" aria-hidden="true">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="8" cy="15" r="4" />
|
||||
<path d="m10.8 12.2 8.2-8.2 2 2-2 2 1.5 1.5-2 2-1.5-1.5-1.7 1.7" />
|
||||
</svg>
|
||||
</span>
|
||||
<h1 id="auth-gate-heading" class="auth-gate-title">{{ headingText }}</h1>
|
||||
<p class="auth-gate-text">{{ textText }}</p>
|
||||
<div class="auth-gate-actions">
|
||||
<button type="button" class="btn btn-primary" @click="show({ mode: 'signin' })">
|
||||
{{ t('auth.signin') }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-ghost" @click="show({ mode: 'signup' })">
|
||||
{{ t('auth.signup') }}
|
||||
</button>
|
||||
</div>
|
||||
<p class="auth-gate-fine">{{ t('gate.fine') }}</p>
|
||||
</section>
|
||||
</template>
|
||||
194
frontend/app/components/AuthModal.vue
Normal file
194
frontend/app/components/AuthModal.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
/* Both ways in — into an existing account or a new one — in a single popup,
|
||||
drawn and closed in the same shape as the settings popup: esc, the backdrop,
|
||||
the ✕, or moving to another page. Whoever opens it chooses a tab and may leave
|
||||
a line of explanation above the form (see useSignInPopup); the editor uses
|
||||
that when a save comes back saying the session has gone.
|
||||
|
||||
Signing in is enough on its own: the editor pages show their form as soon as
|
||||
there is a session, so the page behind this popup is already ready when it
|
||||
closes — whatever was being written is still there. */
|
||||
const { open, mode, notice, show, hide } = useSignInPopup()
|
||||
const { hide: hideSearch } = useSearch()
|
||||
const { hide: hideSettings } = useSettings()
|
||||
const { hide: hideProfile } = useProfileMenu()
|
||||
const { signIn, signUp } = useAuth()
|
||||
const { applyAccount } = useAppLocale()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const errorMsg = ref('')
|
||||
const busy = ref(false)
|
||||
const panelEl = ref<HTMLElement | null>(null)
|
||||
const usernameEl = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const isSignUp = computed(() => mode.value === 'signup')
|
||||
const heading = computed(() => (isSignUp.value ? t('auth.signup') : t('auth.signin')))
|
||||
const submitLabel = computed(() =>
|
||||
busy.value ? t('auth.busy') : isSignUp.value ? t('auth.signupSubmit') : t('auth.signin')
|
||||
)
|
||||
|
||||
watch(
|
||||
open,
|
||||
async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
// opening this shouldn't leave another popup stacked behind it
|
||||
hideSearch()
|
||||
hideSettings()
|
||||
hideProfile()
|
||||
errorMsg.value = ''
|
||||
await nextTick()
|
||||
usernameEl.value?.focus()
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
/* Switching tabs keeps the username — the same person either way — but not the
|
||||
password, which is a different secret in each direction. */
|
||||
function setMode(next: SignInMode) {
|
||||
if (mode.value === next) return
|
||||
show({ mode: next })
|
||||
password.value = ''
|
||||
errorMsg.value = ''
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (busy.value) return
|
||||
errorMsg.value = ''
|
||||
busy.value = true
|
||||
try {
|
||||
// the account decides the interface language — let it overrule the device
|
||||
const user = isSignUp.value ? await signUp(username.value, password.value) : await signIn(username.value, password.value)
|
||||
applyAccount(user?.locale)
|
||||
password.value = ''
|
||||
hide()
|
||||
} catch (err: any) {
|
||||
errorMsg.value = err?.data?.message || t('common.apiUnreachable')
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* --- esc closes, navigation closes --- */
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open.value) hide()
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (open.value) hide()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="auth-popup">
|
||||
<div v-if="open" class="auth-overlay" @click.self="hide()">
|
||||
<div
|
||||
ref="panelEl"
|
||||
class="auth-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="heading"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="auth-modal-head">
|
||||
<h2 class="auth-title">{{ heading }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-icon-btn"
|
||||
:aria-label="t('common.close')"
|
||||
:title="t('common.close')"
|
||||
@click="hide()"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="auth-modal-body">
|
||||
<div class="auth-tabs" role="tablist" :aria-label="t('auth.tabsAria')">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="auth-tab"
|
||||
:class="{ active: !isSignUp }"
|
||||
:aria-selected="!isSignUp"
|
||||
@click="setMode('signin')"
|
||||
>
|
||||
{{ t('auth.signin') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="auth-tab"
|
||||
:class="{ active: isSignUp }"
|
||||
:aria-selected="isSignUp"
|
||||
@click="setMode('signup')"
|
||||
>
|
||||
{{ t('auth.signupTab') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-if="notice" class="auth-notice">{{ notice }}</p>
|
||||
|
||||
<form @submit.prevent="submit">
|
||||
<div class="field">
|
||||
<label for="auth-username">{{ t('auth.username') }}</label>
|
||||
<input
|
||||
id="auth-username"
|
||||
ref="usernameEl"
|
||||
v-model="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
:placeholder="t('auth.usernamePlaceholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label for="auth-password">
|
||||
{{ t('auth.password') }} <span class="hint">{{ t('auth.passwordHint') }}</span>
|
||||
</label>
|
||||
<input
|
||||
id="auth-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
:autocomplete="isSignUp ? 'new-password' : 'current-password'"
|
||||
minlength="8"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="errorMsg" class="form-error">{{ errorMsg }}</p>
|
||||
|
||||
<button type="submit" class="btn btn-primary auth-submit" :disabled="busy">
|
||||
{{ submitLabel }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<p class="auth-switch">
|
||||
<template v-if="isSignUp">
|
||||
{{ t('auth.haveAccount') }}
|
||||
<button type="button" @click="setMode('signin')">{{ t('auth.signin') }}</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ t('auth.newToMathew') }}
|
||||
<button type="button" @click="setMode('signup')">{{ t('auth.signup') }}</button>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
62
frontend/app/components/ConfirmModal.vue
Normal file
62
frontend/app/components/ConfirmModal.vue
Normal file
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
/* Yes/no popup, same shape as the settings and cheatsheet modals: teleported
|
||||
overlay, dismissed by the backdrop or the safe button. Esc is left to the
|
||||
caller, which is how the editor already orders its own popups. */
|
||||
const props = defineProps<{
|
||||
open: boolean
|
||||
title?: string
|
||||
message?: string
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/* the defaults live here rather than in withDefaults so they follow the
|
||||
interface language as it changes */
|
||||
const titleText = computed(() => props.title ?? t('common.areYouSure'))
|
||||
const confirmText = computed(() => props.confirmLabel ?? t('common.confirm'))
|
||||
const cancelText = computed(() => props.cancelLabel ?? t('common.cancel'))
|
||||
|
||||
const emit = defineEmits<{ confirm: []; cancel: [] }>()
|
||||
|
||||
const cancelButtonEl = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
/* land on the safe button when the popup opens, so Enter can never trigger
|
||||
the destructive action by accident */
|
||||
watch(
|
||||
() => props.open,
|
||||
async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
await nextTick()
|
||||
cancelButtonEl.value?.focus()
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="confirm-popup">
|
||||
<div v-if="open" class="confirm-overlay" @click.self="emit('cancel')">
|
||||
<div class="confirm-modal" role="dialog" aria-modal="true" :aria-label="titleText">
|
||||
<h2 class="confirm-title">{{ titleText }}</h2>
|
||||
<p v-if="message" class="confirm-message">{{ message }}</p>
|
||||
<div class="confirm-actions">
|
||||
<button
|
||||
ref="cancelButtonEl"
|
||||
type="button"
|
||||
class="btn btn-ghost"
|
||||
@click="emit('cancel')"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" @click="emit('confirm')">
|
||||
{{ confirmText }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
145
frontend/app/components/EditorCheatsheet.vue
Normal file
145
frontend/app/components/EditorCheatsheet.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<script setup lang="ts">
|
||||
/* Writing-help popup for the editor, opened by the "?" in the toolbar.
|
||||
Same shape as SettingsModal: teleported overlay, closes on backdrop
|
||||
click or the × (Esc is handled by ArticleForm, which also keeps it in
|
||||
front of the fullscreen toggle). */
|
||||
defineProps<{ open: boolean }>()
|
||||
const emit = defineEmits<{ close: [] }>()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="cheatsheet-popup">
|
||||
<div v-if="open" class="cheatsheet-overlay" @click.self="emit('close')">
|
||||
<div
|
||||
class="cheatsheet-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="t('cheatsheet.title')"
|
||||
>
|
||||
<div class="cheatsheet-head">
|
||||
<h2 class="cheatsheet-title">{{ t('cheatsheet.title') }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-icon-btn"
|
||||
:aria-label="t('cheatsheet.closeAria')"
|
||||
:title="t('common.close')"
|
||||
@click="emit('close')"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="cheatsheet-body">
|
||||
<section class="cheatsheet-group">
|
||||
<h3 class="cheatsheet-group-title">{{ t('cheatsheet.links') }}</h3>
|
||||
<div class="cheat-row">
|
||||
<code>[[Riemann zeta function]]</code>
|
||||
<span>{{ t('cheatsheet.linkByTitle') }}</span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>[[Cauchy–Schwarz|the inequality]]</code>
|
||||
<span>{{ t('cheatsheet.linkOwnWording') }}</span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>[text](https://example.org)</code>
|
||||
<span>{{ t('cheatsheet.linkWebPage') }}</span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>https://example.org</code>
|
||||
<span>{{ t('cheatsheet.bareUrl') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cheatsheet-group">
|
||||
<h3 class="cheatsheet-group-title">{{ t('cheatsheet.math') }}</h3>
|
||||
<div class="cheat-row">
|
||||
<code>$e^{i\pi}+1=0$</code>
|
||||
<span>{{ t('cheatsheet.inline') }} — <KatexSpan tex="\,e^{i\pi}+1=0\," /></span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>$$\frac{\pi^2}{6}$$</code>
|
||||
<span>{{ t('cheatsheet.display') }} — <KatexSpan tex="\,\tfrac{\pi^2}{6}\," /></span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>\R \N \Z \Q \C</code>
|
||||
<span>{{ t('cheatsheet.numberSets') }} — <KatexSpan tex="\,\R\ \N\ \Z\ \Q\ \C\," /></span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>\sum \int \frac{a}{b} \sqrt{x}</code>
|
||||
<span>{{ t('cheatsheet.sumsEtc') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cheatsheet-group">
|
||||
<h3 class="cheatsheet-group-title">{{ t('cheatsheet.text') }}</h3>
|
||||
<div class="cheat-row">
|
||||
<code>**bold** *italic*</code>
|
||||
<span v-html="t('cheatsheet.boldItalic')"></span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>~~struck out~~</code>
|
||||
<span v-html="t('cheatsheet.struckOut')"></span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>`inline code`</code>
|
||||
<span>{{ t('cheatsheet.shadedCode') }}</span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>```python … ```</code>
|
||||
<span>{{ t('cheatsheet.fencedBlock') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cheatsheet-group">
|
||||
<h3 class="cheatsheet-group-title">{{ t('cheatsheet.blocks') }}</h3>
|
||||
<div class="cheat-row">
|
||||
<code># ## ###</code>
|
||||
<span>{{ t('cheatsheet.headings') }}</span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>- item</code>
|
||||
<span>{{ t('cheatsheet.bulletList') }}</span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>1. item</code>
|
||||
<span>{{ t('cheatsheet.numberedList') }}</span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>> quote</code>
|
||||
<span>{{ t('cheatsheet.blockquote') }}</span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>| a | b |</code>
|
||||
<span>{{ t('cheatsheet.tableRows') }}</span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>---</code>
|
||||
<span>{{ t('cheatsheet.hr') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="cheatsheet-group">
|
||||
<h3 class="cheatsheet-group-title">{{ t('cheatsheet.shortcuts') }}</h3>
|
||||
<div class="cheat-row">
|
||||
<code>Ctrl / ⌘ + S</code>
|
||||
<span>{{ t('cheatsheet.saveShortcut') }}</span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>Tab</code>
|
||||
<span>{{ t('cheatsheet.tabIndent') }}</span>
|
||||
</div>
|
||||
<div class="cheat-row">
|
||||
<code>Esc</code>
|
||||
<span>{{ t('cheatsheet.escHelp') }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
574
frontend/app/components/GraphView.vue
Normal file
574
frontend/app/components/GraphView.vue
Normal file
@@ -0,0 +1,574 @@
|
||||
<script setup lang="ts">
|
||||
/* The link map, Obsidian-style: every article is a node, every link from
|
||||
one article's text into another is an edge between them. A small
|
||||
hand-rolled force simulation (pairwise repulsion, springs along the
|
||||
edges, a gentle pull to the centre) settles the nodes into a shape,
|
||||
and everything is painted on one canvas: drag the background to pan,
|
||||
the wheel to zoom, a node to pull it around, and let go of a node
|
||||
where it was pressed to open that article. */
|
||||
|
||||
export interface GraphNodeDto {
|
||||
slug: string
|
||||
title: string
|
||||
tags?: string[]
|
||||
links: number
|
||||
}
|
||||
|
||||
export interface GraphEdgeDto {
|
||||
source: string
|
||||
target: string
|
||||
}
|
||||
|
||||
const props = defineProps<{ nodes: GraphNodeDto[]; edges: GraphEdgeDto[] }>()
|
||||
|
||||
const router = useRouter()
|
||||
const wrapEl = ref<HTMLElement | null>(null)
|
||||
const canvasEl = ref<HTMLCanvasElement | null>(null)
|
||||
const cursor = ref<'grab' | 'grabbing' | 'pointer'>('grab')
|
||||
const hoverTitle = ref('')
|
||||
const { t } = useI18n()
|
||||
|
||||
/* the simulation is plain mutable state — it ticks every frame, and
|
||||
Vue would only slow it down */
|
||||
interface SimNode {
|
||||
slug: string
|
||||
title: string
|
||||
deg: number
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
r: number
|
||||
fx: number | null
|
||||
fy: number | null
|
||||
}
|
||||
interface SimLink {
|
||||
a: SimNode
|
||||
b: SimNode
|
||||
rest: number
|
||||
strength: number
|
||||
bias: number
|
||||
}
|
||||
|
||||
let simNodes: SimNode[] = []
|
||||
let simLinks: SimLink[] = []
|
||||
let bySlug = new Map<string, SimNode>()
|
||||
let neighbours = new Map<string, Set<string>>()
|
||||
let hubCut = 0 // articles with at least this many links always wear their label
|
||||
|
||||
let width = 0
|
||||
let height = 0
|
||||
let dpr = 1
|
||||
|
||||
/* the view: world point p draws at p * view.k + view.x/y */
|
||||
const view = { k: 1, x: 0, y: 0 }
|
||||
let viewTarget: { k: number; x: number; y: number } | null = null
|
||||
|
||||
const ALPHA_MIN = 0.002
|
||||
const ALPHA_DECAY = 0.021
|
||||
const VEL_DECAY = 0.55
|
||||
const REPULSE = 1100
|
||||
const GRAVITY = 0.055
|
||||
const MIN_K = 0.15
|
||||
const MAX_K = 5
|
||||
|
||||
let alpha = 1
|
||||
let alphaTarget = 0
|
||||
let raf = 0
|
||||
|
||||
let hoverNode: SimNode | null = null
|
||||
let dragNode: SimNode | null = null
|
||||
let panning = false
|
||||
let movedFar = false
|
||||
let lastPt = { x: 0, y: 0 }
|
||||
let downPt = { x: 0, y: 0 }
|
||||
|
||||
/* canvas colours follow the site theme, including accent switches */
|
||||
const colors = {
|
||||
edge: '#888',
|
||||
accent: '#4f5fe8',
|
||||
accent2: '#0ea5c9',
|
||||
halo: '#ffffff',
|
||||
label: '#5b6478',
|
||||
text: '#1b2130',
|
||||
}
|
||||
function readColors() {
|
||||
const cs = getComputedStyle(document.documentElement)
|
||||
const at = (name: string, fallback: string) =>
|
||||
cs.getPropertyValue(name).trim() || fallback
|
||||
colors.edge = at('--border-strong', colors.edge)
|
||||
colors.accent = at('--accent', colors.accent)
|
||||
colors.accent2 = at('--accent-2', colors.accent2)
|
||||
colors.halo = at('--bg', colors.halo)
|
||||
colors.label = at('--muted', colors.label)
|
||||
colors.text = at('--text', colors.text)
|
||||
}
|
||||
|
||||
const toWorldX = (px: number) => (px - view.x) / view.k
|
||||
const toWorldY = (py: number) => (py - view.y) / view.k
|
||||
|
||||
function build() {
|
||||
bySlug = new Map()
|
||||
neighbours = new Map()
|
||||
simNodes = props.nodes.map((n, i) => {
|
||||
/* a golden-angle spiral start: no two nodes on top of each other,
|
||||
and the repulsion unfolds it into a disc from there */
|
||||
const angle = i * 2.399963229728653
|
||||
const radius = 26 * Math.sqrt(i + 1)
|
||||
const node: SimNode = {
|
||||
slug: n.slug,
|
||||
title: n.title,
|
||||
deg: Math.max(n.links ?? 0, 0),
|
||||
x: radius * Math.cos(angle),
|
||||
y: radius * Math.sin(angle),
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
r: 4 + Math.sqrt(Math.max(n.links ?? 0, 0)) * 2.4,
|
||||
fx: null,
|
||||
fy: null,
|
||||
}
|
||||
bySlug.set(n.slug, node)
|
||||
neighbours.set(n.slug, new Set())
|
||||
return node
|
||||
})
|
||||
|
||||
simLinks = []
|
||||
for (const e of props.edges) {
|
||||
const a = bySlug.get(e.source)
|
||||
const b = bySlug.get(e.target)
|
||||
if (!a || !b) continue
|
||||
neighbours.get(a.slug)?.add(b.slug)
|
||||
neighbours.get(b.slug)?.add(a.slug)
|
||||
simLinks.push({
|
||||
a,
|
||||
b,
|
||||
rest: 58 + a.r + b.r,
|
||||
/* d3's rule: an edge pulls hardest when both ends are lonely,
|
||||
and hardly at all when either is a hub */
|
||||
strength: 1 / Math.max(Math.min(a.deg, b.deg), 1),
|
||||
bias: a.deg / Math.max(a.deg + b.deg, 1),
|
||||
})
|
||||
}
|
||||
|
||||
/* small graphs wear every label; bigger ones only the well-linked
|
||||
hubs — until the zoom gets close enough to read the rest */
|
||||
if (simNodes.length <= 24) hubCut = 0
|
||||
else {
|
||||
const degs = simNodes.map((n) => n.deg).sort((x, y) => y - x)
|
||||
hubCut = Math.max(2, degs[11] ?? 2)
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
alpha += (alphaTarget - alpha) * ALPHA_DECAY
|
||||
|
||||
for (let i = 0; i < simNodes.length; i++) {
|
||||
const a = simNodes[i]!
|
||||
for (let j = i + 1; j < simNodes.length; j++) {
|
||||
const b = simNodes[j]!
|
||||
let dx = a.x - b.x
|
||||
let dy = a.y - b.y
|
||||
let d2 = dx * dx + dy * dy
|
||||
if (d2 === 0) {
|
||||
dx = Math.random() - 0.5
|
||||
dy = Math.random() - 0.5
|
||||
d2 = dx * dx + dy * dy
|
||||
}
|
||||
const w = (REPULSE * alpha) / Math.max(d2, 40)
|
||||
a.vx += dx * w
|
||||
a.vy += dy * w
|
||||
b.vx -= dx * w
|
||||
b.vy -= dy * w
|
||||
}
|
||||
}
|
||||
|
||||
for (const l of simLinks) {
|
||||
const dx = l.b.x - l.a.x
|
||||
const dy = l.b.y - l.a.y
|
||||
const d = Math.max(Math.hypot(dx, dy), 0.01)
|
||||
const f = ((d - l.rest) / d) * alpha * l.strength
|
||||
const fx = dx * f
|
||||
const fy = dy * f
|
||||
l.b.vx -= fx * l.bias
|
||||
l.b.vy -= fy * l.bias
|
||||
l.a.vx += fx * (1 - l.bias)
|
||||
l.a.vy += fy * (1 - l.bias)
|
||||
}
|
||||
|
||||
for (const n of simNodes) {
|
||||
n.vx -= n.x * GRAVITY * alpha
|
||||
n.vy -= n.y * GRAVITY * alpha
|
||||
if (n.fx !== null) {
|
||||
n.x = n.fx
|
||||
n.vx = 0
|
||||
} else {
|
||||
n.vx *= VEL_DECAY
|
||||
n.x += n.vx
|
||||
}
|
||||
if (n.fy !== null) {
|
||||
n.y = n.fy
|
||||
n.vy = 0
|
||||
} else {
|
||||
n.vy *= VEL_DECAY
|
||||
n.y += n.vy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const ctx = canvasEl.value?.getContext('2d')
|
||||
if (!ctx || width === 0) return
|
||||
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
ctx.clearRect(0, 0, width, height)
|
||||
|
||||
const sx = (wx: number) => wx * view.k + view.x
|
||||
const sy = (wy: number) => wy * view.k + view.y
|
||||
const focus = hoverNode ?? dragNode
|
||||
const lit = focus ? new Set([focus.slug, ...(neighbours.get(focus.slug) ?? [])]) : null
|
||||
|
||||
/* edges first: under the focus all others recede into the background */
|
||||
ctx.lineCap = 'round'
|
||||
for (const l of simLinks) {
|
||||
const active = !!focus && (l.a === focus || l.b === focus)
|
||||
if (!active) {
|
||||
ctx.globalAlpha = focus ? 0.05 : 0.5
|
||||
ctx.strokeStyle = colors.edge
|
||||
ctx.lineWidth = 1
|
||||
} else {
|
||||
ctx.globalAlpha = 0.9
|
||||
ctx.strokeStyle = colors.accent
|
||||
ctx.lineWidth = 1.5
|
||||
}
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(sx(l.a.x), sy(l.a.y))
|
||||
ctx.lineTo(sx(l.b.x), sy(l.b.y))
|
||||
ctx.stroke()
|
||||
}
|
||||
|
||||
const paintNode = (n: SimNode) => {
|
||||
const x = sx(n.x)
|
||||
const y = sy(n.y)
|
||||
if (x < -30 || y < -30 || x > width + 30 || y > height + 30) return
|
||||
const r = Math.max(n.r * view.k, 2.5)
|
||||
const dimmed = !!focus && !lit?.has(n.slug)
|
||||
const isFocus = n === focus
|
||||
|
||||
ctx.globalAlpha = dimmed ? 0.16 : 1
|
||||
ctx.fillStyle = isFocus ? colors.accent2 : colors.accent
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, r, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
ctx.globalAlpha = dimmed ? 0.1 : 1
|
||||
ctx.lineWidth = 1.5
|
||||
ctx.strokeStyle = colors.halo
|
||||
ctx.stroke()
|
||||
if (isFocus) {
|
||||
ctx.globalAlpha = 0.35
|
||||
ctx.strokeStyle = colors.accent
|
||||
ctx.beginPath()
|
||||
ctx.arc(x, y, r + 4.5, 0, Math.PI * 2)
|
||||
ctx.stroke()
|
||||
}
|
||||
}
|
||||
|
||||
/* the nodes in a glow sit above the receded ones */
|
||||
for (const n of simNodes) if (!lit?.has(n.slug)) paintNode(n)
|
||||
if (focus && lit) {
|
||||
for (const n of simNodes) if (lit.has(n.slug) && n !== focus) paintNode(n)
|
||||
paintNode(focus)
|
||||
}
|
||||
|
||||
/* labels in screen space so they stay the same size at every zoom */
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'top'
|
||||
for (const n of simNodes) {
|
||||
const isLit = !!focus && lit?.has(n.slug)
|
||||
if (!isLit && n.deg < hubCut && view.k < 1.35) continue
|
||||
const x = sx(n.x)
|
||||
const y = sy(n.y)
|
||||
if (x < -60 || y < -30 || x > width + 60 || y > height + 20) continue
|
||||
const r = Math.max(n.r * view.k, 2.5)
|
||||
ctx.globalAlpha = focus && !isLit ? 0.25 : 1
|
||||
ctx.font = `500 ${n === focus ? 13 : 11.5}px 'Computer Modern', Georgia, serif`
|
||||
ctx.lineWidth = 3.5
|
||||
ctx.lineJoin = 'round'
|
||||
ctx.strokeStyle = colors.halo
|
||||
ctx.strokeText(n.title, x, y + r + 4)
|
||||
ctx.fillStyle = n === focus ? colors.text : colors.label
|
||||
ctx.fillText(n.title, x, y + r + 4)
|
||||
}
|
||||
ctx.globalAlpha = 1
|
||||
}
|
||||
|
||||
function frame() {
|
||||
if (viewTarget) {
|
||||
view.k += (viewTarget.k - view.k) * 0.18
|
||||
view.x += (viewTarget.x - view.x) * 0.18
|
||||
view.y += (viewTarget.y - view.y) * 0.18
|
||||
const settled =
|
||||
Math.abs(viewTarget.k - view.k) < 0.001 &&
|
||||
Math.abs(viewTarget.x - view.x) < 0.5 &&
|
||||
Math.abs(viewTarget.y - view.y) < 0.5
|
||||
if (settled) viewTarget = null
|
||||
}
|
||||
if (alpha > ALPHA_MIN || alphaTarget > 0 || dragNode) tick()
|
||||
draw()
|
||||
raf = requestAnimationFrame(frame)
|
||||
}
|
||||
|
||||
/* the view fits the whole graph, labels given room, capped so a lone
|
||||
dot doesn't fill the screen */
|
||||
function fitView() {
|
||||
if (!simNodes.length || !width || !height) return
|
||||
let minX = Infinity
|
||||
let minY = Infinity
|
||||
let maxX = -Infinity
|
||||
let maxY = -Infinity
|
||||
for (const n of simNodes) {
|
||||
minX = Math.min(minX, n.x - n.r)
|
||||
minY = Math.min(minY, n.y - n.r)
|
||||
maxX = Math.max(maxX, n.x + n.r)
|
||||
maxY = Math.max(maxY, n.y + n.r)
|
||||
}
|
||||
const padX = 90
|
||||
const padTop = 40
|
||||
const padBottom = 72
|
||||
const bw = Math.max(maxX - minX, 40)
|
||||
const bh = Math.max(maxY - minY, 40)
|
||||
const k = Math.min(
|
||||
(width - padX * 2) / bw,
|
||||
(height - padTop - padBottom) / bh,
|
||||
1.8
|
||||
)
|
||||
viewTarget = {
|
||||
k,
|
||||
x: width / 2 - ((minX + maxX) / 2) * k,
|
||||
y: (padTop + height) / 2 - ((minY + maxY) / 2) * k,
|
||||
}
|
||||
}
|
||||
|
||||
function zoomAt(px: number, py: number, factor: number) {
|
||||
const k = Math.min(Math.max(view.k * factor, MIN_K), MAX_K)
|
||||
/* keep the world point under (px, py) anchored — the offsets are
|
||||
computed against the old scale, so k goes in last */
|
||||
view.x = px - ((px - view.x) / view.k) * k
|
||||
view.y = py - ((py - view.y) / view.k) * k
|
||||
view.k = k
|
||||
viewTarget = null
|
||||
}
|
||||
|
||||
function zoomBy(factor: number) {
|
||||
zoomAt(width / 2, height / 2, factor)
|
||||
}
|
||||
|
||||
function reheat() {
|
||||
alpha = 1
|
||||
alphaTarget = 0
|
||||
}
|
||||
|
||||
function localPoint(e: PointerEvent | WheelEvent) {
|
||||
const rect = canvasEl.value!.getBoundingClientRect()
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top }
|
||||
}
|
||||
|
||||
function pick(px: number, py: number): SimNode | null {
|
||||
for (let i = simNodes.length - 1; i >= 0; i--) {
|
||||
const n = simNodes[i]!
|
||||
const r = Math.max(n.r * view.k, 2.5) + 3
|
||||
const dx = px - (n.x * view.k + view.x)
|
||||
const dy = py - (n.y * view.k + view.y)
|
||||
if (dx * dx + dy * dy <= r * r) return n
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
const p = localPoint(e)
|
||||
canvasEl.value?.setPointerCapture(e.pointerId)
|
||||
lastPt = downPt = p
|
||||
movedFar = false
|
||||
const hit = pick(p.x, p.y)
|
||||
if (hit) {
|
||||
dragNode = hit
|
||||
hit.fx = toWorldX(p.x)
|
||||
hit.fy = toWorldY(p.y)
|
||||
alphaTarget = 0.3
|
||||
alpha = Math.max(alpha, 0.12)
|
||||
cursor.value = 'grabbing'
|
||||
} else {
|
||||
panning = true
|
||||
cursor.value = 'grabbing'
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerMove(e: PointerEvent) {
|
||||
const p = localPoint(e)
|
||||
if (dragNode) {
|
||||
dragNode.fx = toWorldX(p.x)
|
||||
dragNode.fy = toWorldY(p.y)
|
||||
} else if (panning) {
|
||||
view.x += p.x - lastPt.x
|
||||
view.y += p.y - lastPt.y
|
||||
viewTarget = null
|
||||
} else {
|
||||
hoverNode = pick(p.x, p.y)
|
||||
hoverTitle.value = hoverNode?.title ?? ''
|
||||
cursor.value = hoverNode ? 'pointer' : 'grab'
|
||||
return
|
||||
}
|
||||
if (Math.hypot(p.x - downPt.x, p.y - downPt.y) > 4) movedFar = true
|
||||
lastPt = p
|
||||
}
|
||||
|
||||
function onPointerUp(e: PointerEvent) {
|
||||
const p = localPoint(e)
|
||||
if (dragNode) {
|
||||
const opened = dragNode
|
||||
dragNode.fx = null
|
||||
dragNode.fy = null
|
||||
dragNode = null
|
||||
alphaTarget = 0
|
||||
/* pressed and released without a drag: open the article, the way
|
||||
Obsidian jumps to the note behind the node */
|
||||
if (!movedFar) {
|
||||
e.preventDefault()
|
||||
router.push(`/wiki/${opened.slug}`)
|
||||
return
|
||||
}
|
||||
}
|
||||
panning = false
|
||||
const still = pick(p.x, p.y)
|
||||
hoverNode = still
|
||||
hoverTitle.value = still?.title ?? ''
|
||||
cursor.value = still ? 'pointer' : 'grab'
|
||||
}
|
||||
|
||||
function onWheel(e: WheelEvent) {
|
||||
e.preventDefault()
|
||||
const p = localPoint(e)
|
||||
let dy = e.deltaY
|
||||
if (e.deltaMode === 1) dy *= 16
|
||||
else if (e.deltaMode === 2) dy *= 100
|
||||
/* pinch-to-zoom arrives as ctrl+wheel — calm the large deltas down */
|
||||
const step = e.ctrlKey ? 0.004 : 0.0016
|
||||
zoomAt(p.x, p.y, Math.exp(-dy * step))
|
||||
}
|
||||
|
||||
function onDoubleClick() {
|
||||
fitView()
|
||||
}
|
||||
|
||||
function clearHover() {
|
||||
if (dragNode || panning) return
|
||||
hoverNode = null
|
||||
hoverTitle.value = ''
|
||||
cursor.value = 'grab'
|
||||
}
|
||||
|
||||
function onPointerCancel() {
|
||||
/* the browser took the gesture over — release everything, never open */
|
||||
if (dragNode) {
|
||||
dragNode.fx = null
|
||||
dragNode.fy = null
|
||||
dragNode = null
|
||||
}
|
||||
alphaTarget = 0
|
||||
panning = false
|
||||
clearHover()
|
||||
}
|
||||
|
||||
let resizeObserver: ResizeObserver | null = null
|
||||
let themeObserver: MutationObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
readColors()
|
||||
const wrap = wrapEl.value!
|
||||
const canvas = canvasEl.value!
|
||||
|
||||
const resize = () => {
|
||||
const rect = wrap.getBoundingClientRect()
|
||||
width = Math.max(rect.width, 1)
|
||||
height = Math.max(rect.height, 1)
|
||||
dpr = window.devicePixelRatio || 1
|
||||
canvas.width = Math.round(width * dpr)
|
||||
canvas.height = Math.round(height * dpr)
|
||||
}
|
||||
resize()
|
||||
resizeObserver = new ResizeObserver(resize)
|
||||
resizeObserver.observe(wrap)
|
||||
|
||||
themeObserver = new MutationObserver(readColors)
|
||||
themeObserver.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme', 'data-accent'],
|
||||
})
|
||||
build()
|
||||
/* a few silent ticks so the first painted frame is already a shape */
|
||||
for (let i = 0; i < 40; i++) tick()
|
||||
fitView()
|
||||
raf = requestAnimationFrame(frame)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(raf)
|
||||
resizeObserver?.disconnect()
|
||||
themeObserver?.disconnect()
|
||||
})
|
||||
|
||||
/* a fresh fetch (a new article, a new link) rebuilds the map */
|
||||
watch(
|
||||
() => [props.nodes, props.edges],
|
||||
() => {
|
||||
if (!import.meta.client) return
|
||||
build()
|
||||
alpha = 1
|
||||
alphaTarget = 0
|
||||
fitView()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapEl" class="graph-canvas-wrap">
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
class="graph-canvas"
|
||||
:style="{ cursor }"
|
||||
:title="hoverTitle"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerCancel"
|
||||
@wheel="onWheel"
|
||||
@dblclick="onDoubleClick"
|
||||
@pointerleave="clearHover"
|
||||
/>
|
||||
<div class="graph-toolbar">
|
||||
<button type="button" class="panel-icon-btn" :title="t('graph.fit')" :aria-label="t('graph.fit')" @click="fitView">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M8 3H5a2 2 0 0 0-2 2v3" />
|
||||
<path d="M16 3h3a2 2 0 0 1 2 2v3" />
|
||||
<path d="M8 21H5a2 2 0 0 1-2-2v-3" />
|
||||
<path d="M16 21h3a2 2 0 0 0 2-2v-3" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="panel-icon-btn" :title="t('graph.zoomIn')" :aria-label="t('graph.zoomIn')" @click="zoomBy(1.35)">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="panel-icon-btn" :title="t('graph.zoomOut')" :aria-label="t('graph.zoomOut')" @click="zoomBy(1 / 1.35)">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true">
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button type="button" class="panel-icon-btn" :title="t('graph.relayout')" :aria-label="t('graph.relayout')" @click="reheat">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12a9 9 0 1 1-2.6-6.4" />
|
||||
<path d="M21 3v5h-5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
14
frontend/app/components/KatexSpan.vue
Normal file
14
frontend/app/components/KatexSpan.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
/** Renders a short inline LaTeX snippet with KaTeX. */
|
||||
import katex from 'katex'
|
||||
|
||||
const props = defineProps<{ tex: string }>()
|
||||
|
||||
const html = computed(() =>
|
||||
katex.renderToString(props.tex, { throwOnError: false, displayMode: false })
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-html="html" />
|
||||
</template>
|
||||
37
frontend/app/components/MarkdownView.vue
Normal file
37
frontend/app/components/MarkdownView.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Renders markdown (with LaTeX) into styled prose HTML.
|
||||
* Used on article pages and in the editor's live preview pane.
|
||||
*/
|
||||
const props = defineProps<{
|
||||
content: string
|
||||
/** Intercept clicks on internal links and use client-side routing. */
|
||||
interceptLinks?: boolean
|
||||
/**
|
||||
* Prefix for the heading ids this article is given. Several articles are
|
||||
* open at once in the reader, so their sections are namespaced by slug to
|
||||
* keep two articles' "Proof" headings from sharing one id.
|
||||
*/
|
||||
namespace?: string
|
||||
}>()
|
||||
|
||||
const rendered = computed(() => renderMarkdown(props.content, props.namespace))
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
function onClick(e: MouseEvent) {
|
||||
if (!props.interceptLinks) return
|
||||
const target = e.target as HTMLElement
|
||||
const anchor = target.closest?.('a')
|
||||
if (!anchor) return
|
||||
const href = anchor.getAttribute('href') || ''
|
||||
if (href.startsWith('/')) {
|
||||
e.preventDefault()
|
||||
router.push(href)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose" v-html="rendered.html" @click="onClick" />
|
||||
</template>
|
||||
358
frontend/app/components/ProfileModal.vue
Normal file
358
frontend/app/components/ProfileModal.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<script setup lang="ts">
|
||||
/* Centered profile popup, opened by the account row in the sidebar (and closed
|
||||
with esc, a backdrop click, or navigation — the same shape as the settings
|
||||
popup). It holds the account itself: the profile picture, the username and
|
||||
the password, and the way out. */
|
||||
const { open, hide } = useProfileMenu()
|
||||
const { hide: hideSearch } = useSearch()
|
||||
const { hide: hideSettings } = useSettings()
|
||||
const { hide: hideSignIn, show: openSignIn } = useSignInPopup()
|
||||
const { user, signOut, updateUsername, changePassword, setAvatar } = useAuth()
|
||||
const { t } = useI18n()
|
||||
const route = useRoute()
|
||||
|
||||
const panelEl = ref<HTMLElement | null>(null)
|
||||
|
||||
/* the fallback the sidebar wears too: an initial in a circle */
|
||||
const initials = computed(() => (user.value?.username.charAt(0) ?? '?').toUpperCase())
|
||||
|
||||
/* --- the picture ---
|
||||
The chosen file is shrunk in the browser before it is sent: the avatar is
|
||||
small everywhere, so there is no reason to store it any bigger. */
|
||||
const AVATAR_SIZE = 256
|
||||
const avatarBusy = ref(false)
|
||||
const avatarError = ref('')
|
||||
const fileInputEl = ref<HTMLInputElement | null>(null)
|
||||
|
||||
/* Reads the file, crops the centre to a square, and scales it down to
|
||||
AVATAR_SIZE on a canvas. The white fill makes transparent PNGs flatten to
|
||||
white rather than black. */
|
||||
async function fileToAvatarDataUrl(file: File): Promise<string> {
|
||||
const raw = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(String(reader.result))
|
||||
reader.onerror = () => reject(new Error(t('profile.readFileFailed')))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
const img = new Image()
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
img.onload = () => resolve()
|
||||
img.onerror = () => reject(new Error(t('profile.notReadable')))
|
||||
img.src = raw
|
||||
})
|
||||
const side = Math.min(img.naturalWidth, img.naturalHeight)
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = AVATAR_SIZE
|
||||
canvas.height = AVATAR_SIZE
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) throw new Error(t('profile.drawFailed'))
|
||||
ctx.fillStyle = '#fff'
|
||||
ctx.fillRect(0, 0, AVATAR_SIZE, AVATAR_SIZE)
|
||||
ctx.drawImage(
|
||||
img,
|
||||
(img.naturalWidth - side) / 2,
|
||||
(img.naturalHeight - side) / 2,
|
||||
side,
|
||||
side,
|
||||
0,
|
||||
0,
|
||||
AVATAR_SIZE,
|
||||
AVATAR_SIZE
|
||||
)
|
||||
return canvas.toDataURL('image/jpeg', 0.86)
|
||||
}
|
||||
|
||||
async function onAvatarPicked(e: Event) {
|
||||
const input = e.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = '' // so the same file can be chosen again later
|
||||
if (!file) return
|
||||
avatarError.value = ''
|
||||
if (!file.type.startsWith('image/')) {
|
||||
avatarError.value = t('profile.notAnImage')
|
||||
return
|
||||
}
|
||||
avatarBusy.value = true
|
||||
try {
|
||||
const dataUrl = await fileToAvatarDataUrl(file)
|
||||
await setAvatar(dataUrl)
|
||||
} catch (err: any) {
|
||||
// a failure of the browser's own (reading, drawing) says why in its
|
||||
// message; a failure of the request goes through describeFailure
|
||||
avatarError.value =
|
||||
err?.statusCode != null
|
||||
? describeFailure(err) || t('profile.uploadFailed')
|
||||
: err?.message || t('profile.readFailed')
|
||||
} finally {
|
||||
avatarBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeAvatar() {
|
||||
avatarError.value = ''
|
||||
avatarBusy.value = true
|
||||
try {
|
||||
await setAvatar('')
|
||||
} catch (err: any) {
|
||||
avatarError.value = describeFailure(err) || t('profile.removeFailed')
|
||||
} finally {
|
||||
avatarBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* --- the account --- */
|
||||
const username = ref('')
|
||||
const savingName = ref(false)
|
||||
const nameError = ref('')
|
||||
const nameSaved = ref(false)
|
||||
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const savingPassword = ref(false)
|
||||
const passwordError = ref('')
|
||||
const passwordSaved = ref(false)
|
||||
|
||||
/* A 401 means the session held is dead: drop it and hand the person the
|
||||
sign-in popup over what just failed. Anything else is only an error line —
|
||||
'' when the popup has already been swept away with the session. */
|
||||
function describeFailure(err: any): string {
|
||||
if (err?.statusCode === 401 || err?.response?.status === 401) {
|
||||
signOut()
|
||||
openSignIn({ notice: t('auth.expiredProfile') })
|
||||
return ''
|
||||
}
|
||||
return err?.data?.message || t('common.apiUnreachable')
|
||||
}
|
||||
|
||||
async function saveUsername() {
|
||||
if (savingName.value) return
|
||||
nameError.value = ''
|
||||
nameSaved.value = false
|
||||
const trimmed = username.value.trim().toLowerCase()
|
||||
if (!trimmed) {
|
||||
nameError.value = t('profile.nameRequired')
|
||||
return
|
||||
}
|
||||
savingName.value = true
|
||||
try {
|
||||
await updateUsername(trimmed)
|
||||
nameSaved.value = true
|
||||
} catch (err: any) {
|
||||
nameError.value = describeFailure(err) || t('profile.nameFailed')
|
||||
} finally {
|
||||
savingName.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
if (savingPassword.value) return
|
||||
passwordError.value = ''
|
||||
passwordSaved.value = false
|
||||
savingPassword.value = true
|
||||
try {
|
||||
await changePassword(currentPassword.value, newPassword.value)
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
passwordSaved.value = true
|
||||
} catch (err: any) {
|
||||
passwordError.value = describeFailure(err) || t('profile.passwordFailed')
|
||||
} finally {
|
||||
savingPassword.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function leave() {
|
||||
signOut()
|
||||
hide()
|
||||
}
|
||||
|
||||
/* sync the form from the account whenever the popup opens */
|
||||
watch(
|
||||
open,
|
||||
async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
// opening this shouldn't leave another popup stacked behind it
|
||||
hideSearch()
|
||||
hideSettings()
|
||||
hideSignIn()
|
||||
username.value = user.value?.username ?? ''
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
avatarBusy.value = false
|
||||
avatarError.value = ''
|
||||
nameError.value = ''
|
||||
nameSaved.value = false
|
||||
passwordError.value = ''
|
||||
passwordSaved.value = false
|
||||
await nextTick()
|
||||
panelEl.value?.focus()
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
/* --- esc closes, navigation closes --- */
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open.value) hide()
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (open.value) hide()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="profile-popup">
|
||||
<div v-if="open && user" class="profile-overlay" @click.self="hide()">
|
||||
<div
|
||||
ref="panelEl"
|
||||
class="profile-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="t('profile.title')"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="profile-modal-head">
|
||||
<h2 class="profile-title">{{ t('profile.title') }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-icon-btn"
|
||||
:aria-label="t('profile.closeAria')"
|
||||
:title="t('common.close')"
|
||||
@click="hide()"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="profile-modal-body">
|
||||
<!-- the picture -->
|
||||
<section class="profile-group" aria-labelledby="profile-picture">
|
||||
<div class="profile-group-head">
|
||||
<span id="profile-picture" class="profile-group-title">{{ t('profile.picture') }}</span>
|
||||
<span class="profile-group-hint">{{ t('profile.pictureHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="profile-avatar-row">
|
||||
<span class="profile-avatar" aria-hidden="true">
|
||||
<img v-if="user.avatar" :src="user.avatar" alt="" />
|
||||
<template v-else>{{ initials }}</template>
|
||||
</span>
|
||||
<div class="profile-avatar-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="avatarBusy"
|
||||
@click="fileInputEl?.click()"
|
||||
>
|
||||
{{ avatarBusy ? t('profile.uploading') : user.avatar ? t('profile.changePicture') : t('profile.choosePicture') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="user.avatar"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm"
|
||||
:disabled="avatarBusy"
|
||||
@click="removeAvatar()"
|
||||
>
|
||||
{{ t('profile.remove') }}
|
||||
</button>
|
||||
<input
|
||||
ref="fileInputEl"
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/webp, image/gif"
|
||||
class="profile-file-input"
|
||||
aria-hidden="true"
|
||||
tabindex="-1"
|
||||
@change="onAvatarPicked($event)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="avatarError" class="form-error">{{ avatarError }}</p>
|
||||
</section>
|
||||
|
||||
<!-- the account itself -->
|
||||
<section class="profile-group" aria-labelledby="profile-username">
|
||||
<div class="profile-group-head">
|
||||
<span id="profile-username" class="profile-group-title">{{ t('profile.username') }}</span>
|
||||
<span class="profile-group-hint">{{ t('profile.usernameHint') }}</span>
|
||||
</div>
|
||||
|
||||
<form class="profile-form" @submit.prevent="saveUsername">
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
:aria-label="t('profile.username')"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
minlength="3"
|
||||
maxlength="30"
|
||||
@input="nameSaved = false"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-primary btn-sm"
|
||||
:disabled="savingName || !username.trim() || username.trim().toLowerCase() === user.username"
|
||||
>
|
||||
{{ savingName ? t('profile.saving') : t('profile.save') }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="nameError" class="form-error">{{ nameError }}</p>
|
||||
<p v-else-if="nameSaved" class="profile-saved">{{ t('profile.nameSaved') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="profile-group" aria-labelledby="profile-password">
|
||||
<div class="profile-group-head">
|
||||
<span id="profile-password" class="profile-group-title">{{ t('profile.password') }}</span>
|
||||
<span class="profile-group-hint">{{ t('profile.passwordHint') }}</span>
|
||||
</div>
|
||||
|
||||
<form class="profile-form profile-form-password" @submit.prevent="savePassword">
|
||||
<input
|
||||
v-model="currentPassword"
|
||||
type="password"
|
||||
:aria-label="t('profile.currentPassword')"
|
||||
autocomplete="current-password"
|
||||
:placeholder="t('profile.currentPassword')"
|
||||
required
|
||||
/>
|
||||
<input
|
||||
v-model="newPassword"
|
||||
type="password"
|
||||
:aria-label="t('profile.newPassword')"
|
||||
autocomplete="new-password"
|
||||
:placeholder="t('profile.newPassword')"
|
||||
minlength="8"
|
||||
required
|
||||
/>
|
||||
<button type="submit" class="btn btn-primary btn-sm" :disabled="savingPassword">
|
||||
{{ savingPassword ? t('profile.saving') : t('profile.changePassword') }}
|
||||
</button>
|
||||
</form>
|
||||
<p v-if="passwordError" class="form-error">{{ passwordError }}</p>
|
||||
<p v-else-if="passwordSaved" class="profile-saved">{{ t('profile.passwordChanged') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="profile-group">
|
||||
<button type="button" class="btn btn-danger btn-sm profile-signout" @click="leave()">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
|
||||
<path d="m16 17 5-5-5-5" />
|
||||
<path d="M21 12H9" />
|
||||
</svg>
|
||||
{{ t('common.signOut') }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
26
frontend/app/components/RecentlyViewed.vue
Normal file
26
frontend/app/components/RecentlyViewed.vue
Normal file
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
const props = defineProps<{ exclude?: string }>()
|
||||
|
||||
const { viewed } = useRecentlyViewed()
|
||||
const { t } = useI18n()
|
||||
|
||||
/* the list only exists in localStorage, so render it only after mount to
|
||||
keep the client's first render identical to the server's */
|
||||
const mounted = ref(false)
|
||||
onMounted(() => (mounted.value = true))
|
||||
|
||||
const others = computed(() =>
|
||||
mounted.value ? viewed.value.filter((v) => v.slug !== props.exclude) : []
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="others.length" class="recently-viewed" :aria-label="t('recent.aria')">
|
||||
<div class="rv-title">{{ t('recent.title') }}</div>
|
||||
<ul>
|
||||
<li v-for="v in others" :key="v.slug">
|
||||
<NuxtLink :to="`/wiki/${v.slug}`">{{ v.title }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</template>
|
||||
284
frontend/app/components/SearchModal.vue
Normal file
284
frontend/app/components/SearchModal.vue
Normal file
@@ -0,0 +1,284 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleMeta } from './ArticleCard.vue'
|
||||
|
||||
/* Centered search popup: the sidebar search bar (or Ctrl/⌘+K, or "/"
|
||||
on pages without their own search box) opens it, then it live-searches
|
||||
articles against the API as you type. ↑/↓ select, ↵ opens, esc closes. */
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { t } = useI18n()
|
||||
const { th } = useAppLocale()
|
||||
const { open, show, hide, toggle } = useSearch()
|
||||
const { hide: hideSettings } = useSettings()
|
||||
const { hide: hideProfile } = useProfileMenu()
|
||||
const { viewed } = useRecentlyViewed()
|
||||
|
||||
const MAX_RESULTS = 8
|
||||
|
||||
const q = ref('')
|
||||
const results = ref<ArticleMeta[]>([])
|
||||
const loading = ref(false)
|
||||
const failed = ref(false)
|
||||
const activeIndex = ref(0)
|
||||
|
||||
const trimmed = computed(() => q.value.trim())
|
||||
|
||||
/* --- focus: the field takes focus on open, focus returns on close --- */
|
||||
const inputEl = ref<HTMLInputElement | null>(null)
|
||||
let previouslyFocused: HTMLElement | null = null
|
||||
|
||||
watch(open, async (isOpen) => {
|
||||
if (isOpen) {
|
||||
previouslyFocused =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null
|
||||
await nextTick()
|
||||
inputEl.value?.focus()
|
||||
} else {
|
||||
previouslyFocused?.focus()
|
||||
}
|
||||
})
|
||||
|
||||
/* --- live search: debounce typing, discard stale responses --- */
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let requestSeq = 0
|
||||
|
||||
watch(trimmed, () => {
|
||||
clearTimeout(debounceTimer)
|
||||
activeIndex.value = 0
|
||||
if (!trimmed.value) {
|
||||
results.value = []
|
||||
loading.value = false
|
||||
failed.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
debounceTimer = setTimeout(runSearch, 180)
|
||||
})
|
||||
|
||||
async function runSearch() {
|
||||
const seq = ++requestSeq
|
||||
try {
|
||||
const res = await $fetch<ArticleMeta[]>(`${api}/articles`, {
|
||||
query: { q: trimmed.value },
|
||||
})
|
||||
if (seq !== requestSeq) return // a newer search already won
|
||||
results.value = res.slice(0, MAX_RESULTS)
|
||||
failed.value = false
|
||||
} catch {
|
||||
if (seq !== requestSeq) return
|
||||
failed.value = true
|
||||
} finally {
|
||||
if (seq === requestSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/* --- keyboard: global shortcuts, plus arrows + enter inside the field --- */
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
e.preventDefault()
|
||||
hideSettings()
|
||||
hideProfile()
|
||||
toggle()
|
||||
return
|
||||
}
|
||||
if (e.key === 'Escape' && open.value) {
|
||||
hide()
|
||||
return
|
||||
}
|
||||
/* "/" also opens the popup — except on the home page, where the key
|
||||
focuses the hero's inline search, and while typing in a field */
|
||||
if (e.key === '/' && !open.value && route.path !== '/') {
|
||||
const t = e.target
|
||||
if (t instanceof HTMLInputElement || t instanceof HTMLTextAreaElement) return
|
||||
e.preventDefault()
|
||||
hideSettings()
|
||||
hideProfile()
|
||||
show()
|
||||
}
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('keydown', onGlobalKeydown)
|
||||
clearTimeout(debounceTimer)
|
||||
})
|
||||
|
||||
/* where a result leads: the language address of the version it shows */
|
||||
function articleHref(a: ArticleMeta): string {
|
||||
return versionHref(identityOf(a), { language: articleLanguage(a), slug: a.slug })
|
||||
}
|
||||
|
||||
function openArticle(article: ArticleMeta) {
|
||||
hide()
|
||||
void router.push(articleHref(article))
|
||||
}
|
||||
|
||||
function openAllResults() {
|
||||
hide()
|
||||
void router.push({ path: '/', query: { q: trimmed.value } })
|
||||
}
|
||||
|
||||
function onFieldKeydown(e: KeyboardEvent) {
|
||||
const n = results.value.length
|
||||
if (e.key === 'ArrowDown' && n) {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value + 1) % n
|
||||
} else if (e.key === 'ArrowUp' && n) {
|
||||
e.preventDefault()
|
||||
activeIndex.value = (activeIndex.value - 1 + n) % n
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const chosen = results.value[activeIndex.value] ?? results.value[0]
|
||||
if (chosen) openArticle(chosen)
|
||||
else if (trimmed.value) openAllResults()
|
||||
}
|
||||
}
|
||||
|
||||
/* keep the highlighted row visible while arrowing through the list */
|
||||
watch(activeIndex, async () => {
|
||||
await nextTick()
|
||||
document
|
||||
.querySelector('.search-modal .search-result.active')
|
||||
?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
|
||||
/* navigating away (clicking any link in the popup) closes it */
|
||||
watch(() => route.fullPath, () => {
|
||||
if (open.value) hide()
|
||||
})
|
||||
|
||||
/* split text into plain / matched parts so results can highlight the term */
|
||||
interface Part {
|
||||
text: string
|
||||
hit: boolean
|
||||
}
|
||||
function highlight(text: string): Part[] {
|
||||
const term = trimmed.value.toLowerCase()
|
||||
if (!term) return [{ text, hit: false }]
|
||||
const parts: Part[] = []
|
||||
const lower = text.toLowerCase()
|
||||
let from = 0
|
||||
for (;;) {
|
||||
const idx = lower.indexOf(term, from)
|
||||
if (idx === -1) break
|
||||
if (idx > from) parts.push({ text: text.slice(from, idx), hit: false })
|
||||
parts.push({ text: text.slice(idx, idx + term.length), hit: true })
|
||||
from = idx + term.length
|
||||
}
|
||||
if (from < text.length) parts.push({ text: text.slice(from), hit: false })
|
||||
return parts
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="search-popup">
|
||||
<div v-if="open" class="search-overlay" @click.self="hide()">
|
||||
<div class="search-modal" role="dialog" aria-modal="true" :aria-label="t('common.searchArticles')">
|
||||
<div class="search-modal-field">
|
||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7" />
|
||||
<path d="m20 20-3.2-3.2" />
|
||||
</svg>
|
||||
<input
|
||||
ref="inputEl"
|
||||
v-model="q"
|
||||
class="search-modal-input"
|
||||
type="text"
|
||||
:placeholder="t('search.placeholder')"
|
||||
:aria-label="t('common.searchArticles')"
|
||||
autocomplete="off"
|
||||
@keydown="onFieldKeydown"
|
||||
/>
|
||||
<span v-if="loading" class="search-spinner" aria-hidden="true" />
|
||||
<button
|
||||
v-else-if="q"
|
||||
type="button"
|
||||
class="panel-icon-btn"
|
||||
:aria-label="t('search.clear')"
|
||||
@click="q = ''"
|
||||
>
|
||||
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="search-modal-body">
|
||||
<div v-if="failed" class="banner">
|
||||
<span class="glyph">!</span>
|
||||
<span>{{ t('search.apiDown') }}</span>
|
||||
</div>
|
||||
|
||||
<ul v-else-if="results.length" class="search-results" role="listbox">
|
||||
<li v-for="(a, i) in results" :key="a._id" role="option" :aria-selected="i === activeIndex">
|
||||
<NuxtLink
|
||||
:to="articleHref(a)"
|
||||
class="search-result"
|
||||
:class="{ active: i === activeIndex }"
|
||||
@mouseenter="activeIndex = i"
|
||||
@click.prevent="openArticle(a)"
|
||||
>
|
||||
<span class="sr-title" :lang="articleLanguage(a)">
|
||||
<template v-for="(p, pi) in highlight(a.title)" :key="pi">
|
||||
<mark v-if="p.hit">{{ p.text }}</mark>
|
||||
<template v-else>{{ p.text }}</template>
|
||||
</template>
|
||||
</span>
|
||||
<span class="sr-meta">
|
||||
<span>{{ articleHref(a) }}</span>
|
||||
<span v-if="a.tags?.length" class="tags">
|
||||
<span v-for="t in a.tags.slice(0, 3)" :key="t" class="tag">{{ t }}</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="articleLanguage(a) !== 'en'"
|
||||
class="lang-badge"
|
||||
:title="t('common.writtenIn', { lang: languageName(articleLanguage(a)) })"
|
||||
>{{ languageName(articleLanguage(a)) }}</span
|
||||
>
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-else-if="loading">
|
||||
<div v-for="i in 3" :key="i" class="search-skeleton" />
|
||||
</div>
|
||||
|
||||
<!-- idle state: quick jumps to recently viewed articles -->
|
||||
<div v-else-if="!trimmed && viewed.length" class="search-quick">
|
||||
<div class="search-quick-title">{{ t('recent.title') }}</div>
|
||||
<ul class="search-results">
|
||||
<li v-for="v in viewed.slice(0, 6)" :key="v.slug">
|
||||
<NuxtLink :to="`/wiki/${v.slug}`" class="search-result">
|
||||
<span class="sr-title">{{ v.title }}</span>
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-else-if="trimmed" class="search-none" v-html="th('search.noMatch', { q: trimmed })"></div>
|
||||
|
||||
<div v-else class="search-none">{{ t('search.idle') }}</div>
|
||||
</div>
|
||||
|
||||
<footer class="search-modal-foot">
|
||||
<span class="foot-hints">
|
||||
<span><kbd class="kbd-hint">↑</kbd><kbd class="kbd-hint">↓</kbd> {{ t('search.navigate') }}</span>
|
||||
<span><kbd class="kbd-hint">↵</kbd> {{ t('search.open') }}</span>
|
||||
<span><kbd class="kbd-hint">esc</kbd> {{ t('search.close') }}</span>
|
||||
</span>
|
||||
<a
|
||||
v-if="trimmed"
|
||||
class="foot-all"
|
||||
href="#"
|
||||
@click.prevent="openAllResults"
|
||||
>
|
||||
{{ t('search.allResults', { q: trimmed }) }}
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
229
frontend/app/components/SettingsModal.vue
Normal file
229
frontend/app/components/SettingsModal.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<script setup lang="ts">
|
||||
/* Centered settings popup, opened by the sidebar cog (and closed with esc,
|
||||
a backdrop click, or navigation). It holds the preferences — the light /
|
||||
dark theme switch, the accent colour, and the interface language — which
|
||||
drive the data-theme and data-accent attributes that the pre-paint script
|
||||
in nuxt.config.ts restores from localStorage. The language lives in the
|
||||
mathew-locale cookie and, with an account behind the session, on the
|
||||
account itself, so it follows the person across devices. */
|
||||
const { open, hide } = useSettings()
|
||||
const { hide: hideSearch } = useSearch()
|
||||
const { hide: hideSignIn } = useSignInPopup()
|
||||
const { hide: hideProfile } = useProfileMenu()
|
||||
const { loggedIn, updateLocale } = useAuth()
|
||||
const { t, locale } = useI18n()
|
||||
const { choose } = useAppLocale()
|
||||
const route = useRoute()
|
||||
|
||||
const theme = ref<'light' | 'dark'>('light')
|
||||
const accent = ref('violet')
|
||||
const language = ref('en')
|
||||
const panelEl = ref<HTMLElement | null>(null)
|
||||
|
||||
const themes = [
|
||||
{ value: 'light', labelKey: 'themeLight' },
|
||||
{ value: 'dark', labelKey: 'themeDark' },
|
||||
] as const
|
||||
|
||||
/* swatch previews use the light-mode pair; the real tokens come from the
|
||||
[data-accent] blocks in main.scss (violet = default, no attribute) */
|
||||
const accents = [
|
||||
{ value: 'violet', labelKey: 'accentViolet', from: '#4f5fe8', to: '#0ea5c9' },
|
||||
{ value: 'purple', labelKey: 'accentPurple', from: '#7c3aed', to: '#c026d3' },
|
||||
{ value: 'ocean', labelKey: 'accentOcean', from: '#2563eb', to: '#06b6d4' },
|
||||
{ value: 'teal', labelKey: 'accentTeal', from: '#0d9488', to: '#10b981' },
|
||||
{ value: 'forest', labelKey: 'accentForest', from: '#16a34a', to: '#84cc16' },
|
||||
{ value: 'amber', labelKey: 'accentAmber', from: '#d97706', to: '#f97316' },
|
||||
{ value: 'rose', labelKey: 'accentRose', from: '#e11d48', to: '#ec4899' },
|
||||
{ value: 'slate', labelKey: 'accentSlate', from: '#334155', to: '#64748b' },
|
||||
] as const
|
||||
|
||||
/* read the live theme, accent + language whenever the popup opens so the
|
||||
switches are in sync */
|
||||
watch(
|
||||
open,
|
||||
async (isOpen) => {
|
||||
if (!isOpen) return
|
||||
// opening settings shouldn't leave another popup stacked behind it
|
||||
hideSearch()
|
||||
hideSignIn()
|
||||
hideProfile()
|
||||
theme.value =
|
||||
(document.documentElement.getAttribute('data-theme') as 'light' | 'dark') || 'light'
|
||||
accent.value = document.documentElement.getAttribute('data-accent') || 'violet'
|
||||
language.value = locale.value
|
||||
await nextTick()
|
||||
panelEl.value?.focus()
|
||||
},
|
||||
{ flush: 'post' }
|
||||
)
|
||||
|
||||
function setTheme(value: 'light' | 'dark') {
|
||||
theme.value = value
|
||||
document.documentElement.setAttribute('data-theme', value)
|
||||
try {
|
||||
localStorage.setItem('mathew-theme', value)
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
function setAccent(value: string) {
|
||||
accent.value = value
|
||||
if (value === 'violet') document.documentElement.removeAttribute('data-accent')
|
||||
else document.documentElement.setAttribute('data-accent', value)
|
||||
try {
|
||||
localStorage.setItem('mathew-accent', value)
|
||||
} catch {
|
||||
/* storage unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
/* Turn the interface language. The device's own choice (the cookie setLocale
|
||||
keeps) lands at once; with an account it is saved there too, so the next
|
||||
device says the same hello. An account that won't take it — a session gone
|
||||
quiet — doesn't undo the switch made here. */
|
||||
async function setLanguage(code: string) {
|
||||
if (language.value === code) return
|
||||
language.value = code
|
||||
await choose(code)
|
||||
if (loggedIn.value) {
|
||||
try {
|
||||
await updateLocale(code)
|
||||
} catch {
|
||||
/* the device-level switch stands on its own */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* --- esc closes, navigation closes --- */
|
||||
function onGlobalKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape' && open.value) hide()
|
||||
}
|
||||
onMounted(() => window.addEventListener('keydown', onGlobalKeydown))
|
||||
onBeforeUnmount(() => window.removeEventListener('keydown', onGlobalKeydown))
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => {
|
||||
if (open.value) hide()
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="settings-popup">
|
||||
<div v-if="open" class="settings-overlay" @click.self="hide()">
|
||||
<div
|
||||
ref="panelEl"
|
||||
class="settings-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="t('settings.title')"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="settings-modal-head">
|
||||
<h2 class="settings-title">{{ t('settings.title') }}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="panel-icon-btn"
|
||||
:aria-label="t('settings.closeAria')"
|
||||
:title="t('common.close')"
|
||||
@click="hide()"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
|
||||
<path d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="settings-modal-body">
|
||||
<section class="settings-group" aria-labelledby="settings-appearance">
|
||||
<div class="settings-group-head">
|
||||
<span id="settings-appearance" class="settings-group-title">{{ t('settings.appearance') }}</span>
|
||||
<span class="settings-group-hint">{{ t('settings.appearanceHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="theme-switch"
|
||||
role="radiogroup"
|
||||
:aria-label="t('settings.themeAria')"
|
||||
>
|
||||
<button
|
||||
v-for="opt in themes"
|
||||
:key="opt.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="theme-option"
|
||||
:class="{ active: theme === opt.value }"
|
||||
:aria-checked="theme === opt.value"
|
||||
@click="setTheme(opt.value)"
|
||||
>
|
||||
<svg v-if="opt.value === 'light'" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4.5" />
|
||||
<path d="M12 2v2.5M12 19.5V22M2 12h2.5M19.5 12H22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M19.1 4.9l-1.8 1.8M6.7 17.3l-1.8 1.8" />
|
||||
</svg>
|
||||
<svg v-else width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8Z" />
|
||||
</svg>
|
||||
<span>{{ t(`settings.${opt.labelKey}`) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-group" aria-labelledby="settings-accent">
|
||||
<div class="settings-group-head">
|
||||
<span id="settings-accent" class="settings-group-title">{{ t('settings.accent') }}</span>
|
||||
<span class="settings-group-hint">{{ t('settings.accentHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="accent-swatches" role="radiogroup" :aria-label="t('settings.accent')">
|
||||
<button
|
||||
v-for="a in accents"
|
||||
:key="a.value"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="accent-swatch"
|
||||
:class="{ active: accent === a.value }"
|
||||
:aria-checked="accent === a.value"
|
||||
:aria-label="t('settings.accentName', { name: t(`settings.${a.labelKey}`) })"
|
||||
:title="t('settings.accentName', { name: t(`settings.${a.labelKey}`) })"
|
||||
:style="{ '--swatch': a.from, '--swatch-2': a.to }"
|
||||
@click="setAccent(a.value)"
|
||||
>
|
||||
<svg v-if="accent === a.value" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 12.5l4.5 4.5L19 7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-group" aria-labelledby="settings-language">
|
||||
<div class="settings-group-head">
|
||||
<span id="settings-language" class="settings-group-title">{{ t('settings.language') }}</span>
|
||||
<span class="settings-group-hint">{{ t('settings.languageHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div class="theme-switch" role="radiogroup" :aria-label="t('settings.languageAria')">
|
||||
<button
|
||||
v-for="l in APP_LANGUAGES"
|
||||
:key="l.code"
|
||||
type="button"
|
||||
role="radio"
|
||||
class="theme-option"
|
||||
:class="{ active: language === l.code }"
|
||||
:aria-checked="language === l.code"
|
||||
:lang="l.code"
|
||||
@click="setLanguage(l.code)"
|
||||
>
|
||||
<span>{{ l.name }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
73
frontend/app/components/TocNav.vue
Normal file
73
frontend/app/components/TocNav.vue
Normal file
@@ -0,0 +1,73 @@
|
||||
<script setup lang="ts">
|
||||
import type { TocEntry } from '~/utils/markdown'
|
||||
|
||||
const props = defineProps<{ entries: TocEntry[] }>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const navEl = ref<HTMLElement | null>(null)
|
||||
const activeId = ref<string>('')
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
/* The article in view scrolls inside its own page of the book, so the TOC
|
||||
follows — and moves — that page's scroller rather than the window. */
|
||||
function ownScroller(): HTMLElement | null {
|
||||
return navEl.value?.closest<HTMLElement>('.pane-scroll') ?? null
|
||||
}
|
||||
|
||||
function observe() {
|
||||
observer?.disconnect()
|
||||
if (!import.meta.client || !props.entries.length) return
|
||||
const headings = props.entries
|
||||
.map((e) => document.getElementById(e.id))
|
||||
.filter((el): el is HTMLElement => !!el)
|
||||
if (!headings.length) return
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(records) => {
|
||||
const visible = records
|
||||
.filter((r) => r.isIntersecting)
|
||||
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top)
|
||||
if (visible.length) activeId.value = visible[0]!.target.id
|
||||
},
|
||||
{ root: ownScroller(), rootMargin: '-48px 0px -65% 0px', threshold: 0 }
|
||||
)
|
||||
headings.forEach((h) => observer!.observe(h))
|
||||
}
|
||||
|
||||
onMounted(() => nextTick(observe))
|
||||
watch(() => props.entries, () => nextTick(observe))
|
||||
onBeforeUnmount(() => observer?.disconnect())
|
||||
|
||||
function goTo(id: string) {
|
||||
activeId.value = id
|
||||
const heading = document.getElementById(id)
|
||||
if (!heading) return
|
||||
const scroller = ownScroller()
|
||||
if (!scroller) {
|
||||
heading.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
return
|
||||
}
|
||||
/* scroll the page itself — scrollIntoView would also shove the stack of
|
||||
open pages sideways */
|
||||
const top =
|
||||
heading.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop - 18
|
||||
scroller.scrollTo({ top: Math.max(0, top), behavior: 'smooth' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav v-if="entries.length" ref="navEl" class="toc" :aria-label="t('toc.aria')">
|
||||
<div class="toc-title">{{ t('toc.title') }}</div>
|
||||
<ol>
|
||||
<li v-for="entry in entries" :key="entry.id" :class="`level-${entry.level}`">
|
||||
<a
|
||||
href="#"
|
||||
:class="{ active: activeId === entry.id }"
|
||||
@click.prevent="goTo(entry.id)"
|
||||
>
|
||||
{{ entry.text }}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</nav>
|
||||
</template>
|
||||
Reference in New Issue
Block a user