56 changed files with 1205 additions and 10996 deletions
@ -1,6 +1,5 @@ |
|||||||
import tailwindcss from "tailwindcss"; |
|
||||||
import autoprefixer from "autoprefixer"; |
|
||||||
|
|
||||||
export default { |
export default { |
||||||
plugins: [tailwindcss(), autoprefixer()], |
plugins: { |
||||||
}; |
'@tailwindcss/postcss': {} |
||||||
|
} |
||||||
|
} |
||||||
|
|||||||
@ -0,0 +1,52 @@ |
|||||||
|
{ |
||||||
|
"files": [ |
||||||
|
{ |
||||||
|
"path": "src/lib/theme/build-tokens.ts", |
||||||
|
"content": "#!/usr/bin/env -S deno run --allow-read --allow-write\nimport { join } from \"https://deno.land/std@0.224.0/path/mod.ts\";\nimport { parse as parseYaml } from \"https://deno.land/std@0.224.0/yaml/mod.ts\";\nconst themesDir = \"src/lib/theme/themes\";\nconst outCss = \"src/lib/theme/generated/themes.css\";\nfunction toRgb(hex: string) {\n const h = hex.replace(\"#\", \"\").trim();\n const n = (s: string) => parseInt(s, 16);\n if (h.length === 3) return `${n(h[0]+h[0])} ${n(h[1]+h[1])} ${n(h[2]+h[2])}`;\n return `${n(h.slice(0,2))} ${n(h.slice(2,4))} ${n(h.slice(4,6))}`;\n}\nconst entries: string[] = [];\nfor await (const ent of Deno.readDir(themesDir)) if (ent.isFile && ent.name.endsWith(\".yaml\")) entries.push(ent.name);\nentries.sort();\nlet css = \"\";\nfor (const file of entries) {\n const t = parseYaml(await Deno.readTextFile(join(themesDir, file))) as any;\n const sel = t.name === \"light\" ? ':root,[data-theme=\"light\"]' : `[data-theme=\"${t.name}\"]`;\n css += `${sel}{\\n`;\n for (const [k, v] of Object.entries(t.colors ?? {})) css += `--color-${k}: ${toRgb(String(v))};\\n`;\n for (const [k, v] of Object.entries(t.radii ?? {})) css += `--radius-${k}: ${v};\\n`;\n for (const [k, v] of Object.entries(t.spacing ?? {})) css += `--space-${k}: ${v};\\n`;\n const ty = t.typography ?? {};\n if (ty[\"font-reading\"]) css += `--font-reading: ${ty[\"font-reading\"]};\\n`;\n if (ty[\"font-ui\"]) css += `--font-ui: ${ty[\"font-ui\"]};\\n`;\n if (ty[\"leading-reading\"]) css += `--leading-reading: ${ty[\"leading-reading\"]};\\n`;\n if (ty[\"measure-ch\"]) css += `--measure-ch: ${ty[\"measure-ch\"]};\\n`;\n css += `}\\n\\n`;\n}\nawait Deno.mkdir(join(\"src/lib/theme/generated\"), { recursive: true });\nawait Deno.writeTextFile(outCss, css);\nconsole.log(\"Wrote\", outCss);\n" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"path": "deno.json", |
||||||
|
"content": "{\n \"tasks\": {\n \"tokens\": \"deno run --allow-read --allow-write src/lib/theme/build-tokens.ts\",\n \"scaffold\": \"deno run --allow-read --allow-write scripts/scaffold.ts scripts/a-ui.v5.manifest.json\"\n }\n}\n" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"path": "src/lib/a/primitives/AButton.svelte", |
||||||
|
"content": "<script lang=\"ts\">\n import { cva, twMerge } from '$lib/styles/cva';\n let {\n variant = 'solid',\n size = 'md',\n as = 'button',\n disabled = false,\n class: className = '',\n // common attrs (no $$restProps in runes)\n href = undefined as string | undefined,\n target = undefined as string | undefined,\n rel = undefined as string | undefined,\n type = 'button',\n onclick = undefined as undefined | ((e:MouseEvent)=>void)\n } = $props();\n\n const styles = cva(\n 'inline-flex items-center justify-center font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-primary/40 transition',\n { variants: { variant:\n { solid: 'bg-primary text-[rgb(var(--color-primary-contrast,255 255 255))] hover:bg-primary/90',\n outline: 'border border-primary text-primary hover:bg-primary/10',\n ghost: 'text-primary hover:bg-primary/10' },\n size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4', lg: 'h-12 px-5 text-lg' } },\n defaultVariants: { variant: 'solid', size: 'md' } }\n );\n</script>\n\n<svelte:element\n this={as}\n class={twMerge(styles({ variant, size }), className)}\n disabled={as === 'button' ? disabled : undefined}\n href={as === 'a' ? href : undefined}\n target={as === 'a' ? target : undefined}\n rel={as === 'a' ? rel : undefined}\n type={as === 'button' ? type : undefined}\n onclick={onclick}\n>\n <slot />\n</svelte:element>\n" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"path": "src/lib/a/primitives/AInput.svelte", |
||||||
|
"content": "<script lang=\"ts\"> let { value = '', class: className = '', placeholder = '' } = $props(); </script>\n<input\n class={`w-full h-10 px-3 rounded-md border border-muted/30 bg-surface text-text placeholder:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/40 ${className}`}\n bind:value\n placeholder={placeholder}\n/>\n" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"path": "src/lib/a/primitives/ACard.svelte", |
||||||
|
"content": "<script lang=\"ts\"> let { class: className = '' } = $props(); </script>\n<div class={`rounded-lg border border-muted/20 bg-surface shadow-sm ${className}`}>\n <slot name=\"header\" />\n <div class=\"p-4\"><slot /></div>\n <slot name=\"footer\" />\n</div>\n" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"path": "src/lib/a/primitives/ASwitch.svelte", |
||||||
|
"content": "<script lang=\"ts\">\n let { checked = false, class: className = '', onchange = undefined as undefined | ((v:boolean)=>void) } = $props();\n function toggle(){ checked = !checked; onchange?.(checked); }\n</script>\n<button type=\"button\" role=\"switch\" aria-checked={checked}\n onclick={toggle}\n class={`inline-flex items-center h-6 w-11 rounded-full border border-muted/30 transition px-0.5 ${checked ? 'bg-primary' : 'bg-surface'} ${className}`}\n>\n <span class={`inline-block h-5 w-5 rounded-full bg-white shadow transform transition ${checked ? 'translate-x-5' : 'translate-x-0'}`} />\n</button>\n" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"path": "src/lib/a/primitives/ADetails.svelte", |
||||||
|
"content": "<script lang=\"ts\">\n import { showTech } from '$lib/tech/tech-store';\n let { summary = '', tech = false, defaultOpen = false, forceHide = false, class: className = '' } = $props();\n let open = defaultOpen;\n $: if (tech && !$showTech) open = false;\n function onToggle(e: Event){ const el = e.currentTarget as HTMLDetailsElement; open = el.open; }\n</script>\n<details open={open} ontoggle={onToggle} class={`group rounded-lg border border-muted/20 bg-surface ${className}`} data-kind={tech ? 'tech':'general'}>\n <summary class=\"flex items-center gap-2 cursor-pointer list-none px-3 py-2 rounded-lg select-none hover:bg-primary/10\">\n <svg class={`h-4 w-4 transition-transform ${open ? 'rotate-90':''}`} viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><path d=\"M9 18l6-6-6-6\"/></svg>\n <span class=\"font-medium\">{summary}</span>\n {#if tech}<span class=\"ml-2 text-[10px] uppercase tracking-wide rounded px-1.5 py-0.5 border border-primary/30 text-primary bg-primary/5\">Technical</span>{/if}\n <span class=\"ml-auto text-xs opacity-60 group-open:opacity-50\">{open ? 'Hide':'Show'}</span>\n </summary>\n {#if !(tech && !$showTech && forceHide)}<div class=\"px-3 pb-3 pt-1 text-[0.95rem] leading-6\"><slot /></div>{/if}\n</details>\n" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"path": "src/lib/a/reader/AReaderTOC.svelte", |
||||||
|
"content": "<script lang=\"ts\">\n import { onMount } from 'svelte';\n import TocNode from './TocNode.svelte';\n import type { TocItem } from './toc-utils';\n let { items = [] as TocItem[], activeId = null as string | null, collapsible = true, expandDepth = 1, class: className = '', onnavigate = undefined as undefined | ((href:string)=>void) } = $props();\n let expanded = new Set<string>();\n let parentOf = new Map<string, string | null>();\n function mapParents(list: TocItem[], parent: string | null = null){ for (const it of list){ parentOf.set(it.id, parent); if (it.children?.length) mapParents(it.children, it.id); } }\n $: mapParents(items);\n function initExpansion(list: TocItem[], depth = 0){ for (const it of list){ if (depth < expandDepth) expanded.add(it.id); if (it.children?.length) initExpansion(it.children, depth + 1); } expanded = new Set(expanded); }\n function expandAncestors(id: string | null){ if (!id) return; let cur: string | null | undefined = id; while (cur){ expanded.add(cur); cur = parentOf.get(cur) ?? null; } expanded = new Set(expanded); }\n onMount(()=>{ initExpansion(items,0); expandAncestors(activeId); if (activeId){ const el = document.querySelector(`[data-toc-id=\"${activeId}\"]`); if (el instanceof HTMLElement) el.scrollIntoView({ block:'nearest' }); } });\n $: if (activeId) expandAncestors(activeId);\n function toggle(id: string){ if (!collapsible) return; if (expanded.has(id)) expanded.delete(id); else expanded.add(id); expanded = new Set(expanded); }\n const pad = (depth:number)=>`padding-left: calc(var(--space-4) * ${Math.max(depth,0)})`;\n const onNavigate = (href:string)=> onnavigate?.(href);\n</script>\n<nav aria-label=\"Table of contents\" class={`text-sm ${className}`}>\n <ul class=\"space-y-1 max-h-[calc(100vh-6rem)] overflow-auto pr-1\">\n {#each items as item (item.id)}\n <TocNode {item} depth={0} {activeId} {collapsible} {expanded} {toggle} {pad} {onNavigate} />\n {/each}\n </ul>\n</nav>\n" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"path": "src/lib/a/reader/TocNode.svelte", |
||||||
|
"content": "<script lang=\"ts\">\n import type { TocItem } from './toc-utils';\n import TocNode from './TocNode.svelte';\n let { item, depth = 0, activeId = null as string | null, collapsible = true, expanded, toggle, pad, onNavigate }: { item: TocItem; depth?: number; activeId?: string|null; collapsible?: boolean; expanded: Set<string>; toggle: (id:string)=>void; pad: (depth:number)=>string; onNavigate: (href:string)=>void; } = $props();\n const hasChildren = !!(item.children && item.children.length > 0);\n $: isOpen = expanded.has(item.id);\n $: isActive = activeId === item.id;\n</script>\n<li>\n <div class=\"flex items-center gap-1 rounded-md hover:bg-primary/10\" style={pad(depth)}>\n {#if collapsible && hasChildren}\n <button class=\"shrink-0 h-6 w-6 grid place-items-center rounded-md hover:bg-primary/10\" aria-label={isOpen?`Collapse ${item.title}`:`Expand ${item.title}`} aria-expanded={isOpen} onclick={() => toggle(item.id)}>\n <svg width=\"16\" height=\"16\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class={isOpen ? 'rotate-90 transition-transform' : 'transition-transform'}><path d=\"M9 18l6-6-6-6\" /></svg>\n </button>\n {:else}\n <span class=\"shrink-0 h-6 w-6\" />\n {/if}\n <a href={item.href ?? `#${item.id}`} data-toc-id={item.id}\n class=\"flex-1 min-w-0 rounded-md px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-primary/40\"\n class:hover=\"bg-primary/10\" class:text-primary={isActive} class:bg-primary/10={isActive}\n onclick={() => onNavigate(item.href ?? `#${item.id}`)}>\n <span class=\"truncate\">{item.title}</span>\n </a>\n </div>\n {#if hasChildren}\n <ul id={`sub-${item.id}`} aria-hidden={!isOpen} class={isOpen ? 'mt-1 space-y-1' : 'hidden'}>\n {#each item.children as child (child.id)}\n <TocNode item={child} depth={depth + 1} {activeId} {collapsible} {expanded} {toggle} {pad} {onNavigate} />\n {/each}\n </ul>\n {/if}\n</li>\n" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"path": "src/lib/a/nav/ANavbar.svelte", |
||||||
|
"content": "<script lang=\"ts\">\n import type { NavItem, UserInfo } from './nav-types';\n import UserDropdown from './UserDropdown.svelte';\n import MobileNav from './MobileNav.svelte';\n import { onMount } from 'svelte';\n let { items = [] as NavItem[], currentPath = '', user = null as UserInfo | null, userMenu = [] as NavItem[], logo = null, brand = 'Alexandria', onselect = undefined as undefined | ((i:NavItem)=>void) } = $props();\n let mobileOpen = false; let openId: string | null = null;\n function onKey(e: KeyboardEvent){ if (e.key === 'Escape'){ openId = null; mobileOpen = false; } }\n onMount(()=>{ window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); });\n const topItems = items.map((it,i)=>({ ...it, id: it.id || `${(it.title||'item').toLowerCase().replace(/[^\\w]+/g,'-')}-${i}` }));\n function isActive(href?: string){ if (!href) return false; try { return new URL(href, 'http://x').pathname === currentPath; } catch { return href === currentPath; } }\n</script>\n<header class=\"sticky top-0 z-40 border-b border-muted/20 bg-surface/95 backdrop-blur supports-[backdrop-filter]:bg-surface/80\">\n <div class=\"mx-auto max-w-7xl px-3 sm:px-4 lg:px-6\">\n <div class=\"h-14 flex items-center gap-3\">\n <button class=\"lg:hidden inline-flex h-9 w-9 items-center justify-center rounded-md border border-muted/30\" aria-label=\"Open menu\" onclick={() => (mobileOpen = true)}>\n <svg width=\"20\" height=\"20\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"3\" y1=\"6\" x2=\"21\" y2=\"6\"/><line x1=\"3\" y1=\"12\" x2=\"21\" y2=\"12\"/><line x1=\"3\" y1=\"18\" x2=\"21\" y2=\"18\"/></svg>\n </button>\n <a href=\"/\" class=\"flex items-center gap-2\">{#if logo}<svelte:component this={logo} />{/if}<span class=\"font-semibold\">{brand}</span></a>\n <nav class=\"ml-4 hidden lg:flex items-stretch gap-1\">\n {#each topItems as item}\n <div class=\"relative\" onmouseleave={() => (openId = null)}>\n <button class=\"px-2.5 h-10 rounded-md inline-flex items-center gap-1.5 hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-primary/40\"\n aria-haspopup={item.children?.length ? 'menu' : undefined}\n aria-expanded={openId === item.id}\n onmouseenter={() => (openId = item.children?.length ? item.id! : null)}\n onfocus={() => (openId = item.children?.length ? item.id! : null)}\n onclick={() => (openId = openId === item.id ? null : (item.children?.length ? item.id! : null))}\n >\n {#if item.icon}<svelte:component this={item.icon} class=\"h-4 w-4\" />{/if}\n <a href={item.href || '#'} class=\"px-0.5 rounded-md\" class:text-primary={isActive(item.href)}>{item.title}</a>\n {#if item.children?.length}<svg class=\"h-4 w-4 opacity-70\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"6 9 12 15 18 9\"/></svg>{/if}\n {#if item.badge}<span class=\"text-[10px] ml-1 rounded px-1.5 py-0.5 border border-primary/30 text-primary bg-primary/5\">{item.badge}</span>{/if}\n </button>\n {#if item.children?.length && openId === item.id}\n <div class=\"absolute left-0 mt-1 w-[min(90vw,48rem)] rounded-lg border border-muted/20 bg-surface shadow-lg p-3\">\n <div class=\"grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3\">\n {#each item.children as section}\n <div>\n <a href={section.href || '#'} class=\"font-medium hover:underline flex items-center gap-2\">{#if section.icon}<svelte:component this={section.icon} class=\"h-4 w-4\" />{/if}{section.title}</a>\n {#if section.children?.length}\n <ul class=\"mt-2 space-y-1\">\n {#each section.children as leaf}\n <li><a href={leaf.href || '#'} target={leaf.external ? '_blank' : undefined} rel={leaf.external ? 'noreferrer' : undefined} class=\"block rounded-md px-2 py-1 hover:bg-primary/10\">{leaf.title}</a></li>\n {/each}\n </ul>\n {/if}\n </div>\n {/each}\n </div>\n </div>\n {/if}\n </div>\n {/each}\n </nav>\n <div class=\"ml-auto flex items-center gap-2\">\n <slot name=\"search\" />\n <UserDropdown {user} items={userMenu} onselect={onselect} />\n </div>\n </div>\n </div>\n <MobileNav bind:open={mobileOpen} {items} {user} userItems={userMenu} />\n</header>\n" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"path": "src/lib/a/nav/UserDropdown.svelte", |
||||||
|
"content": "<script lang=\"ts\">\n import type { NavItem, UserInfo } from './nav-types';\n import { onMount } from 'svelte';\n let { user = null as UserInfo | null, items = [] as NavItem[], onselect = undefined as undefined | ((i:NavItem)=>void) } = $props();\n let open = false; let btn: HTMLButtonElement;\n function onDoc(e: MouseEvent){ if (!open) return; if (!(e.target instanceof Node)) return; if (!btn?.parentElement?.contains(e.target)) open = false; }\n onMount(()=>{ document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); });\n</script>\n<div class=\"relative\">\n <button bind:this={btn} class=\"inline-flex items-center gap-2 h-9 px-2 rounded-md border border-muted/30 hover:bg-primary/10\" aria-haspopup=\"menu\" aria-expanded={open} onclick={() => (open = !open)}>\n <img src={user?.avatarUrl || 'https://via.placeholder.com/24'} alt=\"\" class=\"h-6 w-6 rounded-full object-cover\" />\n <span class=\"hidden sm:block text-sm\">{user?.name || 'Account'}</span>\n <svg class=\"h-4 w-4 opacity-70\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"6 9 12 15 18 9\"/></svg>\n </button>\n {#if open}\n <div class=\"absolute right-0 mt-1 min-w-[14rem] rounded-lg border border-muted/20 bg-surface shadow-lg py-1 z-50\">\n {#if user}\n <div class=\"px-3 py-2 text-sm\">\n <div class=\"font-medium\">{user.name}</div>\n {#if user.email}<div class=\"opacity-70\">{user.email}</div>{/if}\n </div>\n <div class=\"my-1 h-px bg-muted/20\" />\n {/if}\n <ul>\n {#each items as it}\n {#if it.divider}\n <li class=\"my-1 h-px bg-muted/20\"></li>\n {:else}\n <li>\n <a href={it.href || '#'} target={it.external ? '_blank':undefined} rel={it.external ? 'noreferrer':undefined}\n class=\"flex items-center gap-2 px-3 py-2 text-sm hover:bg-primary/10\"\n onclick={(e)=>{ if (!it.href || it.href==='#'){ e.preventDefault(); open=false; onselect?.(it); } else { open=false; } }}>\n {it.title}\n {#if it.badge}<span class=\"ml-auto text-[10px] rounded px-1.5 py-0.5 border border-primary/30 text-primary bg-primary/5\">{it.badge}</span>{/if}\n </a>\n </li>\n {/if}\n {/each}\n </ul>\n </div>\n {/if}\n</div>\n" |
||||||
|
}, |
||||||
|
{ |
||||||
|
"path": "src/lib/a/nav/MobileNav.svelte", |
||||||
|
"content": "<script lang=\"ts\">\n import type { NavItem, UserInfo } from './nav-types';\n let { open = false, items = [] as NavItem[], user = null as UserInfo | null, userItems = [] as NavItem[] } = $props();\n function close(){ open = false; }\n</script>\n{#if open}\n <div class=\"fixed inset-0 z-50\">\n <div class=\"absolute inset-0 bg-black/40\" onclick={close}></div>\n <aside class=\"absolute left-0 top-0 h-full w-[88vw] max-w-sm bg-surface border-r border-muted/20 shadow-xl p-3 overflow-y-auto\">\n <div class=\"flex items-center gap-3\">\n <img src={user?.avatarUrl || 'https://via.placeholder.com/32'} alt=\"\" class=\"h-8 w-8 rounded-full\" />\n <div>\n <div class=\"text-sm font-medium\">{user?.name || 'Welcome'}</div>\n {#if user?.email}<div class=\"text-xs opacity-70\">{user.email}</div>{/if}\n </div>\n <button class=\"ml-auto h-8 w-8 grid place-items-center rounded-md border border-muted/30\" onclick={close} aria-label=\"Close\">\n <svg width=\"18\" height=\"18\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><line x1=\"18\" y1=\"6\" x2=\"6\" y2=\"18\"/><line x1=\"6\" y1=\"6\" x2=\"18\" y2=\"18\"/></svg>\n </button>\n </div>\n <nav class=\"mt-4\">\n <ul class=\"space-y-1\">\n {#each items as item}\n <li>\n {#if item.children?.length}\n <details>\n <summary class=\"flex items-center gap-2 cursor-pointer list-none rounded-md px-2 py-2 hover:bg-primary/10\">\n <span class=\"flex-1\">{item.title}</span>\n <svg class=\"h-4 w-4\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"6 9 12 15 18 9\"/></svg>\n </summary>\n <ul class=\"mt-1 ml-3 space-y-1\">\n {#each item.children as child}\n <li>\n {#if child.children?.length}\n <details>\n <summary class=\"flex items-center gap-2 cursor-pointer list-none rounded-md px-2 py-2 hover:bg-primary/10\">\n <span class=\"flex-1\">{child.title}</span>\n <svg class=\"h-4 w-4\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\"><polyline points=\"6 9 12 15 18 9\"/></svg>\n </summary>\n <ul class=\"mt-1 ml-3 space-y-1\">\n {#each child.children as leaf}\n <li><a href={leaf.href || '#'} target={leaf.external ? '_blank' : undefined} rel={leaf.external ? 'noreferrer' : undefined} class=\"block rounded-md px-2 py-2 hover:bg-primary/10\">{leaf.title}</a></li>\n {/each}\n </ul>\n </details>\n {:else}\n <a href={child.href || '#'} class=\"block rounded-md px-2 py-2 hover:bg-primary/10\">{child.title}</a>\n {/if}\n </li>\n {/each}\n </ul>\n </details>\n {:else}\n <a href={item.href || '#'} class=\"block rounded-md px-2 py-2 hover:bg-primary/10\">{item.title}</a>\n {/if}\n </li>\n {/each}\n </ul>\n </nav>\n {#if userItems.length}\n <div class=\"my-4 h-px bg-muted/20\"></div>\n <ul class=\"space-y-1\">\n {#each userItems as it}\n {#if it.divider}\n <li class=\"my-2 h-px bg-muted/20\"></li>\n {:else}\n <li><a href={it.href || '#'} class=\"block rounded-md px-2 py-2 hover:bg-primary/10\">{it.title}</a></li>\n {/if}\n {/each}\n </ul>\n {/if}\n </aside>\n </div>\n{/if}\n" |
||||||
|
} |
||||||
|
] |
||||||
|
} |
||||||
@ -0,0 +1,84 @@ |
|||||||
|
#!/usr/bin/env -S deno run --allow-read --allow-write |
||||||
|
import { dirname, resolve } from "jsr:@std/path"; |
||||||
|
|
||||||
|
/** Read and parse JSON */ |
||||||
|
async function readJson(p: string) { |
||||||
|
try { |
||||||
|
const txt = await Deno.readTextFile(p); |
||||||
|
return JSON.parse(txt); |
||||||
|
} catch (e) { |
||||||
|
console.error(`Failed to read JSON: ${p}\n${e.message}`); |
||||||
|
Deno.exit(1); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** Ensure parent directory exists */ |
||||||
|
async function ensureDir(filePath: string) { |
||||||
|
await Deno.mkdir(dirname(filePath), { recursive: true }); |
||||||
|
} |
||||||
|
|
||||||
|
/** File exists? */ |
||||||
|
async function exists(p: string) { |
||||||
|
try { |
||||||
|
await Deno.lstat(p); |
||||||
|
return true; |
||||||
|
} catch (e) { |
||||||
|
if (e instanceof Deno.errors.NotFound) return false; |
||||||
|
throw e; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
/** Write file if changed; return action marker */ |
||||||
|
async function writeFile(p: string, content: string) { |
||||||
|
await ensureDir(p); |
||||||
|
const had = await exists(p); |
||||||
|
if (had) { |
||||||
|
const current = await Deno.readTextFile(p); |
||||||
|
if (current === content) return "skip" as const; |
||||||
|
} |
||||||
|
await Deno.writeTextFile(p, content); |
||||||
|
return had ? ("update" as const) : ("create" as const); |
||||||
|
} |
||||||
|
|
||||||
|
/** Shallow-merge package.json fields */ |
||||||
|
function mergePackage(pkg: any, merge: any) { |
||||||
|
const next = { ...pkg }; |
||||||
|
for (const key of ["scripts", "dependencies", "devDependencies"]) { |
||||||
|
if (merge?.[key]) next[key] = { ...(pkg[key] || {}), ...merge[key] }; |
||||||
|
} |
||||||
|
return next; |
||||||
|
} |
||||||
|
|
||||||
|
async function main() { |
||||||
|
const manifestPath = Deno.args[0]; |
||||||
|
if (!manifestPath) { |
||||||
|
console.error("Usage: deno run --allow-read --allow-write scripts/scaffold.ts <manifest.json>"); |
||||||
|
Deno.exit(1); |
||||||
|
} |
||||||
|
|
||||||
|
const manifest = await readJson(manifestPath); |
||||||
|
|
||||||
|
// Optional: merge package.json if present
|
||||||
|
const pkgPath = resolve(Deno.cwd(), "package.json"); |
||||||
|
if (manifest.package?.merge && (await exists(pkgPath))) { |
||||||
|
const pkg = await readJson(pkgPath); |
||||||
|
const merged = mergePackage(pkg, manifest.package.merge); |
||||||
|
await Deno.writeTextFile(pkgPath, JSON.stringify(merged, null, 2) + "\n"); |
||||||
|
console.log("✓ package.json merged"); |
||||||
|
} else if (manifest.package?.merge) { |
||||||
|
console.log("• package.json not found — skipping merge"); |
||||||
|
} |
||||||
|
|
||||||
|
// Write files from manifest
|
||||||
|
const files = manifest.files ?? []; |
||||||
|
for (const f of files) { |
||||||
|
const out = resolve(Deno.cwd(), String(f.path)); |
||||||
|
const action = await writeFile(out, String(f.content ?? "")); |
||||||
|
const marker = action === "skip" ? "•" : action === "update" ? "↻" : "✓"; |
||||||
|
console.log(`${marker} ${f.path}`); |
||||||
|
} |
||||||
|
|
||||||
|
console.log("\nDone."); |
||||||
|
} |
||||||
|
|
||||||
|
if (import.meta.main) main(); |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
export { default as AButton } from './primitives/AButton.svelte'; |
||||||
|
export { default as AInput } from './primitives/AInput.svelte'; |
||||||
|
export { default as ACard } from './primitives/ACard.svelte'; |
||||||
|
export { default as ASwitch } from './primitives/ASwitch.svelte'; |
||||||
|
export { default as ADetails } from './primitives/ADetails.svelte'; |
||||||
|
export { default as ANostrUser } from './primitives/ANostrUser.svelte'; |
||||||
|
export { default as ANostrBadge } from './primitives/ANostrBadge.svelte'; |
||||||
|
export { default as ANostrBadgeRow } from './primitives/ANostrBadgeRow.svelte'; |
||||||
|
export { default as AThemeToggleMini } from './primitives/AThemeToggleMini.svelte'; |
||||||
|
|
||||||
|
export { default as AReaderPage } from './reader/AReaderPage.svelte'; |
||||||
|
export { default as AReaderToolbar } from './reader/AReaderToolbar.svelte'; |
||||||
|
export { default as AReaderTOC } from './reader/AReaderTOC.svelte'; |
||||||
|
export { default as ATechToggle } from './reader/ATechToggle.svelte'; |
||||||
|
export { default as ATechBlock } from './reader/ATechBlock.svelte'; |
||||||
|
export { default as ATocNode } from './reader/ATocNode.svelte'; |
||||||
|
|
||||||
|
export { default as ANavbar } from './nav/ANavbar.svelte'; |
||||||
|
export { default as AFooter } from './nav/AFooter.svelte'; |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
<script> |
||||||
|
import { Footer, FooterCopyright, FooterLink, FooterLinkGroup } from "flowbite-svelte"; |
||||||
|
</script> |
||||||
|
|
||||||
|
<Footer class="mx-2"> |
||||||
|
<FooterCopyright href="/" by="GitCitadel" year={2025} /> |
||||||
|
<FooterLinkGroup class="mt-3 flex flex-wrap items-center text-sm text-gray-500 sm:mt-0 dark:text-gray-400"> |
||||||
|
<FooterLink href="/about">About</FooterLink> |
||||||
|
<FooterLink href="/contact">Contact</FooterLink> |
||||||
|
</FooterLinkGroup> |
||||||
|
</Footer> |
||||||
@ -0,0 +1,82 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { |
||||||
|
DarkMode, |
||||||
|
Navbar, |
||||||
|
NavLi, |
||||||
|
NavUl, |
||||||
|
NavHamburger, |
||||||
|
NavBrand, |
||||||
|
Dropdown, |
||||||
|
DropdownItem, |
||||||
|
DropdownDivider |
||||||
|
} from "flowbite-svelte"; |
||||||
|
import { siteNav, userMenu } from "$lib/nav/site-nav.js"; |
||||||
|
import { logoutUser, userStore } from "$lib/stores/userStore"; |
||||||
|
import Profile from "$components/util/Profile.svelte"; |
||||||
|
import { shortenBech32 } from "$lib/nostr/format.ts"; |
||||||
|
import type { NavItem } from "$lib/a/nav/nav-types.ts"; |
||||||
|
import { goto } from "$app/navigation"; |
||||||
|
import { ChevronDownOutline } from "flowbite-svelte-icons"; |
||||||
|
|
||||||
|
let { |
||||||
|
currentPath = "", |
||||||
|
}: { |
||||||
|
currentPath?: string; |
||||||
|
} = $props(); |
||||||
|
|
||||||
|
let userState = $derived($userStore); |
||||||
|
|
||||||
|
function handleNavClick(item: NavItem) { |
||||||
|
if (item.href) { |
||||||
|
goto(item.href); |
||||||
|
} else if (item.id === 'logout') { |
||||||
|
logoutUser(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function flattenNavItems(navItems: NavItem[]): NavItem[] { |
||||||
|
const result: NavItem[] = []; |
||||||
|
for (const item of navItems) { |
||||||
|
if (item.children && item.children.length > 0) { |
||||||
|
result.push(...flattenNavItems(item.children)); |
||||||
|
} else { |
||||||
|
result.push(item); |
||||||
|
} |
||||||
|
} |
||||||
|
return result; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<Navbar class="flex flex-row" navContainerClass="w-full flex-row justify-between items-center"> |
||||||
|
<NavBrand href="/"> |
||||||
|
<h1>Alexandria</h1> |
||||||
|
</NavBrand> |
||||||
|
<div class="flex md:order-2"> |
||||||
|
<Profile isNav={true} pubkey={userState?.npub || undefined} /> |
||||||
|
<NavHamburger /> |
||||||
|
</div> |
||||||
|
<NavUl class="order-1" activeUrl={currentPath}> |
||||||
|
{#each siteNav as navSection} |
||||||
|
{#if navSection.children && navSection.children.length > 0} |
||||||
|
<NavLi class="cursor-pointer"> |
||||||
|
{navSection.title}<ChevronDownOutline class="text-primary-800 ms-2 inline h-6 w-6 dark:text-white" /> |
||||||
|
</NavLi> |
||||||
|
<Dropdown simple class="w-44 z-20"> |
||||||
|
{#each flattenNavItems(navSection.children) as item} |
||||||
|
<DropdownItem |
||||||
|
href={item.href || undefined} |
||||||
|
onclick={() => handleNavClick(item)} |
||||||
|
> |
||||||
|
{item.title} |
||||||
|
</DropdownItem> |
||||||
|
{/each} |
||||||
|
</Dropdown> |
||||||
|
{:else if navSection.href} |
||||||
|
<NavLi href={navSection.href}>{navSection.title}</NavLi> |
||||||
|
{/if} |
||||||
|
{/each} |
||||||
|
<NavLi> |
||||||
|
<DarkMode class="btn-leather p-0" /> |
||||||
|
</NavLi> |
||||||
|
</NavUl> |
||||||
|
</Navbar> |
||||||
@ -0,0 +1,41 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { NavItem, UserInfo } from './nav-types'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
let { user = null as UserInfo | null, items = [] as NavItem[], onselect = undefined as undefined | ((i:NavItem)=>void) } = $props(); |
||||||
|
let open = $state(false); let btn: HTMLButtonElement; |
||||||
|
function onDoc(e: MouseEvent){ if (!open) return; if (!(e.target instanceof Node)) return; if (!btn?.parentElement?.contains(e.target)) open = false; } |
||||||
|
onMount(()=>{ document.addEventListener('mousedown', onDoc); return () => document.removeEventListener('mousedown', onDoc); }); |
||||||
|
</script> |
||||||
|
<div class="relative"> |
||||||
|
<button bind:this={btn} class="inline-flex items-center gap-2 h-9 px-2 rounded-md border border-muted/30 hover:bg-primary/10" aria-haspopup="menu" aria-expanded={open} onclick={() => (open = !open)}> |
||||||
|
<img src={user?.avatarUrl || 'https://via.placeholder.com/24'} alt="" class="h-6 w-6 rounded-full object-cover" /> |
||||||
|
<span class="hidden sm:block text-sm">{user?.name || 'Account'}</span> |
||||||
|
<svg class="h-4 w-4 opacity-70" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg> |
||||||
|
</button> |
||||||
|
{#if open} |
||||||
|
<div class="absolute right-0 mt-1 min-w-[14rem] rounded-lg border border-muted/20 bg-surface shadow-lg py-1 z-50"> |
||||||
|
{#if user} |
||||||
|
<div class="px-3 py-2 text-sm"> |
||||||
|
<div class="font-medium">{user.name}</div> |
||||||
|
</div> |
||||||
|
<div class="my-1 h-px bg-muted/20" ></div> |
||||||
|
{/if} |
||||||
|
<ul> |
||||||
|
{#each items as it} |
||||||
|
{#if it.divider} |
||||||
|
<li class="my-1 h-px bg-muted/20"></li> |
||||||
|
{:else} |
||||||
|
<li> |
||||||
|
<a href={it.href || '#'} target={it.external ? '_blank':undefined} rel={it.external ? 'noreferrer':undefined} |
||||||
|
class="flex items-center gap-2 px-3 py-2 text-sm hover:bg-primary/10" |
||||||
|
onclick={(e)=>{ if (!it.href || it.href==='#'){ e.preventDefault(); open=false; onselect?.(it); } else { open=false; } }}> |
||||||
|
{it.title} |
||||||
|
{#if it.badge}<span class="ml-auto text-[10px] rounded px-1.5 py-0.5 border border-primary/30 text-primary bg-primary/5">{it.badge}</span>{/if} |
||||||
|
</a> |
||||||
|
</li> |
||||||
|
{/if} |
||||||
|
{/each} |
||||||
|
</ul> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
export type NavItem = { |
||||||
|
id?: string; |
||||||
|
title: string; |
||||||
|
href?: string; |
||||||
|
icon?: any; |
||||||
|
badge?: string; |
||||||
|
children?: NavItem[]; |
||||||
|
divider?: boolean; |
||||||
|
external?: boolean; |
||||||
|
}; |
||||||
|
|
||||||
|
export type UserInfo = { |
||||||
|
name: string; |
||||||
|
avatarUrl?: string; |
||||||
|
}; |
||||||
@ -0,0 +1,39 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { cva, twMerge } from '$lib/styles/cva'; |
||||||
|
let { |
||||||
|
variant = 'solid', |
||||||
|
size = 'md', |
||||||
|
as = 'button', |
||||||
|
disabled = false, |
||||||
|
class: className = '', |
||||||
|
// common attrs (no $$restProps in runes) |
||||||
|
href = undefined as string | undefined, |
||||||
|
target = undefined as string | undefined, |
||||||
|
rel = undefined as string | undefined, |
||||||
|
type = 'button', |
||||||
|
onclick = undefined as undefined | ((e:MouseEvent)=>void) |
||||||
|
} = $props(); |
||||||
|
|
||||||
|
const styles = cva( |
||||||
|
'inline-flex items-center justify-center font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-primary/40 transition', |
||||||
|
{ variants: { variant: |
||||||
|
{ solid: 'bg-primary text-[rgb(var(--color-primary-contrast,255 255 255))] hover:bg-primary/90', |
||||||
|
outline: 'border border-primary text-primary hover:bg-primary/10', |
||||||
|
ghost: 'text-primary hover:bg-primary/10' }, |
||||||
|
size: { sm: 'h-8 px-3 text-sm', md: 'h-10 px-4', lg: 'h-12 px-5 text-lg' } }, |
||||||
|
defaultVariants: { variant: 'solid', size: 'md' } } |
||||||
|
); |
||||||
|
</script> |
||||||
|
|
||||||
|
<svelte:element |
||||||
|
this={as} |
||||||
|
class={twMerge(styles({ variant, size }), className)} |
||||||
|
disabled={as === 'button' ? disabled : undefined} |
||||||
|
href={as === 'a' ? href : undefined} |
||||||
|
target={as === 'a' ? target : undefined} |
||||||
|
rel={as === 'a' ? rel : undefined} |
||||||
|
type={as === 'button' ? type : undefined} |
||||||
|
onclick={onclick} |
||||||
|
> |
||||||
|
<slot /> |
||||||
|
</svelte:element> |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
<script lang="ts"> let { class: className = '' } = $props();
</script> |
||||||
|
<div class={`rounded-lg border border-muted/20 bg-surface shadow-sm ${className}`}> |
||||||
|
<slot name="header" /> |
||||||
|
<div class="p-4"><slot /></div> |
||||||
|
<slot name="footer" /> |
||||||
|
</div> |
||||||
@ -0,0 +1,16 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { showTech } from '$lib/stores/techStore'; |
||||||
|
let { summary = '', tech = false, defaultOpen = false, forceHide = false, class: className = '' } = $props(); |
||||||
|
let open = $derived(defaultOpen); |
||||||
|
$effect(() => { if (tech && !$showTech) open = false; }); |
||||||
|
function onToggle(e: Event){ const el = e.currentTarget as HTMLDetailsElement; open = el.open; } |
||||||
|
</script> |
||||||
|
<details open={open} ontoggle={onToggle} class={`group rounded-lg border border-muted/20 bg-surface ${className}`} data-kind={tech ? 'tech':'general'}> |
||||||
|
<summary class="flex items-center gap-2 cursor-pointer list-none px-3 py-2 rounded-lg select-none hover:bg-primary/10"> |
||||||
|
<svg class={`h-4 w-4 transition-transform ${open ? 'rotate-90':''}`} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 18l6-6-6-6"/></svg> |
||||||
|
<span class="font-medium">{summary}</span> |
||||||
|
{#if tech}<span class="ml-2 text-[10px] uppercase tracking-wide rounded px-1.5 py-0.5 border border-primary/30 text-primary bg-primary/5">Technical</span>{/if} |
||||||
|
<span class="ml-auto text-xs opacity-60 group-open:opacity-50">{open ? 'Hide':'Show'}</span> |
||||||
|
</summary> |
||||||
|
{#if !(tech && !$showTech && forceHide)}<div class="px-3 pb-3 pt-1 text-[0.95rem] leading-6"><slot /></div>{/if} |
||||||
|
</details> |
||||||
@ -0,0 +1,6 @@ |
|||||||
|
<script lang="ts"> let { value = '', class: className = '', placeholder = '' } = $props();
</script> |
||||||
|
<input |
||||||
|
class={`w-full h-10 px-3 rounded-md border border-muted/30 bg-surface text-text placeholder:opacity-60 focus:outline-none focus:ring-2 focus:ring-primary/40 ${className}`} |
||||||
|
bind:value |
||||||
|
placeholder={placeholder} |
||||||
|
/> |
||||||
@ -0,0 +1,27 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { DisplayBadge } from '$lib/nostr/nip58'; |
||||||
|
export let badge: DisplayBadge; |
||||||
|
export let size: 'xs' | 's' | 'm' | 'l' = 's'; |
||||||
|
const px = { xs: 16, s: 24, m: 32, l: 48 }[size]; |
||||||
|
</script> |
||||||
|
|
||||||
|
<span class="inline-flex items-center" title={badge.title}> |
||||||
|
{#if badge.thumbUrl} |
||||||
|
<img |
||||||
|
src={badge.thumbUrl} |
||||||
|
alt={badge.title} |
||||||
|
width={px} |
||||||
|
height={px} |
||||||
|
loading="lazy" |
||||||
|
decoding="async" |
||||||
|
class="rounded-md border border-muted/20 object-cover" |
||||||
|
/> |
||||||
|
{:else} |
||||||
|
<span |
||||||
|
class="grid place-items-center rounded-md border border-muted/20 bg-surface text-xs" |
||||||
|
style={`width:${px}px;height:${px}px`} |
||||||
|
> |
||||||
|
{badge.title.slice(0, 1)} |
||||||
|
</span> |
||||||
|
{/if} |
||||||
|
</span> |
||||||
@ -0,0 +1,19 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { DisplayBadge } from '$lib/nostr/nip58'; |
||||||
|
import ANostrBadge from './ANostrBadge.svelte'; |
||||||
|
export let badges: DisplayBadge[] = []; |
||||||
|
export let size: 'xs' | 's' | 'm' | 'l' = 's'; |
||||||
|
export let limit: number = 6; |
||||||
|
const shown = () => badges.slice(0, limit); |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-1.5 items-center"> |
||||||
|
{#each shown() as b (b.def.id)} |
||||||
|
<ANostrBadge badge={b} {size} /> |
||||||
|
{/each} |
||||||
|
{#if badges.length > limit} |
||||||
|
<span class="text-[10px] px-1.5 py-0.5 rounded-md border border-muted/30 bg-surface/70"> |
||||||
|
+{badges.length - limit} |
||||||
|
</span> |
||||||
|
{/if} |
||||||
|
</div> |
||||||
@ -0,0 +1,113 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { NostrProfile } from '$lib/nostr/types'; |
||||||
|
import type { DisplayBadge } from '$lib/nostr/nip58'; |
||||||
|
import ANostrBadgeRow from './ANostrBadgeRow.svelte'; |
||||||
|
import { shortenBech32, displayNameFrom } from '$lib/nostr/format'; |
||||||
|
import { verifyNip05 } from '$lib/nostr/nip05'; |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
|
||||||
|
let { |
||||||
|
npub, // required |
||||||
|
pubkey = undefined as string | undefined, |
||||||
|
profile = undefined as NostrProfile | undefined, |
||||||
|
size = 'md' as 'sm' | 'md' | 'lg', |
||||||
|
showNpub = true, |
||||||
|
showBadges = true, |
||||||
|
verifyNip05: doVerify = true, |
||||||
|
nip05Verified = undefined as boolean | undefined, |
||||||
|
nativeBadges = null as DisplayBadge[] | null, |
||||||
|
badgeLimit = 6, |
||||||
|
href = undefined as string | undefined, |
||||||
|
class: className = '' |
||||||
|
} = $props(); |
||||||
|
|
||||||
|
// Derived view-model |
||||||
|
let displayName = displayNameFrom(npub, profile); |
||||||
|
let shortNpub = shortenBech32(npub, true); |
||||||
|
let avatarUrl = profile?.picture ?? ''; |
||||||
|
let nip05 = profile?.nip05 ?? ''; |
||||||
|
|
||||||
|
// NIP-05 verify |
||||||
|
let computedVerified = $state(false); |
||||||
|
let loadingVerify = $state(false); |
||||||
|
|
||||||
|
onMount(async () => { |
||||||
|
if (nip05Verified !== undefined) { |
||||||
|
computedVerified = nip05Verified; |
||||||
|
return; |
||||||
|
} |
||||||
|
if (!doVerify || !nip05 || !pubkey) return; |
||||||
|
loadingVerify = true; |
||||||
|
computedVerified = await verifyNip05(nip05, pubkey); |
||||||
|
loadingVerify = false; |
||||||
|
}); |
||||||
|
|
||||||
|
// Sizing map |
||||||
|
const sizes = { |
||||||
|
sm: { avatar: 'h-6 w-6', gap: 'gap-2', name: 'text-sm', meta: 'text-[11px]' }, |
||||||
|
md: { avatar: 'h-8 w-8', gap: 'gap-2.5', name: 'text-base',meta: 'text-xs' }, |
||||||
|
lg: { avatar: 'h-10 w-10',gap: 'gap-3', name: 'text-lg', meta: 'text-sm' } |
||||||
|
}[size]; |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if href} |
||||||
|
<a href={href} class={`inline-flex items-center ${sizes.gap} ${className}`}> |
||||||
|
<Content /> |
||||||
|
</a> |
||||||
|
{:else} |
||||||
|
<div class={`inline-flex items-center ${sizes.gap} ${className}`}> |
||||||
|
<Content /> |
||||||
|
</div> |
||||||
|
{/if} |
||||||
|
|
||||||
|
<!-- component content as a fragment (no extra <script> blocks, no JSX) --> |
||||||
|
{#snippet Content()} |
||||||
|
<span class={`shrink-0 rounded-full overflow-hidden bg-muted/20 border border-muted/30 ${sizes.avatar}`}> |
||||||
|
{#if avatarUrl} |
||||||
|
<img src={avatarUrl} alt="" class="h-full w-full object-cover" /> |
||||||
|
{:else} |
||||||
|
<span class="h-full w-full grid place-items-center text-xs opacity-70"> |
||||||
|
{displayName.slice(0, 1).toUpperCase()} |
||||||
|
</span> |
||||||
|
{/if} |
||||||
|
</span> |
||||||
|
|
||||||
|
<span class="min-w-0"> |
||||||
|
<span class={`flex items-center gap-1 font-medium ${sizes.name}`}> |
||||||
|
<span class="truncate">{displayName}</span> |
||||||
|
{#if nip05 && (computedVerified || loadingVerify)} |
||||||
|
<span class="inline-flex items-center" |
||||||
|
title={computedVerified ? `NIP-05 verified: ${nip05}` : 'Verifying…'}> |
||||||
|
{#if computedVerified} |
||||||
|
<!-- Verified check --> |
||||||
|
<svg class="h-4 w-4 text-primary" viewBox="0 0 24 24" fill="none" |
||||||
|
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> |
||||||
|
<path d="M20 6L9 17l-5-5" /> |
||||||
|
</svg> |
||||||
|
{:else} |
||||||
|
<!-- Loading ring --> |
||||||
|
<svg class="h-4 w-4 animate-pulse opacity-70" viewBox="0 0 24 24" fill="none" |
||||||
|
stroke="currentColor" stroke-width="2"> |
||||||
|
<circle cx="12" cy="12" r="10" /> |
||||||
|
</svg> |
||||||
|
{/if} |
||||||
|
</span> |
||||||
|
{/if} |
||||||
|
</span> |
||||||
|
|
||||||
|
<span class={`flex items-center gap-2 text-muted/80 ${sizes.meta}`}> |
||||||
|
{#if nip05}<span class="truncate" title={nip05}>{nip05}</span>{/if} |
||||||
|
{#if showNpub}<span class="truncate opacity-80" title={npub}>{shortNpub}</span>{/if} |
||||||
|
</span> |
||||||
|
|
||||||
|
{#if showBadges} |
||||||
|
<span class="mt-1 block"> |
||||||
|
<slot name="badges"> |
||||||
|
{#if nativeBadges} |
||||||
|
<ANostrBadgeRow badges={nativeBadges} limit={badgeLimit} size="s" /> |
||||||
|
{/if} |
||||||
|
</slot> |
||||||
|
</span> |
||||||
|
{/if} |
||||||
|
</span> |
||||||
|
{/snippet} |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
let { checked = false, class: className = '', onchange = undefined as undefined | ((v:boolean)=>void) } = $props(); |
||||||
|
function toggle(){ checked = !checked; onchange?.(checked); } |
||||||
|
</script> |
||||||
|
<button type="button" role="switch" aria-checked={checked} |
||||||
|
onclick={toggle} |
||||||
|
class={`inline-flex items-center h-6 w-11 rounded-full border border-muted/30 transition px-0.5 ${checked ? 'bg-primary' : 'bg-surface'} ${className}`} |
||||||
|
> |
||||||
|
<span class={`inline-block h-5 w-5 rounded-full bg-white shadow transform transition ${checked ? 'translate-x-5' : 'translate-x-0'}`} /> |
||||||
|
</button> |
||||||
@ -0,0 +1,10 @@ |
|||||||
|
<script> |
||||||
|
let theme = $state('ocean'); // e.g. 'ocean' or '' for default |
||||||
|
const apply = () => document.documentElement.setAttribute('data-theme', theme); |
||||||
|
$effect(apply); |
||||||
|
</script> |
||||||
|
|
||||||
|
<select bind:value={theme} onchange={apply}> |
||||||
|
<option value="">Default</option> |
||||||
|
<option value="ocean">Ocean</option> |
||||||
|
</select> |
||||||
@ -0,0 +1,7 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
let { className = '' , content } = $props(); |
||||||
|
</script> |
||||||
|
|
||||||
|
<article class="mx-auto max-w-measure font-reading leading-reading text-[1rem] {className}"> |
||||||
|
{@render content()} |
||||||
|
</article> |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { onMount } from 'svelte'; |
||||||
|
import type { TocItem } from './toc-utils'; |
||||||
|
import { ATocNode } from "$lib/a"; |
||||||
|
|
||||||
|
let { |
||||||
|
items = [] as TocItem[], |
||||||
|
activeId = null as string | null, |
||||||
|
collapsible = true, |
||||||
|
expandDepth = 1, |
||||||
|
class: className = '', |
||||||
|
onnavigate = undefined as undefined | ((href: string) => void) |
||||||
|
} = $props(); |
||||||
|
|
||||||
|
let expanded = $state(new Set<string>()); |
||||||
|
let parentOf = new Map<string, string | null>(); |
||||||
|
|
||||||
|
function mapParents(list: TocItem[], parent: string | null = null) { |
||||||
|
for (const it of list) { |
||||||
|
parentOf.set(it.id, parent); |
||||||
|
if (it.children?.length) mapParents(it.children, it.id); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function initExpansion(list: TocItem[], depth = 0) { |
||||||
|
for (const it of list) { |
||||||
|
if (depth < expandDepth) expanded.add(it.id); |
||||||
|
if (it.children?.length) initExpansion(it.children, depth + 1); |
||||||
|
} |
||||||
|
expanded = new Set(expanded); |
||||||
|
} |
||||||
|
|
||||||
|
function expandAncestors(id: string | null) { |
||||||
|
if (!id) return; |
||||||
|
let cur: string | null | undefined = id; |
||||||
|
while (cur) { |
||||||
|
expanded.add(cur); |
||||||
|
cur = parentOf.get(cur) ?? null; |
||||||
|
} |
||||||
|
expanded = new Set(expanded); |
||||||
|
} |
||||||
|
|
||||||
|
onMount(() => { |
||||||
|
initExpansion(items, 0); |
||||||
|
expandAncestors(activeId); |
||||||
|
if (activeId) { |
||||||
|
const el = document.querySelector(`[data-toc-id="${activeId}"]`); |
||||||
|
if (el instanceof HTMLElement) el.scrollIntoView({ block: 'nearest' }); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
function toggle(id: string) { |
||||||
|
if (!collapsible) return; |
||||||
|
if (expanded.has(id)) expanded.delete(id); |
||||||
|
else expanded.add(id); |
||||||
|
expanded = new Set(expanded); |
||||||
|
} |
||||||
|
|
||||||
|
const pad = (depth: number) => `padding-left: calc(var(--space-4) * ${Math.max(depth, 0)})`; |
||||||
|
const onNavigate = (href: string) => onnavigate?.(href); |
||||||
|
|
||||||
|
</script> |
||||||
|
|
||||||
|
<nav aria-label="Table of contents" class={`text-sm ${className}`}> |
||||||
|
<ul class="space-y-1 max-h-[calc(100vh-6rem)] overflow-auto pr-1"> |
||||||
|
{#each items as item (item.id)} |
||||||
|
<ATocNode {item} depth={0} {activeId} {collapsible} {expanded} {toggle} {pad} {onNavigate} /> |
||||||
|
{/each} |
||||||
|
</ul> |
||||||
|
</nav> |
||||||
@ -0,0 +1,48 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { theme, setTheme } from '$lib/theme/theme-store'; |
||||||
|
import AButton from '../primitives/AButton.svelte'; |
||||||
|
let size = 16; |
||||||
|
let line = 1.7; |
||||||
|
function applySize() { |
||||||
|
document.documentElement.style.fontSize = size + 'px'; |
||||||
|
} |
||||||
|
function incSize() { |
||||||
|
size = Math.min(22, size + 1); |
||||||
|
applySize(); |
||||||
|
} |
||||||
|
function decSize() { |
||||||
|
size = Math.max(14, size - 1); |
||||||
|
applySize(); |
||||||
|
} |
||||||
|
function incLine() { |
||||||
|
line = Math.min(2, Math.round((line + 0.05) * 100) / 100); |
||||||
|
document.documentElement.style.setProperty('--leading-reading', String(line)); |
||||||
|
} |
||||||
|
function decLine() { |
||||||
|
line = Math.max(1.4, Math.round((line - 0.05) * 100) / 100); |
||||||
|
document.documentElement.style.setProperty('--leading-reading', String(line)); |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="flex items-center gap-3 p-2 border-b border-muted/20 bg-surface sticky top-0 z-10"> |
||||||
|
<label class="text-sm opacity-70">Theme</label> |
||||||
|
<select |
||||||
|
class="h-9 px-2 rounded-md border border-muted/30 bg-surface" |
||||||
|
bind:value={$theme} |
||||||
|
on:change={(e) => setTheme((e.target as HTMLSelectElement).value)} |
||||||
|
> |
||||||
|
<option value="light">Light</option> |
||||||
|
<option value="dark">Dark</option> |
||||||
|
<option value="sepia">Sepia</option> |
||||||
|
</select> |
||||||
|
<div class="mx-2 h-6 w-px bg-muted/30" /> |
||||||
|
<label class="text-sm opacity-70">Text size</label> |
||||||
|
<AButton variant="outline" size="sm" on:click={decSize}>−</AButton> |
||||||
|
<span class="text-sm w-8 text-center">{size}px</span> |
||||||
|
<AButton variant="outline" size="sm" on:click={incSize}>+</AButton> |
||||||
|
<div class="mx-2 h-6 w-px bg-muted/30" /> |
||||||
|
<label class="text-sm opacity-70">Line height</label> |
||||||
|
<AButton variant="outline" size="sm" on:click={decLine}>−</AButton> |
||||||
|
<span class="text-sm w-10 text-center">{line}</span> |
||||||
|
<AButton variant="outline" size="sm" on:click={incLine}>+</AButton> |
||||||
|
</div> |
||||||
@ -0,0 +1,18 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { showTech } from '$lib/stores/techStore.ts'; |
||||||
|
let revealed = false; |
||||||
|
let { title = 'Technical details', className = '' , content} = $props(); |
||||||
|
|
||||||
|
let hidden = $derived(!$showTech && !revealed); |
||||||
|
</script> |
||||||
|
|
||||||
|
{#if hidden} |
||||||
|
<div class="rounded-md border border-dashed border-muted/40 bg-surface/60 px-3 py-2 flex items-center gap-3 {className}"> |
||||||
|
<span class="text-xs opacity-70">{title} hidden</span> |
||||||
|
<button class="ml-auto text-sm underline hover:no-underline" onclick={() => revealed = true}>Reveal this block</button> |
||||||
|
</div> |
||||||
|
{:else} |
||||||
|
<div class="rounded-md border border-muted/20 bg-surface p-3 {className}"> |
||||||
|
{@render content()} |
||||||
|
</div> |
||||||
|
{/if} |
||||||
@ -0,0 +1,11 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { showTech } from '$lib/stores/techStore.ts'; |
||||||
|
import ASwitch from '../primitives/ASwitch.svelte'; |
||||||
|
let label = 'Show technical details'; |
||||||
|
$: checked = $showTech; |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="inline-flex items-center gap-2 select-none"> |
||||||
|
<ASwitch bind:checked={$showTech} aria-label={label} /> |
||||||
|
<span class="text-sm">{label}</span> |
||||||
|
</div> |
||||||
@ -0,0 +1,32 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import type { TocItem } from './toc-utils'; |
||||||
|
import TocNode from './ATocNode.svelte'; |
||||||
|
let { item, depth = 0, activeId = null as string | null, collapsible = true, expanded, toggle, pad, onNavigate }: { item: TocItem; depth?: number; activeId?: string|null; collapsible?: boolean; expanded: Set<string>; toggle: (id:string)=>void; pad: (depth:number)=>string; onNavigate: (href:string)=>void; } = $props(); |
||||||
|
const hasChildren = !!(item.children && item.children.length > 0); |
||||||
|
let isOpen = $derived(expanded.has(item.id)); |
||||||
|
let isActive = $derived(activeId === item.id); |
||||||
|
</script> |
||||||
|
<li> |
||||||
|
<div class="flex items-center gap-1 rounded-md hover:bg-primary/10" style={pad(depth)}> |
||||||
|
{#if collapsible && hasChildren} |
||||||
|
<button class="shrink-0 h-6 w-6 grid place-items-center rounded-md hover:bg-primary/10" aria-label={isOpen?`Collapse ${item.title}`:`Expand ${item.title}`} aria-expanded={isOpen} onclick={() => toggle(item.id)}> |
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class={isOpen ? 'rotate-90 transition-transform' : 'transition-transform'}><path d="M9 18l6-6-6-6" /></svg> |
||||||
|
</button> |
||||||
|
{:else} |
||||||
|
<span class="shrink-0 h-6 w-6" /> |
||||||
|
{/if} |
||||||
|
<a href={item.href ?? `#${item.id}`} data-toc-id={item.id} |
||||||
|
class="flex-1 min-w-0 rounded-md px-2 py-1.5 hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-primary/40" |
||||||
|
class:text-primary={isActive} |
||||||
|
onclick={() => onNavigate(item.href ?? `#${item.id}`)}> |
||||||
|
<span class="truncate">{item.title}</span> |
||||||
|
</a> |
||||||
|
</div> |
||||||
|
{#if hasChildren} |
||||||
|
<ul id={`sub-${item.id}`} aria-hidden={!isOpen} class={isOpen ? 'mt-1 space-y-1' : 'hidden'}> |
||||||
|
{#each item.children as child (child.id)} |
||||||
|
<TocNode item={child} depth={depth + 1} {activeId} {collapsible} {expanded} {toggle} {pad} {onNavigate} /> |
||||||
|
{/each} |
||||||
|
</ul> |
||||||
|
{/if} |
||||||
|
</li> |
||||||
@ -0,0 +1 @@ |
|||||||
|
import { writable } from 'svelte/store'; export function createScrollSpy(ids:string[], opts:{ rootMargin?:string; threshold?:number[] }={}){ const active=writable<string|null>(null); let observer:IntersectionObserver|null=null; function start(){ if(typeof window==='undefined' || typeof IntersectionObserver==='undefined') return; observer=new IntersectionObserver((entries)=>{ const visible=entries.filter(e=>e.isIntersecting).sort((a,b)=>a.target.getBoundingClientRect().top - b.target.getBoundingClientRect().top); if(visible[0]) active.set((visible[0].target as HTMLElement).id); }, { rootMargin: opts.rootMargin ?? '-30% 0px -60% 0px', threshold: opts.threshold ?? [0,1] }); for(const id of ids){ const el=document.getElementById(id); if(el) observer.observe(el); } } function stop(){ observer?.disconnect(); observer=null; } return { active, start, stop }; } |
||||||
@ -0,0 +1,4 @@ |
|||||||
|
export type TocItem = { id:string; title:string; href?:string; children?: TocItem[]; }; |
||||||
|
const slugify=(s:string)=>s.toLowerCase().trim().replace(/[^\\w\\s-]/g,'').replace(/\\s+/g,'-').slice(0,80); |
||||||
|
export function buildTocFromDocument(root:ParentNode=document, levels:number[]=[2,3,4]):TocItem[]{ const selector=levels.map(l=>`h${'${'}l}`).join(','); const nodes=Array.from(root.querySelectorAll(selector)) as HTMLElement[]; const toc:TocItem[]=[]; const stack:{level:number; item:TocItem}[]=[]; for(const el of nodes){ const level=Number(el.tagName.slice(1)); const text=(el.textContent||'').trim(); if(!text) continue; if(!el.id) el.id=slugify(text); const entry: TocItem = { id:el.id, title:text, href:'#'+el.id, children:[] }; while(stack.length && stack[stack.length-1].level>=level) stack.pop(); if(stack.length===0) toc.push(entry); else stack[stack.length-1].item.children!.push(entry); stack.push({level,item:entry}); } return toc; } |
||||||
|
export function idsFromToc(items:TocItem[]):string[]{ const out:string[]=[]; const walk=(arr:TocItem[])=>{ for(const it of arr){ out.push(it.id); if(it.children?.length) walk(it.children); } }; walk(items); return out; } |
||||||
@ -0,0 +1,40 @@ |
|||||||
|
import type { NavItem } from '$lib/a/nav/nav-types'; |
||||||
|
|
||||||
|
export const siteNav: NavItem[] = [ |
||||||
|
{ |
||||||
|
title: 'Create', |
||||||
|
children: [ |
||||||
|
{ title: 'Compose notes', href: '/new/compose' }, |
||||||
|
{ title: 'Publish events', href: '/events/publish' }, |
||||||
|
] |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'Explore', |
||||||
|
children: [ |
||||||
|
{ title: 'Publications', children: [ |
||||||
|
{ title: 'Publications', href: '/' }, |
||||||
|
{ title: 'My Notes', href: '/my-notes' }, |
||||||
|
{ title: 'Events', href: '/events' }, |
||||||
|
{ title: 'Visualize', href: '/visualize' } |
||||||
|
] |
||||||
|
} |
||||||
|
] |
||||||
|
}, |
||||||
|
{ |
||||||
|
title: 'About', |
||||||
|
children: [ |
||||||
|
{ title: 'Onboarding', children: [{ title: 'Getting Started', href: '/start' }] }, |
||||||
|
{ title: 'Project', children: [ |
||||||
|
{ title: 'About', href: '/about' }, |
||||||
|
{ title: 'Contact', href: '/contact' } |
||||||
|
] } |
||||||
|
] |
||||||
|
} |
||||||
|
]; |
||||||
|
|
||||||
|
export const userMenu: NavItem[] = [ |
||||||
|
{ title: 'Profile', href: '/me' }, |
||||||
|
{ title: 'Settings', href: '/settings' }, |
||||||
|
{ divider: true, title: '' }, |
||||||
|
{ id: 'logout', title: 'Sign out' } // <-- no href => action item
|
||||||
|
]; |
||||||
@ -0,0 +1 @@ |
|||||||
|
export type NostrEvent = { id:string; kind:number; pubkey:string; created_at:number; tags:string[][]; content:string; }; export type AddressPointer = string; |
||||||
@ -0,0 +1 @@ |
|||||||
|
export function shortenBech32(id:string, keepPrefix=true, head=8, tail=6){ if(!id) return ''; const i=id.indexOf('1'); const prefix=i>0? id.slice(0,i):''; const data=i>0? id.slice(i+1): id; const short = data.length>head+tail ? `${'${'}data.slice(0,head)}…${'${'}data.slice(-tail)}` : data; return keepPrefix && prefix ? `${'${'}prefix}1${'${'}short}` : short; } export function displayNameFrom(npub:string, p?:{ name?:string; display_name?:string; nip05?:string }){ return (p?.display_name?.trim() || p?.name?.trim() || (p?.nip05 && p.nip05.split('@')[0]) || shortenBech32(npub,true)); } |
||||||
@ -0,0 +1 @@ |
|||||||
|
export async function verifyNip05(nip05:string, pubkeyHex:string):Promise<boolean>{ try{ if(!nip05||!pubkeyHex) return false; const [name,domain]=nip05.toLowerCase().split('@'); if(!name||!domain) return false; const url=`https://${'${'}domain}/.well-known/nostr.json?name=${'${'}encodeURIComponent(name)}`; const res=await fetch(url,{ headers:{ Accept:'application/json' } }); if(!res.ok) return false; const json=await res.json(); const found=json?.names?.[name]; return typeof found==='string' && found.toLowerCase()===pubkeyHex.toLowerCase(); }catch{ return false; } } |
||||||
@ -0,0 +1 @@ |
|||||||
|
import type { NostrEvent, AddressPointer } from './event'; export type BadgeDefinition={ kind:30009; id:string; pubkey:string; d:string; a:AddressPointer; name?:string; description?:string; image?:{ url:string; size?:string }|null; thumbs:{ url:string; size?:string }[]; }; export type BadgeAward={ kind:8; id:string; pubkey:string; a:AddressPointer; recipients:{ pubkey:string; relay?:string }[]; }; export type ProfileBadges={ kind:30008; id:string; pubkey:string; pairs:{ a:AddressPointer; awardId:string; relay?:string }[]; }; export const isKind=(e:NostrEvent,k:number)=>e.kind===k; const val=(tags:string[][],name:string)=>tags.find(t=>t[0]===name)?.[1]; const vals=(tags:string[][],name:string)=>tags.filter(t=>t[0]===name).map(t=>t.slice(1)); export function parseBadgeDefinition(e:NostrEvent):BadgeDefinition|null{ if(e.kind!==30009) return null; const d=val(e.tags,'d'); if(!d) return null; const a:AddressPointer=`30009:${'${'}e.pubkey}:${'${'}d}`; const name=val(e.tags,'name')||undefined; const description=val(e.tags,'description')||undefined; const imageTag=vals(e.tags,'image')[0]; const image=imageTag? { url:imageTag[0], size:imageTag[1] }: null; const thumbs=vals(e.tags,'thumb').map(([url,size])=>({ url, size })); return { kind:30009, id:e.id, pubkey:e.pubkey, d, a, name, description, image, thumbs }; } export function parseBadgeAward(e:NostrEvent):BadgeAward|null{ if(e.kind!==8) return null; const atag=vals(e.tags,'a')[0]; if(!atag) return null; const a:AddressPointer=atag[0]; const recipients=vals(e.tags,'p').map(([pubkey,relay])=>({ pubkey, relay })); return { kind:8, id:e.id, pubkey:e.pubkey, a, recipients }; } export function parseProfileBadges(e:NostrEvent):ProfileBadges|null{ if(e.kind!==30008) return null; const d=val(e.tags,'d'); if(d!=='profile_badges') return null; const pairs: { a:AddressPointer; awardId:string; relay?:string }[]=[]; for(let i=0;i<e.tags.length;i++){ const t=e.tags[i]; if(t[0]==='a'){ const a=t[1]; const nxt=e.tags[i+1]; if(nxt && nxt[0]==='e'){ pairs.push({ a, awardId:nxt[1], relay:nxt[2] }); i++; } } } return { kind:30008, id:e.id, pubkey:e.pubkey, pairs }; } export type DisplayBadge={ def:BadgeDefinition; award:BadgeAward|null; issuer:string; thumbUrl:string|null; title:string; }; export function pickThumb(def:BadgeDefinition, prefer:( '16'|'32'|'64'|'256'|'512')[]=['32','64','256']):string|null{ for(const p of prefer){ const t=def.thumbs.find(t=>(t.size||'').startsWith(p+'x')); if(t) return t.url; } return def.image?.url || null; } export function buildDisplayBadgesForUser(userPubkey:string, defs:BadgeDefinition[], awards:BadgeAward[], profileBadges?:ProfileBadges|null, opts:{ issuerWhitelist?:Set<string>; max?:number }={}):DisplayBadge[]{ const byA=new Map<string,BadgeDefinition>(defs.map(d=>[d.a,d])); const byAwardId=new Map<string,BadgeAward>(awards.map(a=>[a.id,a])); const isWhitelisted=(issuer:string)=>!opts.issuerWhitelist || opts.issuerWhitelist.has(issuer); let out:DisplayBadge[]=[]; if(profileBadges && profileBadges.pubkey===userPubkey){ for(const {a,awardId} of profileBadges.pairs){ const def=byA.get(a); if(!def) continue; const award=byAwardId.get(awardId)||null; if(award && (award.a!==a || !award.recipients.find(r=>r.pubkey===userPubkey))) continue; if(!isWhitelisted(def.pubkey)) continue; out.push({ def, award, issuer:def.pubkey, thumbUrl: pickThumb(def), title: def.name || def.d }); } } else { for(const aw of awards){ if(!aw.recipients.find(r=>r.pubkey===userPubkey)) continue; const def=byA.get(aw.a); if(!def) continue; if(!isWhitelisted(def.pubkey)) continue; out.push({ def, award:aw, issuer:def.pubkey, thumbUrl: pickThumb(def), title: def.name || def.d }); } } if(opts.max && out.length>opts.max) out=out.slice(0,opts.max); return out; } |
||||||
@ -0,0 +1 @@ |
|||||||
|
export type NostrProfile = { name?:string; display_name?:string; picture?:string; about?:string; nip05?:string; lud16?:string; badges?: Array<{ label:string; color?:string }>; }; |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
import { writable } from 'svelte/store'; |
||||||
|
const KEY='showTech'; |
||||||
|
const initial = typeof localStorage!=='undefined' ? localStorage.getItem(KEY)==='true' : true; |
||||||
|
export const showTech = writable<boolean>(initial); |
||||||
|
showTech.subscribe(v=>{ if(typeof document!=='undefined'){ document.documentElement.dataset.tech = v ? 'on' : 'off'; localStorage.setItem(KEY,String(v)); } }); |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
import { cva, type VariantProps } from 'class-variance-authority'; |
||||||
|
import { twMerge } from 'tailwind-merge'; |
||||||
|
export { cva, twMerge, type VariantProps }; |
||||||
@ -0,0 +1,32 @@ |
|||||||
|
#!/usr/bin/env -S deno run --allow-read --allow-write |
||||||
|
import { join } from "https://deno.land/std@0.224.0/path/mod.ts"; |
||||||
|
import { parse as parseYaml } from "https://deno.land/std@0.224.0/yaml/mod.ts"; |
||||||
|
const themesDir = "src/lib/theme/themes"; |
||||||
|
const outCss = "src/lib/theme/generated/themes.css"; |
||||||
|
function toRgb(hex: string) { |
||||||
|
const h = hex.replace("#", "").trim(); |
||||||
|
const n = (s: string) => parseInt(s, 16); |
||||||
|
if (h.length === 3) return `${n(h[0]+h[0])} ${n(h[1]+h[1])} ${n(h[2]+h[2])}`; |
||||||
|
return `${n(h.slice(0,2))} ${n(h.slice(2,4))} ${n(h.slice(4,6))}`; |
||||||
|
} |
||||||
|
const entries: string[] = []; |
||||||
|
for await (const ent of Deno.readDir(themesDir)) if (ent.isFile && ent.name.endsWith(".yaml")) entries.push(ent.name); |
||||||
|
entries.sort(); |
||||||
|
let css = ""; |
||||||
|
for (const file of entries) { |
||||||
|
const t = parseYaml(await Deno.readTextFile(join(themesDir, file))) as any; |
||||||
|
const sel = t.name === "light" ? ':root,[data-theme="light"]' : `[data-theme="${t.name}"]`; |
||||||
|
css += `${sel}{\n`; |
||||||
|
for (const [k, v] of Object.entries(t.colors ?? {})) css += `--color-${k}: ${toRgb(String(v))};\n`; |
||||||
|
for (const [k, v] of Object.entries(t.radii ?? {})) css += `--radius-${k}: ${v};\n`; |
||||||
|
for (const [k, v] of Object.entries(t.spacing ?? {})) css += `--space-${k}: ${v};\n`; |
||||||
|
const ty = t.typography ?? {}; |
||||||
|
if (ty["font-reading"]) css += `--font-reading: ${ty["font-reading"]};\n`; |
||||||
|
if (ty["font-ui"]) css += `--font-ui: ${ty["font-ui"]};\n`; |
||||||
|
if (ty["leading-reading"]) css += `--leading-reading: ${ty["leading-reading"]};\n`; |
||||||
|
if (ty["measure-ch"]) css += `--measure-ch: ${ty["measure-ch"]};\n`; |
||||||
|
css += `}\n\n`; |
||||||
|
} |
||||||
|
await Deno.mkdir(join("src/lib/theme/generated"), { recursive: true }); |
||||||
|
await Deno.writeTextFile(outCss, css); |
||||||
|
console.log("Wrote", outCss); |
||||||
@ -0,0 +1,93 @@ |
|||||||
|
[data-theme="dark"]{ |
||||||
|
--color-bg: 11 16 32; |
||||||
|
--color-surface: 15 20 38; |
||||||
|
--color-text: 229 231 235; |
||||||
|
--color-muted: 148 163 184; |
||||||
|
--color-primary: 96 165 250; |
||||||
|
--color-primary-50: #fff5f2; |
||||||
|
--color-primary-100: #fff1ee; |
||||||
|
--color-primary-200: #ffe4de; |
||||||
|
--color-primary-300: #ffd5cc; |
||||||
|
--color-primary-400: #ffbcad; |
||||||
|
--color-primary-500: #fe795d; |
||||||
|
--color-primary-600: #ef562f; |
||||||
|
--color-primary-700: #eb4f27; |
||||||
|
--color-primary-800: #cc4522; |
||||||
|
--color-primary-900: #a5371b; |
||||||
|
|
||||||
|
--color-secondary-50: #f0f9ff; |
||||||
|
--color-secondary-100: #e0f2fe; |
||||||
|
--color-secondary-200: #bae6fd; |
||||||
|
--color-secondary-300: #7dd3fc; |
||||||
|
--color-secondary-400: #38bdf8; |
||||||
|
--color-secondary-500: #0ea5e9; |
||||||
|
--color-secondary-600: #0284c7; |
||||||
|
--color-secondary-700: #0369a1; |
||||||
|
--color-secondary-800: #075985; |
||||||
|
--color-secondary-900: #0c4a6e; |
||||||
|
--color-primary-contrast: 11 16 32; |
||||||
|
--radius-sm: 4px; |
||||||
|
--radius-md: 8px; |
||||||
|
--radius-lg: 14px; |
||||||
|
--radius-full: 9999px; |
||||||
|
--space-0: 0; |
||||||
|
--space-1: 0.25rem; |
||||||
|
--space-2: 0.5rem; |
||||||
|
--space-3: 0.75rem; |
||||||
|
--space-4: 1rem; |
||||||
|
--space-6: 1.5rem; |
||||||
|
--space-8: 2rem; |
||||||
|
--font-reading: Literata, ui-serif, Georgia, serif; |
||||||
|
--font-ui: Inter, ui-sans-serif, system-ui, sans-serif; |
||||||
|
--leading-reading: 1.8; |
||||||
|
--measure-ch: 68; |
||||||
|
} |
||||||
|
|
||||||
|
:root,[data-theme="light"]{ |
||||||
|
--color-bg: 255 255 255; |
||||||
|
--color-surface: 250 250 250; |
||||||
|
--color-text: 17 24 39; |
||||||
|
--color-muted: 107 114 128; |
||||||
|
--color-primary: 29 78 216; |
||||||
|
--color-primary-contrast: 255 255 255; |
||||||
|
--radius-sm: 4px; |
||||||
|
--radius-md: 8px; |
||||||
|
--radius-lg: 14px; |
||||||
|
--radius-full: 9999px; |
||||||
|
--space-0: 0; |
||||||
|
--space-1: 0.25rem; |
||||||
|
--space-2: 0.5rem; |
||||||
|
--space-3: 0.75rem; |
||||||
|
--space-4: 1rem; |
||||||
|
--space-6: 1.5rem; |
||||||
|
--space-8: 2rem; |
||||||
|
--font-reading: Literata, ui-serif, Georgia, serif; |
||||||
|
--font-ui: Inter, ui-sans-serif, system-ui, sans-serif; |
||||||
|
--leading-reading: 1.7; |
||||||
|
--measure-ch: 70; |
||||||
|
} |
||||||
|
|
||||||
|
[data-theme="sepia"]{ |
||||||
|
--color-bg: 245 236 217; |
||||||
|
--color-surface: 249 243 231; |
||||||
|
--color-text: 58 47 30; |
||||||
|
--color-muted: 109 92 70; |
||||||
|
--color-primary: 138 90 68; |
||||||
|
--color-primary-contrast: 255 255 255; |
||||||
|
--radius-sm: 4px; |
||||||
|
--radius-md: 8px; |
||||||
|
--radius-lg: 14px; |
||||||
|
--radius-full: 9999px; |
||||||
|
--space-0: 0; |
||||||
|
--space-1: 0.25rem; |
||||||
|
--space-2: 0.5rem; |
||||||
|
--space-3: 0.75rem; |
||||||
|
--space-4: 1rem; |
||||||
|
--space-6: 1.5rem; |
||||||
|
--space-8: 2rem; |
||||||
|
--font-reading: Literata, ui-serif, Georgia, serif; |
||||||
|
--font-ui: Inter, ui-sans-serif, system-ui, sans-serif; |
||||||
|
--leading-reading: 1.7; |
||||||
|
--measure-ch: 70; |
||||||
|
} |
||||||
|
|
||||||
@ -0,0 +1,6 @@ |
|||||||
|
import { writable } from 'svelte/store'; |
||||||
|
const KEY='theme'; |
||||||
|
const initial = (typeof localStorage!=='undefined' && localStorage.getItem(KEY)) || 'light'; |
||||||
|
export const theme = writable(initial); |
||||||
|
theme.subscribe(v=>{ if(typeof document!=='undefined'){ document.documentElement.dataset.theme=String(v); localStorage.setItem(KEY,String(v)); } }); |
||||||
|
export const setTheme = (t:string)=>theme.set(t); |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
name: dark |
||||||
|
colors: |
||||||
|
bg: '#0B1020' |
||||||
|
surface: '#0F1426' |
||||||
|
text: '#E5E7EB' |
||||||
|
muted: '#94A3B8' |
||||||
|
primary: '#60A5FA' |
||||||
|
primary-contrast: '#0B1020' |
||||||
|
radii: { sm: 4px, md: 8px, lg: 14px, full: 9999px } |
||||||
|
spacing: { 0: 0, 1: 0.25rem, 2: 0.5rem, 3: 0.75rem, 4: 1rem, 6: 1.5rem, 8: 2rem } |
||||||
|
typography: |
||||||
|
font-reading: 'Literata, ui-serif, Georgia, serif' |
||||||
|
font-ui: 'Inter, ui-sans-serif, system-ui, sans-serif' |
||||||
|
leading-reading: 1.8 |
||||||
|
measure-ch: 68 |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
name: light |
||||||
|
colors: |
||||||
|
bg: '#FFFFFF' |
||||||
|
surface: '#FAFAFA' |
||||||
|
text: '#111827' |
||||||
|
muted: '#6B7280' |
||||||
|
primary: '#1D4ED8' |
||||||
|
primary-contrast: '#FFFFFF' |
||||||
|
radii: { sm: 4px, md: 8px, lg: 14px, full: 9999px } |
||||||
|
spacing: { 0: 0, 1: 0.25rem, 2: 0.5rem, 3: 0.75rem, 4: 1rem, 6: 1.5rem, 8: 2rem } |
||||||
|
typography: |
||||||
|
font-reading: 'Literata, ui-serif, Georgia, serif' |
||||||
|
font-ui: 'Inter, ui-sans-serif, system-ui, sans-serif' |
||||||
|
leading-reading: 1.7 |
||||||
|
measure-ch: 70 |
||||||
@ -0,0 +1,15 @@ |
|||||||
|
name: sepia |
||||||
|
colors: |
||||||
|
bg: '#F5ECD9' |
||||||
|
surface: '#F9F3E7' |
||||||
|
text: '#3A2F1E' |
||||||
|
muted: '#6D5C46' |
||||||
|
primary: '#8A5A44' |
||||||
|
primary-contrast: '#FFFFFF' |
||||||
|
radii: { sm: 4px, md: 8px, lg: 14px, full: 9999px } |
||||||
|
spacing: { 0: 0, 1: 0.25rem, 2: 0.5rem, 3: 0.75rem, 4: 1rem, 6: 1.5rem, 8: 2rem } |
||||||
|
typography: |
||||||
|
font-reading: 'Literata, ui-serif, Georgia, serif' |
||||||
|
font-ui: 'Inter, ui-sans-serif, system-ui, sans-serif' |
||||||
|
leading-reading: 1.7 |
||||||
|
measure-ch: 70 |
||||||
@ -0,0 +1,40 @@ |
|||||||
|
/* Default theme (your current palette) */ |
||||||
|
:root { |
||||||
|
--brand-primary-0: #efe6dc; |
||||||
|
--brand-primary-50: #decdb9; |
||||||
|
--brand-primary-100: #d6c1a8; |
||||||
|
--brand-primary-200: #c6a885; |
||||||
|
--brand-primary-300: #b58f62; |
||||||
|
--brand-primary-400: #ad8351; |
||||||
|
--brand-primary-500: #c6a885; |
||||||
|
--brand-primary-600: #795c39; |
||||||
|
--brand-primary-700: #564a3e; |
||||||
|
--brand-primary-800: #3c352c; |
||||||
|
--brand-primary-900: #2a241c; |
||||||
|
--brand-primary-950: #1d1812; |
||||||
|
--brand-primary-1000: #15110d; |
||||||
|
} |
||||||
|
|
||||||
|
/* Example alternative theme: ocean */ |
||||||
|
:root[data-theme="ocean"] { |
||||||
|
--brand-primary-0: #ecf8ff; |
||||||
|
--brand-primary-50: #e6f3ff; |
||||||
|
--brand-primary-100: #d9ecff; |
||||||
|
--brand-primary-200: #b9ddff; |
||||||
|
--brand-primary-300: #90cbff; |
||||||
|
--brand-primary-400: #61b6fb; |
||||||
|
--brand-primary-500: #0ea5e9; /* sky-500-ish */ |
||||||
|
--brand-primary-600: #0284c7; |
||||||
|
--brand-primary-700: #0369a1; |
||||||
|
--brand-primary-800: #075985; |
||||||
|
--brand-primary-900: #0c4a6e; |
||||||
|
--brand-primary-950: #082f49; |
||||||
|
--brand-primary-1000: #062233; |
||||||
|
} |
||||||
|
|
||||||
|
/* (Optional) per-theme dark tweaks — applied when <html class="dark"> is set */ |
||||||
|
:root.dark[data-theme="ocean"] { |
||||||
|
/* nudge the mid tones brighter for contrast */ |
||||||
|
--brand-primary-400: #7ccdfc; |
||||||
|
--brand-primary-500: #38bdf8; |
||||||
|
} |
||||||
@ -1,123 +0,0 @@ |
|||||||
import flowbite from "flowbite/plugin"; |
|
||||||
import plugin from "tailwindcss/plugin"; |
|
||||||
import typography from "@tailwindcss/typography"; |
|
||||||
|
|
||||||
/** @type {import('tailwindcss').Config}*/ |
|
||||||
const config = { |
|
||||||
content: [ |
|
||||||
"./src/**/*.{html,js,svelte,ts}", |
|
||||||
"./node_modules/flowbite/**/*.js", |
|
||||||
"./node_modules/flowbite-svelte/**/*.{html,js,svelte,ts}", |
|
||||||
], |
|
||||||
|
|
||||||
theme: { |
|
||||||
extend: { |
|
||||||
colors: { |
|
||||||
highlight: "#f9f6f1", |
|
||||||
primary: { |
|
||||||
0: "#efe6dc", |
|
||||||
50: "#decdb9", |
|
||||||
100: "#d6c1a8", |
|
||||||
200: "#c6a885", |
|
||||||
300: "#b58f62", |
|
||||||
400: "#ad8351", |
|
||||||
500: "#c6a885", |
|
||||||
600: "#795c39", |
|
||||||
700: "#564a3e", |
|
||||||
800: "#3c352c", |
|
||||||
900: "#2a241c", |
|
||||||
950: "#1d1812", |
|
||||||
1000: "#15110d", |
|
||||||
}, |
|
||||||
success: { |
|
||||||
50: "#e3f2e7", |
|
||||||
100: "#c7e6cf", |
|
||||||
200: "#a2d4ae", |
|
||||||
300: "#7dbf8e", |
|
||||||
400: "#5ea571", |
|
||||||
500: "#4e8e5f", |
|
||||||
600: "#3e744c", |
|
||||||
700: "#305b3b", |
|
||||||
800: "#22412a", |
|
||||||
900: "#15281b", |
|
||||||
}, |
|
||||||
info: { |
|
||||||
50: "#e7eff6", |
|
||||||
100: "#c5d9ea", |
|
||||||
200: "#9fbfdb", |
|
||||||
300: "#7aa5cc", |
|
||||||
400: "#5e90be", |
|
||||||
500: "#4779a5", |
|
||||||
600: "#365d80", |
|
||||||
700: "#27445d", |
|
||||||
800: "#192b3a", |
|
||||||
900: "#0d161f", |
|
||||||
}, |
|
||||||
warning: { |
|
||||||
50: "#fef4e6", |
|
||||||
100: "#fde4bf", |
|
||||||
200: "#fcd18e", |
|
||||||
300: "#fbbc5c", |
|
||||||
400: "#f9aa33", |
|
||||||
500: "#f7971b", |
|
||||||
600: "#c97a14", |
|
||||||
700: "#9a5c0e", |
|
||||||
800: "#6c3e08", |
|
||||||
900: "#3e2404", |
|
||||||
}, |
|
||||||
danger: { |
|
||||||
50: "#fbeaea", |
|
||||||
100: "#f5cccc", |
|
||||||
200: "#eba5a5", |
|
||||||
300: "#e17e7e", |
|
||||||
400: "#d96060", |
|
||||||
500: "#c94848", |
|
||||||
600: "#a53939", |
|
||||||
700: "#7c2b2b", |
|
||||||
800: "#521c1c", |
|
||||||
900: "#2b0e0e", |
|
||||||
}, |
|
||||||
}, |
|
||||||
listStyleType: { |
|
||||||
"upper-alpha": "upper-alpha", // Uppercase letters |
|
||||||
"lower-alpha": "lower-alpha", // Lowercase letters |
|
||||||
}, |
|
||||||
flexGrow: { |
|
||||||
1: "1", |
|
||||||
2: "2", |
|
||||||
3: "3", |
|
||||||
}, |
|
||||||
hueRotate: { |
|
||||||
20: "20deg", |
|
||||||
}, |
|
||||||
}, |
|
||||||
}, |
|
||||||
|
|
||||||
plugins: [ |
|
||||||
flowbite(), |
|
||||||
typography, |
|
||||||
plugin(function ({ addUtilities, matchUtilities }) { |
|
||||||
addUtilities({ |
|
||||||
".content-visibility-auto": { |
|
||||||
"content-visibility": "auto", |
|
||||||
}, |
|
||||||
".contain-size": { |
|
||||||
contain: "size", |
|
||||||
}, |
|
||||||
}); |
|
||||||
|
|
||||||
matchUtilities({ |
|
||||||
"contain-intrinsic-w-*": (value) => ({ |
|
||||||
width: value, |
|
||||||
}), |
|
||||||
"contain-intrinsic-h-*": (value) => ({ |
|
||||||
height: value, |
|
||||||
}), |
|
||||||
}); |
|
||||||
}), |
|
||||||
], |
|
||||||
|
|
||||||
darkMode: "class", |
|
||||||
}; |
|
||||||
|
|
||||||
module.exports = config; |
|
||||||
Loading…
Reference in new issue