First commit
This commit is contained in:
15
frontend/app/composables/useAccountActionsMenu.ts
Normal file
15
frontend/app/composables/useAccountActionsMenu.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/* The open state of the ⋮ menu on a row of the admin dashboard, shared like the
|
||||
other popups. One row's menu at a time, so it holds the id of the account
|
||||
whose menu is open — '' when none is — rather than a boolean per row. */
|
||||
export function useAccountActionsMenu() {
|
||||
const openId = useState<string>('account-actions-menu:open', () => '')
|
||||
|
||||
function show(id: string) {
|
||||
openId.value = id
|
||||
}
|
||||
function hide() {
|
||||
openId.value = ''
|
||||
}
|
||||
|
||||
return { openId, show, hide }
|
||||
}
|
||||
60
frontend/app/composables/useAppLocale.ts
Normal file
60
frontend/app/composables/useAppLocale.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
/* The interface language — the thing the settings popup offers, distinct from
|
||||
the language an *article* is written in (see utils/languages.ts).
|
||||
|
||||
Where the choice lives: a `mathew-locale` cookie, which the server reads so
|
||||
pages render in the right language from the first draw, and — for a signed-in
|
||||
person — the account itself, so the choice follows them across devices. */
|
||||
|
||||
export const APP_LANGUAGES = [
|
||||
{ code: 'en', language: 'en-US', name: 'English' },
|
||||
{ code: 'es', language: 'es-ES', name: 'Español' },
|
||||
{ code: 'ca', language: 'ca-ES', name: 'Català' },
|
||||
{ code: 'fr', language: 'fr-FR', name: 'Français' },
|
||||
{ code: 'de', language: 'de-DE', name: 'Deutsch' },
|
||||
] as const
|
||||
|
||||
const SUPPORTED = new Set<string>(APP_LANGUAGES.map((l) => l.code))
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(
|
||||
/[&<>"']/g,
|
||||
(c) =>
|
||||
(({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }) as Record<string, string>)[c]!
|
||||
)
|
||||
}
|
||||
|
||||
export function useAppLocale() {
|
||||
const { locale, setLocale, t } = useI18n()
|
||||
|
||||
/* Translate a message that is drawn with v-html. Every interpolated value
|
||||
is escaped — tags, slugs and usernames wander into these — while the
|
||||
messages themselves are ours and carry their own <code>/<span> markup. */
|
||||
function th(key: string, params?: Record<string, unknown>): string {
|
||||
if (!params) return t(key)
|
||||
const escaped: Record<string, unknown> = {}
|
||||
for (const [k, v] of Object.entries(params)) escaped[k] = typeof v === 'string' ? escapeHtml(v) : v
|
||||
return t(key, escaped)
|
||||
}
|
||||
|
||||
/* Turn the interface to another language. setLocale loads the messages and
|
||||
keeps the detection cookie in step; where the person also has an account,
|
||||
the caller saves it there too (see SettingsModal). */
|
||||
async function choose(code: string) {
|
||||
if (!SUPPORTED.has(code) || code === locale.value) return
|
||||
await setLocale(code)
|
||||
}
|
||||
|
||||
/* Follow an account's own choice — at startup or on signing in, where the
|
||||
account outranks whatever this device was showing. */
|
||||
async function applyAccount(code?: string) {
|
||||
if (!code || !SUPPORTED.has(code) || code === locale.value) return
|
||||
try {
|
||||
await setLocale(code)
|
||||
} catch {
|
||||
/* the device keeps its own language; nothing else about the session
|
||||
depends on this */
|
||||
}
|
||||
}
|
||||
|
||||
return { locale, th, choose, applyAccount }
|
||||
}
|
||||
15
frontend/app/composables/useArticleActionsMenu.ts
Normal file
15
frontend/app/composables/useArticleActionsMenu.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/* The open state of the article's ⋮ actions menu, shared like the other
|
||||
popups: the book's Esc handler defers to whatever popup is on top, and the
|
||||
menu steps aside when search or settings opens — popups don't stack. */
|
||||
export function useArticleActionsMenu() {
|
||||
const open = useState<boolean>('article-actions-menu:open', () => false)
|
||||
|
||||
function show() {
|
||||
open.value = true
|
||||
}
|
||||
function hide() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
return { open, show, hide }
|
||||
}
|
||||
118
frontend/app/composables/useArticleLibrary.ts
Normal file
118
frontend/app/composables/useArticleLibrary.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { ArticleMeta } from '~/components/ArticleCard.vue'
|
||||
|
||||
export interface ReaderArticle extends ArticleMeta {
|
||||
content?: string
|
||||
}
|
||||
|
||||
/* Requests in flight on the client, so two panes asking for the same article
|
||||
only trouble the API once. (Not touched during SSR, where only the page in
|
||||
view is ever fetched.) */
|
||||
const pending = new Map<string, Promise<ReaderArticle | null>>()
|
||||
|
||||
/** What a language version answers to from its address: `fr:bayes-theorem`. */
|
||||
export function versionKey(identity: string, lang: string): string {
|
||||
return `${lang}:${identity}`
|
||||
}
|
||||
|
||||
/** The article a document belongs to: its own slug unless it translates one. */
|
||||
export function identityOf(article: { slug: string; baseSlug?: string | null }): string {
|
||||
return article.baseSlug || article.slug
|
||||
}
|
||||
|
||||
/**
|
||||
* The text of the pages the open book holds: one entry per article version,
|
||||
* in shared state so the book, the route hosts and the middleware all see the
|
||||
* same copy. A document answers both to its stored slug (what the panes and
|
||||
* tabs are filed under) and, for language versions, to the `lang:identity`
|
||||
* key its /wiki/<lang>/<slug> address asks for. The /wiki routes' middleware
|
||||
* is what usually fills it, before anything renders — so the server's first
|
||||
* render already has the page in view written out.
|
||||
*/
|
||||
export function useArticleLibrary() {
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
const shelf = useState<Record<string, ReaderArticle>>('article-book:shelf', () => ({}))
|
||||
/* request key -> the status of the request that failed to bring it in */
|
||||
const unreadable = useState<Record<string, number>>('article-book:unreadable', () => ({}))
|
||||
|
||||
function get(slug: string): ReaderArticle | null {
|
||||
return shelf.value[slug] ?? null
|
||||
}
|
||||
|
||||
/** the version an address like /wiki/fr/bayes-theorem asks for, if held */
|
||||
function getVersion(identity: string, lang: string): ReaderArticle | null {
|
||||
return shelf.value[versionKey(identity, lang)] ?? null
|
||||
}
|
||||
|
||||
function put(key: string, article: ReaderArticle) {
|
||||
delete unreadable.value[key]
|
||||
delete unreadable.value[versionKey(identityOf(article), article.language ?? 'en')]
|
||||
shelf.value[key] = article
|
||||
/* a version also answers to its document's own slug, where it was fetched
|
||||
from and where the panes keep it */
|
||||
shelf.value[article.slug] = article
|
||||
}
|
||||
|
||||
function forget(slug: string) {
|
||||
const article = shelf.value[slug]
|
||||
if (article) delete shelf.value[versionKey(identityOf(article), article.language ?? 'en')]
|
||||
delete shelf.value[slug]
|
||||
delete unreadable.value[slug]
|
||||
}
|
||||
|
||||
async function fetchOntoShelf(key: string, url: string): Promise<ReaderArticle | null> {
|
||||
const running = pending.get(key)
|
||||
if (running) return running
|
||||
|
||||
const request = (async () => {
|
||||
try {
|
||||
const article = await $fetch<ReaderArticle>(url)
|
||||
put(key, article)
|
||||
return article
|
||||
} catch (err) {
|
||||
/* remember why, and let the page say so in its own words */
|
||||
const status = (err as { statusCode?: number; status?: number }) ?? {}
|
||||
unreadable.value[key] = status.statusCode ?? status.status ?? 0
|
||||
return null
|
||||
} finally {
|
||||
pending.delete(key)
|
||||
}
|
||||
})()
|
||||
|
||||
pending.set(key, request)
|
||||
return request
|
||||
}
|
||||
|
||||
/** Bring a document onto the shelf, once. */
|
||||
async function load(slug: string): Promise<ReaderArticle | null> {
|
||||
const known = shelf.value[slug]
|
||||
if (known) return known
|
||||
return fetchOntoShelf(slug, `${api}/articles/slug/${encodeURIComponent(slug)}`)
|
||||
}
|
||||
|
||||
/** Bring one language version onto the shelf, once. */
|
||||
async function loadVersion(identity: string, lang: string): Promise<ReaderArticle | null> {
|
||||
const key = versionKey(identity, lang)
|
||||
const known = shelf.value[key]
|
||||
if (known) return known
|
||||
return fetchOntoShelf(
|
||||
key,
|
||||
`${api}/articles/slug/${encodeURIComponent(identity)}?lang=${encodeURIComponent(lang)}`
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a page again without waiting for it: a page already open is served
|
||||
* from the shelf at once, but an edit made elsewhere should still find its
|
||||
* way onto the paper.
|
||||
*/
|
||||
function revalidate(slug: string, lang?: string) {
|
||||
const key = lang ? versionKey(slug, lang) : slug
|
||||
if (import.meta.server || pending.has(key)) return
|
||||
const url = lang
|
||||
? `${api}/articles/slug/${encodeURIComponent(slug)}?lang=${encodeURIComponent(lang)}`
|
||||
: `${api}/articles/slug/${encodeURIComponent(slug)}`
|
||||
void fetchOntoShelf(key, url)
|
||||
}
|
||||
|
||||
return { get, getVersion, put, forget, load, loadVersion, revalidate, unreadable }
|
||||
}
|
||||
109
frontend/app/composables/useArticleTabs.ts
Normal file
109
frontend/app/composables/useArticleTabs.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export interface ArticleTab {
|
||||
slug: string
|
||||
title: string
|
||||
/** the language version this page holds */
|
||||
lang?: string
|
||||
/** the article this document is a version of — its canonical slug */
|
||||
identity?: string
|
||||
}
|
||||
|
||||
const MAX_TABS = 10
|
||||
|
||||
/** The address a page of the book was read from: the article's own URL for
|
||||
* the canonical version, /wiki/<lang>/<slug> for the others. */
|
||||
export function tabHref(tab: Pick<ArticleTab, 'slug' | 'lang' | 'identity'>): string {
|
||||
const identity = tab.identity ?? tab.slug
|
||||
return versionHref(identity, { language: tab.lang, slug: tab.slug })
|
||||
}
|
||||
|
||||
/** Which page of the stack an address points at, if any. */
|
||||
export function tabAt(tabs: readonly ArticleTab[], path: string): ArticleTab | null {
|
||||
const loc = parseReaderPath(path)
|
||||
if (!loc) return null
|
||||
const exact = tabs.find((t) => {
|
||||
const identity = t.identity ?? t.slug
|
||||
if (loc.lang) return identity === loc.identity && (t.lang ?? 'en') === loc.lang
|
||||
return identity === loc.identity && t.slug === loc.identity
|
||||
})
|
||||
/* an address naming a document directly (older /slug-fr form) still finds it */
|
||||
return exact ?? tabs.find((t) => t.slug === loc.identity) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The session stack of articles opened in the reader — the pages of the
|
||||
* book, in the order they were opened, newest at the right. The page
|
||||
* currently in view is simply wherever the route points; the stack lives in
|
||||
* shared state and survives every client navigation (reset on reload), so the
|
||||
* open pages are still there when you come back to the reading surface.
|
||||
*/
|
||||
export function useArticleTabs() {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const tabs = useState<ArticleTab[]>('article-tabs', () => [])
|
||||
/* the last page seen outside the reading surface, so closing the last tab
|
||||
returns there instead of always going home */
|
||||
const origin = useState<string>('article-tabs:origin', () => '')
|
||||
/* the page last in view, so the stack can be found again from any page that
|
||||
hides the reading surface — the editor, a new article, the graph */
|
||||
const lastSlug = useState<string>('article-tabs:last', () => '')
|
||||
|
||||
function ensureTab(
|
||||
slug: string,
|
||||
title: string,
|
||||
meta?: { lang?: string; identity?: string }
|
||||
) {
|
||||
const existing = tabs.value.find((t) => t.slug === slug)
|
||||
if (existing) {
|
||||
existing.title = title // follow the tab label after a rename
|
||||
if (meta?.lang) existing.lang = meta.lang
|
||||
if (meta?.identity) existing.identity = meta.identity
|
||||
return
|
||||
}
|
||||
const stack = [...tabs.value, { slug, title, lang: meta?.lang, identity: meta?.identity }]
|
||||
if (stack.length > MAX_TABS) {
|
||||
/* don't stack forever — retire the oldest tab that isn't in view */
|
||||
const drop = stack.findIndex((t) => t.slug !== slug)
|
||||
if (drop !== -1) stack.splice(drop, 1)
|
||||
}
|
||||
tabs.value = stack
|
||||
}
|
||||
|
||||
/* Close a page. Closing one that isn't in view just takes it out of the
|
||||
stack and the rest settle; closing the page in view turns back to the
|
||||
page before it — or returns to the last page outside the book once the
|
||||
last page is gone. */
|
||||
function closeTab(slug: string) {
|
||||
const at = tabs.value.findIndex((t) => t.slug === slug)
|
||||
const isActive = tabAt(tabs.value, route.path)?.slug === slug
|
||||
tabs.value = tabs.value.filter((t) => t.slug !== slug)
|
||||
if (isActive) {
|
||||
const back = tabs.value[Math.max(0, Math.min(at - 1, tabs.value.length - 1))]
|
||||
void router.push(back ? tabHref(back) : origin.value || '/')
|
||||
}
|
||||
}
|
||||
|
||||
return { tabs, origin, lastSlug, ensureTab, closeTab }
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the book's stack of pages in step with the route: which page was last
|
||||
* in view, and the last plain page outside the reading surface. The stack
|
||||
* itself is left alone — it survives the whole session, so wandering off to
|
||||
* the graph or the article list and coming back finds the pages as they were.
|
||||
* Call once, high in the app.
|
||||
*/
|
||||
export function useArticleTabsSync() {
|
||||
const route = useRoute()
|
||||
const { tabs, lastSlug, origin } = useArticleTabs()
|
||||
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
(to) => {
|
||||
/* filed under the tab's own slug, so a language version is found again
|
||||
as the version it was, not merely as the article */
|
||||
if (isReaderPath(to)) lastSlug.value = tabAt(tabs.value, to)?.slug ?? wikiSlugFromPath(to) ?? ''
|
||||
else if (!isReadingSurface(to)) origin.value = to
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
}
|
||||
158
frontend/app/composables/useAuth.ts
Normal file
158
frontend/app/composables/useAuth.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
/* Who is using the wiki right now.
|
||||
|
||||
A session is a bearer token the API handed us, kept in a cookie rather than
|
||||
localStorage so the server can see it too: the sidebar then knows who is
|
||||
signed in on the very first draw, before any script runs. The token itself is
|
||||
never decoded here — the API is asked who it belongs to (see restoreSession). */
|
||||
|
||||
export interface AuthUser {
|
||||
id: string
|
||||
username: string
|
||||
/** Profile picture as a data URL; "" when the person has not chosen one. */
|
||||
avatar: string
|
||||
/** An admin opens the account dashboard; a member reads and writes. */
|
||||
role: AuthRole
|
||||
/** The interface language saved on the account; "" follows the browser. */
|
||||
locale: string
|
||||
}
|
||||
|
||||
export type AuthRole = 'admin' | 'member'
|
||||
|
||||
interface Session {
|
||||
token: string
|
||||
user: AuthUser
|
||||
}
|
||||
|
||||
const TOKEN_COOKIE = 'mathew-session'
|
||||
const WEEK_IN_SECONDS = 60 * 60 * 24 * 7
|
||||
|
||||
export function useAuth() {
|
||||
const api = `${useRuntimeConfig().public.apiBase}/api`
|
||||
|
||||
const token = useCookie<string | null>(TOKEN_COOKIE, {
|
||||
default: () => null,
|
||||
maxAge: WEEK_IN_SECONDS,
|
||||
sameSite: 'lax',
|
||||
})
|
||||
const user = useState<AuthUser | null>('auth:user', () => null)
|
||||
|
||||
const loggedIn = computed(() => user.value !== null)
|
||||
|
||||
/* Whether this session may open the admin dashboard. The API checks the
|
||||
account again on every admin request — this only decides what to draw. */
|
||||
const isAdmin = computed(() => user.value?.role === 'admin')
|
||||
|
||||
/* Header for the write endpoints. The read endpoints are public and ignore it. */
|
||||
function authHeaders(): Record<string, string> {
|
||||
return token.value ? { Authorization: `Bearer ${token.value}` } : {}
|
||||
}
|
||||
|
||||
function openSession(session: Session) {
|
||||
token.value = session.token
|
||||
user.value = session.user
|
||||
}
|
||||
|
||||
/* Signing out is local: the token simply stops being sent, and it lapses on
|
||||
its own — the API issues nothing that needs revoking. */
|
||||
function signOut() {
|
||||
token.value = null
|
||||
user.value = null
|
||||
}
|
||||
|
||||
async function signIn(username: string, password: string) {
|
||||
const session = await $fetch<Session>(`${api}/auth/login`, {
|
||||
method: 'POST',
|
||||
body: { username: username.trim().toLowerCase(), password },
|
||||
})
|
||||
openSession(session)
|
||||
return session.user
|
||||
}
|
||||
|
||||
/* Opening an account signs you straight into it: the API answers with the
|
||||
same token pair as a login would. */
|
||||
async function signUp(username: string, password: string) {
|
||||
const session = await $fetch<Session>(`${api}/auth/register`, {
|
||||
method: 'POST',
|
||||
body: { username: username.trim().toLowerCase(), password },
|
||||
})
|
||||
openSession(session)
|
||||
return session.user
|
||||
}
|
||||
|
||||
/* Runs once at startup, on the server and in the browser: asks the API who a
|
||||
stored token belongs to. A token the API refuses — expired, or the account
|
||||
is gone — is dropped here rather than at the first save. A request that
|
||||
never got through (the backend is restarting) leaves it in place. */
|
||||
async function restoreSession() {
|
||||
if (!token.value || user.value) return
|
||||
try {
|
||||
const { user: known } = await $fetch<{ user: AuthUser }>(`${api}/auth/me`, {
|
||||
headers: authHeaders(),
|
||||
})
|
||||
user.value = known
|
||||
} catch (err: any) {
|
||||
if (err?.statusCode === 401 || err?.response?.status === 401) token.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/* The account updates from the profile popup. Each comes back with the
|
||||
account as the API now has it, so the sidebar and the popup are showing
|
||||
the truth the moment the request resolves. */
|
||||
async function updateUsername(username: string) {
|
||||
const { user: updated } = await $fetch<{ user: AuthUser }>(`${api}/auth/me`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: { username: username.trim().toLowerCase() },
|
||||
})
|
||||
user.value = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
async function changePassword(currentPassword: string, newPassword: string) {
|
||||
await $fetch(`${api}/auth/password`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: { currentPassword, newPassword },
|
||||
})
|
||||
}
|
||||
|
||||
/* The picture arrives already shrunk to a small data URL (see ProfileModal);
|
||||
"" clears it and leaves the initial in its place. */
|
||||
async function setAvatar(dataUrl: string) {
|
||||
const { user: updated } = await $fetch<{ user: AuthUser }>(`${api}/auth/avatar`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: { avatar: dataUrl },
|
||||
})
|
||||
user.value = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
/* The interface language saved on the account, so the choice follows the
|
||||
person across devices. Switching this device's language is the caller's
|
||||
business (useAppLocale) — this only stores it. */
|
||||
async function updateLocale(locale: string) {
|
||||
const { user: updated } = await $fetch<{ user: AuthUser }>(`${api}/auth/locale`, {
|
||||
method: 'PUT',
|
||||
headers: authHeaders(),
|
||||
body: { locale },
|
||||
})
|
||||
user.value = updated
|
||||
return updated
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
loggedIn,
|
||||
isAdmin,
|
||||
authHeaders,
|
||||
signIn,
|
||||
signUp,
|
||||
signOut,
|
||||
restoreSession,
|
||||
updateUsername,
|
||||
updateLocale,
|
||||
changePassword,
|
||||
setAvatar,
|
||||
}
|
||||
}
|
||||
15
frontend/app/composables/useLanguageMenu.ts
Normal file
15
frontend/app/composables/useLanguageMenu.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
/* The open state of the reader's language menu, shared like the other popups:
|
||||
the book's Esc handler defers to whatever popup is on top, and the menu
|
||||
steps aside when search or settings opens — popups don't stack in this app. */
|
||||
export function useLanguageMenu() {
|
||||
const open = useState<boolean>('language-menu:open', () => false)
|
||||
|
||||
function show() {
|
||||
open.value = true
|
||||
}
|
||||
function hide() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
return { open, show, hide }
|
||||
}
|
||||
20
frontend/app/composables/useProfileMenu.ts
Normal file
20
frontend/app/composables/useProfileMenu.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/* Shared open state for the profile popup (ProfileModal.vue), shaped like
|
||||
useSettings and useSignInPopup. The account row in the sidebar flips this
|
||||
flag; the popup itself owns the account form. */
|
||||
export function useProfileMenu() {
|
||||
const open = useState<boolean>('profile:open', () => false)
|
||||
|
||||
function show() {
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function hide() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
return { open, show, hide, toggle }
|
||||
}
|
||||
56
frontend/app/composables/useRecentlyViewed.ts
Normal file
56
frontend/app/composables/useRecentlyViewed.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
export interface ViewedArticle {
|
||||
slug: string
|
||||
title: string
|
||||
viewedAt: number
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'mathew-recently-viewed'
|
||||
const MAX_ENTRIES = 8
|
||||
|
||||
let loaded = false
|
||||
|
||||
function readStored(): ViewedArticle[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY)
|
||||
return raw ? (JSON.parse(raw) as ViewedArticle[]) : []
|
||||
} catch {
|
||||
/* ignore corrupt storage */
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Tracks which articles the visitor has opened, in `localStorage`, so the
|
||||
* sidebar can offer a "recently viewed" list. Shared across pages via
|
||||
* `useState`, so it updates live as you browse without extra fetches.
|
||||
*/
|
||||
export function useRecentlyViewed() {
|
||||
const viewed = useState<ViewedArticle[]>('recently-viewed', () => [])
|
||||
|
||||
/* read the saved history once, after mount, so the server render and the
|
||||
client hydration both start from an empty list */
|
||||
onMounted(() => {
|
||||
if (loaded) return
|
||||
loaded = true
|
||||
const stored = readStored()
|
||||
if (stored.length) viewed.value = stored
|
||||
})
|
||||
|
||||
function record(article: { slug: string; title: string }) {
|
||||
/* merge with what's already saved so recording before the list has
|
||||
loaded (e.g. deep-linking straight to an article) can't clobber it */
|
||||
const base = viewed.value.length ? viewed.value : readStored()
|
||||
viewed.value = [
|
||||
{ ...article, viewedAt: Date.now() },
|
||||
...base.filter((v) => v.slug !== article.slug),
|
||||
].slice(0, MAX_ENTRIES)
|
||||
loaded = true
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(viewed.value))
|
||||
} catch {
|
||||
/* private mode etc. — history just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
return { viewed, record }
|
||||
}
|
||||
20
frontend/app/composables/useSearch.ts
Normal file
20
frontend/app/composables/useSearch.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/* Shared open state for the article search popup (SearchModal.vue).
|
||||
The sidebar search bar and the keyboard shortcuts flip this flag;
|
||||
the modal itself owns the live searching. */
|
||||
export function useSearch() {
|
||||
const open = useState<boolean>('search:open', () => false)
|
||||
|
||||
function show() {
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function hide() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
return { open, show, hide, toggle }
|
||||
}
|
||||
19
frontend/app/composables/useSettings.ts
Normal file
19
frontend/app/composables/useSettings.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/* Shared open state for the settings popup (SettingsModal.vue).
|
||||
The sidebar cog flips this flag; the modal itself owns the settings. */
|
||||
export function useSettings() {
|
||||
const open = useState<boolean>('settings:open', () => false)
|
||||
|
||||
function show() {
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function hide() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
open.value = !open.value
|
||||
}
|
||||
|
||||
return { open, show, hide, toggle }
|
||||
}
|
||||
27
frontend/app/composables/useSidebar.ts
Normal file
27
frontend/app/composables/useSidebar.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/* Shared collapse state for the site sidebar. The choice is remembered in
|
||||
localStorage, and the flag is also set on <html> so the fixed reading
|
||||
surface (--sidebar-w) and the rail itself follow it together. Mobile's
|
||||
top bar is always expanded — the collapsed styles only apply above the
|
||||
900px breakpoint. */
|
||||
export function useSidebar() {
|
||||
const collapsed = useState<boolean>('sidebar:collapsed', () => false)
|
||||
|
||||
if (import.meta.client) {
|
||||
watch(collapsed, (is) => {
|
||||
document.documentElement.classList.toggle('sidebar-collapsed', is)
|
||||
})
|
||||
}
|
||||
|
||||
function restoreSidebar() {
|
||||
if (import.meta.client)
|
||||
collapsed.value = localStorage.getItem('mathew:sidebar-collapsed') === '1'
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
collapsed.value = !collapsed.value
|
||||
if (import.meta.client)
|
||||
localStorage.setItem('mathew:sidebar-collapsed', collapsed.value ? '1' : '0')
|
||||
}
|
||||
|
||||
return { collapsed: readonly(collapsed), toggleSidebar, restoreSidebar }
|
||||
}
|
||||
26
frontend/app/composables/useSignInPopup.ts
Normal file
26
frontend/app/composables/useSignInPopup.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/* Shared open state for the sign-in popup (AuthModal.vue), shaped like
|
||||
useSearch and useSettings. The account row in the sidebar, the gate on the
|
||||
editor pages and the editor itself all reach the popup through this, so only
|
||||
one is ever on screen. */
|
||||
|
||||
export type SignInMode = 'signin' | 'signup'
|
||||
|
||||
export function useSignInPopup() {
|
||||
const open = useState<boolean>('signin:open', () => false)
|
||||
const mode = useState<SignInMode>('signin:mode', () => 'signin')
|
||||
/* Why the popup was opened — shown above the form when a session has just
|
||||
expired mid-edit, so the person knows what happened to their save. */
|
||||
const notice = useState<string>('signin:notice', () => '')
|
||||
|
||||
function show(opts: { mode?: SignInMode; notice?: string } = {}) {
|
||||
mode.value = opts.mode ?? 'signin'
|
||||
notice.value = opts.notice ?? ''
|
||||
open.value = true
|
||||
}
|
||||
|
||||
function hide() {
|
||||
open.value = false
|
||||
}
|
||||
|
||||
return { open, mode, notice, show, hide }
|
||||
}
|
||||
Reference in New Issue
Block a user