diff --git a/src/app.d.ts b/src/app.d.ts index a7c6b90..85d041e 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -7,6 +7,7 @@ declare global { interface Locals { oAuthClient?: OAuthClient; user?: PrivateUserData; + locale?: string; } // interface PageData {} // interface PageState {} diff --git a/src/css/components/bookmark.css b/src/css/components/bookmark.css index 6cde871..09a59fc 100644 --- a/src/css/components/bookmark.css +++ b/src/css/components/bookmark.css @@ -3,6 +3,17 @@ gap: 20px; } +.Bookmarks-header { + align-items: center; + display: flex; + flex-wrap: wrap; + justify-content: space-between; + + & button { + margin-block: -9px; + } +} + .Bookmark { border: 5px solid rgb(var(--color-black)); box-shadow: inset 0 0 0 4px rgb(var(--color-yellow) / 0.1); @@ -39,6 +50,7 @@ font-size: var(--font-size-2); font-weight: 700; grid-column: 1; + white-space: nowrap; } & > code { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index a87602b..85bec7d 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,4 +1,5 @@ import { dev } from "$app/environment"; +import { acceptsLanguages } from "$lib/negotiation"; import { restoreSession } from "$lib/server/session"; import { isAuthEvent } from "$lib/types"; import { error, type Handle } from "@sveltejs/kit"; @@ -19,6 +20,10 @@ export const defaultHandle: Handle = async ({ event, resolve }) => { if (isAuthEvent(event) === false) { error(500); } + + const languages = acceptsLanguages(event.request); + event.locals.locale = languages[0] === "*" ? undefined : languages[0]; + // [TODO] necessary? // if ( // event.url.searchParams.has("session") || @@ -28,7 +33,12 @@ export const defaultHandle: Handle = async ({ event, resolve }) => { // ) { await restoreSession(event); // } - return resolve(event); + + return resolve(event, { + filterSerializedResponseHeaders: (name: string, _value: string) => { + return ["content-type"].includes(name.toLowerCase()); + }, + }); }; export const handle: Handle = sequence(devHandle, defaultHandle); diff --git a/src/lib/negotiation.ts b/src/lib/negotiation.ts new file mode 100644 index 0000000..aab0b36 --- /dev/null +++ b/src/lib/negotiation.ts @@ -0,0 +1,364 @@ +// Copyright 2018-2026 the Deno authors. MIT license. +/*! + * Adapted directly from negotiator at https://github.com/jshttp/negotiator/ + * which is licensed as follows: + * + * (The MIT License) + * + * Copyright (c) 2012-2014 Federico Romero + * Copyright (c) 2012-2014 Isaac Z. Schlueter + * Copyright (c) 2014-2015 Douglas Christopher Wilson + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * 'Software'), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF + * MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. + * IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY + * CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, + * TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE + * SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ + +export interface Specificity { + i: number; + o: number | undefined; + q: number; + s: number | undefined; +} + +export function compareSpecs(a: Specificity, b: Specificity): number { + return ( + b.q - a.q || + (b.s ?? 0) - (a.s ?? 0) || + (a.o ?? 0) - (b.o ?? 0) || + a.i - b.i || + 0 + ); +} + +export function isQuality(spec: Specificity): boolean { + return spec.q > 0; +} + +interface LanguageSpecificity extends Specificity { + prefix: string; + suffix: string | undefined; + full: string; +} + +const SIMPLE_LANGUAGE_REGEXP = /^\s*([^\s\-;]+)(?:-([^\s;]+))?\s*(?:;(.*))?$/; + +function parseLanguage( + str: string, + i: number, +): LanguageSpecificity | undefined { + const match = SIMPLE_LANGUAGE_REGEXP.exec(str); + if (!match) { + return undefined; + } + + const [, prefix, suffix] = match; + if (!prefix) { + return undefined; + } + + const full = suffix !== undefined ? `${prefix}-${suffix}` : prefix; + + let q = 1; + if (match[3]) { + const params = match[3].split(";"); + for (const param of params) { + const [key, value] = param.trim().split("="); + if (key === "q" && value) { + q = parseFloat(value); + break; + } + } + } + + return { prefix, suffix, full, i, o: undefined, q, s: undefined }; +} + +function parseAcceptLanguage(accept: string): LanguageSpecificity[] { + const accepts = accept.split(","); + const result: LanguageSpecificity[] = []; + + for (const [i, accept] of accepts.entries()) { + const language = parseLanguage(accept.trim(), i); + if (language) { + result.push(language); + } + } + return result; +} + +function specify( + language: string, + spec: LanguageSpecificity, + i: number, +): Specificity | undefined { + const p = parseLanguage(language, i); + if (!p) { + return undefined; + } + let s = 0; + if (spec.full.toLowerCase() === p.full.toLowerCase()) { + s |= 4; + } else if (spec.prefix.toLowerCase() === p.prefix.toLowerCase()) { + s |= 2; + } else if (spec.full.toLowerCase() === p.prefix.toLowerCase()) { + s |= 1; + } else if (spec.full !== "*") { + return; + } + + return { i, o: spec.i, q: spec.q, s }; +} + +function getLanguagePriority( + language: string, + accepted: LanguageSpecificity[], + index: number, +): Specificity { + let priority: Specificity = { i: -1, o: -1, q: 0, s: 0 }; + for (const accepts of accepted) { + const spec = specify(language, accepts, index); + if ( + spec && + ((priority.s ?? 0) - (spec.s ?? 0) || priority.q - spec.q || + (priority.o ?? 0) - (spec.o ?? 0)) < 0 + ) { + priority = spec; + } + } + return priority; +} + +export function preferredLanguages( + accept = "*", + provided?: string[], +): string[] { + const accepts = parseAcceptLanguage(accept); + + if (!provided) { + return accepts + .filter(isQuality) + .sort(compareSpecs) + .map((spec) => spec.full); + } + + const priorities = provided + .map((type, index) => getLanguagePriority(type, accepts, index)); + + return priorities + .filter(isQuality) + .sort(compareSpecs) + .map((priority) => provided[priorities.indexOf(priority)]!); +} + +/** + * Returns an array of media types accepted by the request, in order of + * preference. If there are no media types supplied in the request, then any + * media type selector will be returned. + * + * @example Usage + * ```ts + * import { accepts } from "@std/http/negotiation"; + * import { assertEquals } from "@std/assert"; + * + * const request = new Request("https://example.com/", { + * headers: { + * accept: + * "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, *\/*;q=0.8", + * }, + * }); + * + * assertEquals(accepts(request), [ + * "text/html", + * "application/xhtml+xml", + * "image/webp", + * "application/xml", + * "*\/*", + * ]); + * ``` + * + * @param request The request to get the acceptable media types for. + * @returns An array of acceptable media types. + */ +export function accepts(request: Pick): string[]; +/** + * For a given set of media types, return the best match accepted in the + * request. If no media type matches, then the function returns `undefined`. + * + * @example Usage + * ```ts + * import { accepts } from "@std/http/negotiation"; + * import { assertEquals } from "@std/assert"; + * + * const request = new Request("https://example.com/", { + * headers: { + * accept: + * "text/html, application/xhtml+xml, application/xml;q=0.9, image/webp, *\/*;q=0.8", + * }, + * }); + * + * assertEquals(accepts(request, "text/html", "image/webp"), "text/html"); + * ``` + * + * @typeParam T The type of supported content-types (if provided). + * @param request The request to get the acceptable media types for. + * @param types An array of media types to find the best matching one from. + * @returns The best matching media type, if any match. + */ +export function accepts( + request: Pick, + ...types: T[] +): T | undefined; +export function accepts( + request: Pick, + ...types: string[] +): string | string[] | undefined { + const accept = request.headers.get("accept"); + return types.length + ? accept ? preferredMediaTypes(accept, types)[0] : types[0] + : accept + ? preferredMediaTypes(accept) + : ["*/*"]; +} + +/** + * Returns an array of content encodings accepted by the request, in order of + * preference. If there are no encoding supplied in the request, then `["*"]` + * is returned, implying any encoding is accepted. + * + * @example Usage + * ```ts + * import { acceptsEncodings } from "@std/http/negotiation"; + * import { assertEquals } from "@std/assert"; + * + * const request = new Request("https://example.com/", { + * headers: { "accept-encoding": "deflate, gzip;q=1.0, *;q=0.5" }, + * }); + * + * assertEquals(acceptsEncodings(request), ["deflate", "gzip", "*"]); + * ``` + * + * @param request The request to get the acceptable content encodings for. + * @returns An array of content encodings this request accepts. + */ +export function acceptsEncodings(request: Pick): string[]; +/** + * For a given set of content encodings, return the best match accepted in the + * request. If no content encodings match, then the function returns + * `undefined`. + * + * **NOTE:** You should always supply `identity` as one of the encodings + * to ensure that there is a match when the `Accept-Encoding` header is part + * of the request. + * + * @example Usage + * ```ts + * import { acceptsEncodings } from "@std/http/negotiation"; + * import { assertEquals } from "@std/assert"; + * + * const request = new Request("https://example.com/", { + * headers: { "accept-encoding": "deflate, gzip;q=1.0, *;q=0.5" }, + * }); + * + * assertEquals(acceptsEncodings(request, "gzip", "identity"), "gzip"); + * ``` + * + * @typeParam T The type of supported encodings (if provided). + * @param request The request to get the acceptable content encodings for. + * @param encodings An array of encodings to find the best matching one from. + * @returns The best matching encoding, if any match. + */ +export function acceptsEncodings( + request: Pick, + ...encodings: T[] +): T | undefined; +export function acceptsEncodings( + request: Pick, + ...encodings: string[] +): string | string[] | undefined { + const acceptEncoding = request.headers.get("accept-encoding"); + return encodings.length + ? acceptEncoding + ? preferredEncodings(acceptEncoding, encodings)[0] + : encodings[0] + : acceptEncoding + ? preferredEncodings(acceptEncoding) + : ["*"]; +} + +/** + * Returns an array of languages accepted by the request, in order of + * preference. If there are no languages supplied in the request, then `["*"]` + * is returned, imply any language is accepted. + * + * @example Usage + * ```ts + * import { acceptsLanguages } from "@std/http/negotiation"; + * import { assertEquals } from "@std/assert"; + * + * const request = new Request("https://example.com/", { + * headers: { + * "accept-language": "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5", + * }, + * }); + * + * assertEquals(acceptsLanguages(request), ["fr-CH", "fr", "en", "de", "*"]); + * ``` + * + * @param request The request to get the acceptable languages for. + * @returns An array of languages this request accepts. + */ +export function acceptsLanguages(request: Pick): string[]; +/** + * For a given set of languages, return the best match accepted in the request. + * If no languages match, then the function returns `undefined`. + * + * @example Usage + * ```ts + * import { acceptsLanguages } from "@std/http/negotiation"; + * import { assertEquals } from "@std/assert"; + * + * const request = new Request("https://example.com/", { + * headers: { + * "accept-language": "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5", + * }, + * }); + * + * assertEquals(acceptsLanguages(request, "en-gb", "en-us", "en"), "en"); + * ``` + * + * @typeParam T The type of supported languages (if provided). + * @param request The request to get the acceptable language for. + * @param langs An array of languages to find the best matching one from. + * @returns The best matching language, if any match. + */ +export function acceptsLanguages( + request: Pick, + ...langs: T[] +): T | undefined; +export function acceptsLanguages( + request: Pick, + ...langs: string[] +): string | string[] | undefined { + const acceptLanguage = request.headers.get("accept-language"); + return langs.length + ? acceptLanguage ? preferredLanguages(acceptLanguage, langs)[0] : langs[0] + : acceptLanguage + ? preferredLanguages(acceptLanguage) + : ["*"]; +} diff --git a/src/routes/bookmarks/[did=did]/+page.server.ts b/src/routes/bookmarks/[did=did]/+page.server.ts index 0191a52..6cdb85b 100644 --- a/src/routes/bookmarks/[did=did]/+page.server.ts +++ b/src/routes/bookmarks/[did=did]/+page.server.ts @@ -2,6 +2,13 @@ import { isAuthEvent } from "$lib/types"; import { parseBookmark } from "$lib/valibot"; import * as TID from "@atcute/tid"; import { type Actions, fail } from "@sveltejs/kit"; +import type { PageServerLoad } from "./$types"; + +export const load: PageServerLoad = async ({ locals }) => { + return { + locale: locals.locale, + }; +}; export const actions = { deleteBookmark: async (event) => { diff --git a/src/routes/bookmarks/[did=did]/+page.svelte b/src/routes/bookmarks/[did=did]/+page.svelte index 67c6feb..1c4af56 100644 --- a/src/routes/bookmarks/[did=did]/+page.svelte +++ b/src/routes/bookmarks/[did=did]/+page.svelte @@ -10,6 +10,13 @@ let editDialog: HTMLDialogElement | null = $state(null); let createDialog: HTMLDialogElement | null = $state(null); + const dateFormat = $derived( + new Intl.DateTimeFormat(data.locale, { + dateStyle: "medium", + timeStyle: "short", + }), + ); + $effect(() => { if (form?.action === "editBookmark" && "error" in form) { if (editDialog?.open === false) { @@ -32,11 +39,6 @@ if (form.error) alert(form.error); } }); - - const dateFormat = new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", - }); @@ -140,7 +142,7 @@ {#if data.bookmarks.length}
-
+

Bookmarks

{#if isSelf}