mirror of
https://github.com/dsec-hub/dsec-notebook.git
synced 2026-09-22 07:24:27 +00:00
commit
1c9d2df92b
10 changed files with 562 additions and 58 deletions
|
|
@ -6,6 +6,7 @@
|
||||||
import { page } from "$app/state";
|
import { page } from "$app/state";
|
||||||
|
|
||||||
let mobileMenuOpen = $state(false);
|
let mobileMenuOpen = $state(false);
|
||||||
|
let resourcesOpen = $state(false);
|
||||||
let auth = $state(false);
|
let auth = $state(false);
|
||||||
let admin = $state(false);
|
let admin = $state(false);
|
||||||
let searchQuery = $state("");
|
let searchQuery = $state("");
|
||||||
|
|
@ -33,8 +34,10 @@
|
||||||
});
|
});
|
||||||
|
|
||||||
const path = $derived(page.url.pathname);
|
const path = $derived(page.url.pathname);
|
||||||
|
const onUnits = $derived(path === "/units" || path.startsWith("/units/"));
|
||||||
const onNotes = $derived(path === "/notes" || path.startsWith("/notes/"));
|
const onNotes = $derived(path === "/notes" || path.startsWith("/notes/"));
|
||||||
const onQuestions = $derived(path === "/questions" || path.startsWith("/questions/"));
|
const onQuestions = $derived(path === "/questions" || path.startsWith("/questions/"));
|
||||||
|
const onResources = $derived(onUnits || onNotes || onQuestions);
|
||||||
const onAdmin = $derived(path === "/admin" || path.startsWith("/admin/"));
|
const onAdmin = $derived(path === "/admin" || path.startsWith("/admin/"));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -43,10 +46,56 @@
|
||||||
<a href="/" class="text-ink font-serif text-[1.65rem] leading-none">Notebook</a>
|
<a href="/" class="text-ink font-serif text-[1.65rem] leading-none">Notebook</a>
|
||||||
|
|
||||||
<nav class="hidden items-center gap-8 md:flex">
|
<nav class="hidden items-center gap-8 md:flex">
|
||||||
<a href="/notes" class="nav-link {onNotes ? 'nav-link-active' : ''}">Notes</a>
|
<div class="relative">
|
||||||
<a href="/questions" class="nav-link {onQuestions ? 'nav-link-active' : ''}"
|
<button
|
||||||
>Questions</a
|
type="button"
|
||||||
>
|
class="nav-link relative z-20 flex items-center gap-1 {onResources
|
||||||
|
? 'nav-link-active'
|
||||||
|
: ''}"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={resourcesOpen}
|
||||||
|
onclick={() => (resourcesOpen = !resourcesOpen)}
|
||||||
|
>
|
||||||
|
Resources
|
||||||
|
<svg
|
||||||
|
class="h-3 w-3 transition-transform {resourcesOpen ? 'rotate-180' : ''}"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path stroke-linecap="square" d="m6 9 6 6 6-6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{#if resourcesOpen}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="fixed inset-0 z-10 cursor-default"
|
||||||
|
aria-label="Close menu"
|
||||||
|
onclick={() => (resourcesOpen = false)}
|
||||||
|
></button>
|
||||||
|
<div
|
||||||
|
class="border-rule bg-surface absolute top-full left-0 z-20 mt-2 w-44 rounded-sm border py-1 shadow-lg"
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/units"
|
||||||
|
class="nav-link block px-4 py-2 {onUnits ? 'nav-link-active' : ''}"
|
||||||
|
onclick={() => (resourcesOpen = false)}>Units</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/notes"
|
||||||
|
class="nav-link block px-4 py-2 {onNotes ? 'nav-link-active' : ''}"
|
||||||
|
onclick={() => (resourcesOpen = false)}>Notes</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/questions"
|
||||||
|
class="nav-link block px-4 py-2 {onQuestions ? 'nav-link-active' : ''}"
|
||||||
|
onclick={() => (resourcesOpen = false)}>Questions</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
{#if admin}
|
{#if admin}
|
||||||
<a href="/admin" class="nav-link {onAdmin ? 'nav-link-active' : ''}">Admin</a>
|
<a href="/admin" class="nav-link {onAdmin ? 'nav-link-active' : ''}">Admin</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
@ -141,6 +190,9 @@
|
||||||
class="border-rule text-ink placeholder:text-faint focus:border-primary bg-surface w-full rounded-sm border px-3 py-2 text-sm tracking-wide transition-colors outline-none"
|
class="border-rule text-ink placeholder:text-faint focus:border-primary bg-surface w-full rounded-sm border px-3 py-2 text-sm tracking-wide transition-colors outline-none"
|
||||||
/>
|
/>
|
||||||
</form>
|
</form>
|
||||||
|
<a href="/units" class="nav-link block" onclick={() => (mobileMenuOpen = false)}
|
||||||
|
>Units</a
|
||||||
|
>
|
||||||
<a href="/notes" class="nav-link block" onclick={() => (mobileMenuOpen = false)}
|
<a href="/notes" class="nav-link block" onclick={() => (mobileMenuOpen = false)}
|
||||||
>Notes</a
|
>Notes</a
|
||||||
>
|
>
|
||||||
|
|
|
||||||
144
src/lib/components/UnitFilter.svelte
Normal file
144
src/lib/components/UnitFilter.svelte
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { query, mutation } from "$lib/api";
|
||||||
|
import { getToken, isAuthenticated, initAuth } from "$lib/stores/auth";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import type { UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
|
const MAX_PINS = 10;
|
||||||
|
|
||||||
|
let {
|
||||||
|
units,
|
||||||
|
selectedUnitId = $bindable(""),
|
||||||
|
}: {
|
||||||
|
units: UnitDoc[];
|
||||||
|
selectedUnitId?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let pinnedIds = $state<string[]>([]);
|
||||||
|
let authed = $state(false);
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state("");
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await initAuth();
|
||||||
|
if (!get(isAuthenticated)) return;
|
||||||
|
authed = true;
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
pinnedIds = (await query("units:getPinned", { token })) as string[];
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const pinnedUnits = $derived(
|
||||||
|
pinnedIds.map((id) => units.find((u) => u._id === id)).filter((u): u is UnitDoc => !!u),
|
||||||
|
);
|
||||||
|
|
||||||
|
const otherUnits = $derived(units.filter((u) => !pinnedIds.includes(u._id)));
|
||||||
|
|
||||||
|
const canPinMore = $derived(pinnedIds.length < MAX_PINS);
|
||||||
|
|
||||||
|
async function togglePin(unit: UnitDoc) {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
error = "Sign in to pin units";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const pinned = pinnedIds.includes(unit._id);
|
||||||
|
pinnedIds = (await mutation(pinned ? "units:unpin" : "units:pin", {
|
||||||
|
token,
|
||||||
|
unitId: unit._id,
|
||||||
|
})) as string[];
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err?.message ?? "Failed to update pin";
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet pinIcon()}
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 17v5"></path>
|
||||||
|
<path
|
||||||
|
d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16h14v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="flex scrollbar-thin items-center gap-x-1 gap-y-2 overflow-x-scroll pb-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip {selectedUnitId === '' ? 'chip-active' : ''}"
|
||||||
|
onclick={() => (selectedUnitId = "")}
|
||||||
|
>
|
||||||
|
All units
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{#each pinnedUnits as unit (unit._id)}
|
||||||
|
<span class="inline-flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip {selectedUnitId === unit._id ? 'chip-active' : ''}"
|
||||||
|
onclick={() => (selectedUnitId = unit._id)}
|
||||||
|
>
|
||||||
|
{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pin-btn pin-btn-active"
|
||||||
|
onclick={() => togglePin(unit)}
|
||||||
|
disabled={busy}
|
||||||
|
title="Unpin unit"
|
||||||
|
aria-label="Unpin unit"
|
||||||
|
>
|
||||||
|
{@render pinIcon()}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#each otherUnits as unit (unit._id)}
|
||||||
|
<span class="inline-flex items-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="chip {selectedUnitId === unit._id ? 'chip-active' : ''}"
|
||||||
|
onclick={() => (selectedUnitId = unit._id)}
|
||||||
|
>
|
||||||
|
{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}
|
||||||
|
</button>
|
||||||
|
{#if authed}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pin-btn"
|
||||||
|
onclick={() => togglePin(unit)}
|
||||||
|
disabled={busy || !canPinMore}
|
||||||
|
title={canPinMore ? "Pin unit" : "You can pin up to 10 units"}
|
||||||
|
aria-label={canPinMore ? "Pin unit" : "You can pin up to 10 units"}
|
||||||
|
>
|
||||||
|
{@render pinIcon()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<span class="text-primary text-xs">{error}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
119
src/lib/components/UnitRail.svelte
Normal file
119
src/lib/components/UnitRail.svelte
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import { query, mutation } from "$lib/api";
|
||||||
|
import { getToken, isAuthenticated, initAuth } from "$lib/stores/auth";
|
||||||
|
import { get } from "svelte/store";
|
||||||
|
import type { UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
|
const MAX_PINS = 10;
|
||||||
|
|
||||||
|
let {
|
||||||
|
units,
|
||||||
|
selectedUnitId = $bindable(""),
|
||||||
|
}: {
|
||||||
|
units: UnitDoc[];
|
||||||
|
selectedUnitId?: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let pinnedIds = $state<string[]>([]);
|
||||||
|
let authed = $state(false);
|
||||||
|
let busy = $state(false);
|
||||||
|
let error = $state("");
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
await initAuth();
|
||||||
|
if (!get(isAuthenticated)) return;
|
||||||
|
authed = true;
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
pinnedIds = (await query("units:getPinned", { token })) as string[];
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const isPinned = (id: string) => pinnedIds.includes(id);
|
||||||
|
const canPinMore = $derived(pinnedIds.length < MAX_PINS);
|
||||||
|
|
||||||
|
async function togglePin(unit: UnitDoc) {
|
||||||
|
const token = getToken();
|
||||||
|
if (!token) {
|
||||||
|
error = "Sign in to pin units";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
busy = true;
|
||||||
|
error = "";
|
||||||
|
try {
|
||||||
|
const pinned = pinnedIds.includes(unit._id);
|
||||||
|
pinnedIds = (await mutation(pinned ? "units:unpin" : "units:pin", {
|
||||||
|
token,
|
||||||
|
unitId: unit._id,
|
||||||
|
})) as string[];
|
||||||
|
} catch (err: any) {
|
||||||
|
error = err?.message ?? "Failed to update pin";
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#snippet pinIcon()}
|
||||||
|
<svg
|
||||||
|
width="14"
|
||||||
|
height="14"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path d="M12 17v5"></path>
|
||||||
|
<path
|
||||||
|
d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16h14v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V6h1a2 2 0 0 0 0-4H8a2 2 0 0 0 0 4h1z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<div class="unit-rail">
|
||||||
|
{#each units as unit (unit._id)}
|
||||||
|
<div class="unit-card {selectedUnitId === unit._id ? 'unit-card-active' : ''}">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex h-full w-full flex-col items-start gap-1 text-left"
|
||||||
|
onclick={() => (selectedUnitId = unit._id)}
|
||||||
|
aria-label="Select {unit.code}"
|
||||||
|
>
|
||||||
|
<span class="text-ink font-serif text-base leading-tight"
|
||||||
|
>{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}</span
|
||||||
|
>
|
||||||
|
<span class="text-muted line-clamp-2 text-xs leading-snug">{unit.name}</span>
|
||||||
|
</button>
|
||||||
|
{#if authed}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="pin-btn {isPinned(unit._id)
|
||||||
|
? 'pin-btn-active'
|
||||||
|
: ''} absolute top-1.5 right-1.5 z-10"
|
||||||
|
onclick={() => togglePin(unit)}
|
||||||
|
disabled={busy || (!isPinned(unit._id) && !canPinMore)}
|
||||||
|
title={isPinned(unit._id)
|
||||||
|
? "Unpin unit"
|
||||||
|
: canPinMore
|
||||||
|
? "Pin unit"
|
||||||
|
: "You can pin up to 10 units"}
|
||||||
|
aria-label={isPinned(unit._id) ? "Unpin unit" : "Pin unit"}
|
||||||
|
>
|
||||||
|
{@render pinIcon()}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if error}
|
||||||
|
<p class="text-primary mt-2 text-xs">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
@ -283,6 +283,56 @@ function unitsCreateCustom(db: Db, args: { code: string; name: string }) {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- pinned units ----
|
||||||
|
|
||||||
|
const MAX_PINNED_UNITS = 10;
|
||||||
|
|
||||||
|
function getPinnedUnitIds(db: Db, userId: string): string[] {
|
||||||
|
return (
|
||||||
|
db
|
||||||
|
.prepare("SELECT unitId FROM pinned_units WHERE userId = ? ORDER BY createdAt ASC")
|
||||||
|
.all(userId) as { unitId: string }[]
|
||||||
|
).map((row) => row.unitId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitsGetPinned(db: Db, args: { token: string }) {
|
||||||
|
const user = requireAuth(db, args.token);
|
||||||
|
return getPinnedUnitIds(db, user._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitsPin(db: Db, args: { token: string; unitId: string }) {
|
||||||
|
const user = requireAuth(db, args.token);
|
||||||
|
const unit = db.prepare("SELECT id AS _id FROM units WHERE id = ?").get(args.unitId);
|
||||||
|
if (!unit) throw new Error("Unit not found");
|
||||||
|
|
||||||
|
const existing = db
|
||||||
|
.prepare("SELECT 1 AS found FROM pinned_units WHERE userId = ? AND unitId = ?")
|
||||||
|
.get(user._id, args.unitId);
|
||||||
|
if (!existing) {
|
||||||
|
const count = db
|
||||||
|
.prepare("SELECT COUNT(*) AS c FROM pinned_units WHERE userId = ?")
|
||||||
|
.get(user._id) as { c: number };
|
||||||
|
if (count.c >= MAX_PINNED_UNITS) {
|
||||||
|
throw new Error(`You can pin up to ${MAX_PINNED_UNITS} units`);
|
||||||
|
}
|
||||||
|
db.prepare("INSERT INTO pinned_units (userId, unitId, createdAt) VALUES (?, ?, ?)").run(
|
||||||
|
user._id,
|
||||||
|
args.unitId,
|
||||||
|
Date.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return getPinnedUnitIds(db, user._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unitsUnpin(db: Db, args: { token: string; unitId: string }) {
|
||||||
|
const user = requireAuth(db, args.token);
|
||||||
|
db.prepare("DELETE FROM pinned_units WHERE userId = ? AND unitId = ?").run(
|
||||||
|
user._id,
|
||||||
|
args.unitId,
|
||||||
|
);
|
||||||
|
return getPinnedUnitIds(db, user._id);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- notes ----
|
// ---- notes ----
|
||||||
|
|
||||||
const NOTE_COLUMNS =
|
const NOTE_COLUMNS =
|
||||||
|
|
@ -947,6 +997,7 @@ function adminUsersDelete(db: Db, args: { token: string; id: string }) {
|
||||||
db.exec("BEGIN");
|
db.exec("BEGIN");
|
||||||
try {
|
try {
|
||||||
db.prepare("DELETE FROM votes WHERE userId = ?").run(args.id);
|
db.prepare("DELETE FROM votes WHERE userId = ?").run(args.id);
|
||||||
|
db.prepare("DELETE FROM pinned_units WHERE userId = ?").run(args.id);
|
||||||
for (const id of noteIds) {
|
for (const id of noteIds) {
|
||||||
db.prepare("DELETE FROM votes WHERE targetType = 'note' AND targetId = ?").run(id);
|
db.prepare("DELETE FROM votes WHERE targetType = 'note' AND targetId = ?").run(id);
|
||||||
db.prepare("DELETE FROM comments WHERE parentId = ?").run(id);
|
db.prepare("DELETE FROM comments WHERE parentId = ?").run(id);
|
||||||
|
|
@ -1085,6 +1136,9 @@ const handlers: Record<string, Handler> = {
|
||||||
"units:getByCode": unitsGetByCode,
|
"units:getByCode": unitsGetByCode,
|
||||||
"units:getAll": unitsGetAll,
|
"units:getAll": unitsGetAll,
|
||||||
"units:createCustom": unitsCreateCustom,
|
"units:createCustom": unitsCreateCustom,
|
||||||
|
"units:getPinned": unitsGetPinned,
|
||||||
|
"units:pin": unitsPin,
|
||||||
|
"units:unpin": unitsUnpin,
|
||||||
"notes:create": notesCreate,
|
"notes:create": notesCreate,
|
||||||
"notes:list": notesList,
|
"notes:list": notesList,
|
||||||
"notes:search": notesSearch,
|
"notes:search": notesSearch,
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,13 @@ function createSchema(database: DatabaseSync) {
|
||||||
value INTEGER NOT NULL
|
value INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS pinned_units (
|
||||||
|
userId TEXT NOT NULL,
|
||||||
|
unitId TEXT NOT NULL,
|
||||||
|
createdAt INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (userId, unitId)
|
||||||
|
);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS email_verifications (
|
CREATE TABLE IF NOT EXISTS email_verifications (
|
||||||
email TEXT PRIMARY KEY,
|
email TEXT PRIMARY KEY,
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
|
|
@ -102,6 +109,7 @@ function createSchema(database: DatabaseSync) {
|
||||||
CREATE INDEX IF NOT EXISTS idx_comments_note ON comments(parentId);
|
CREATE INDEX IF NOT EXISTS idx_comments_note ON comments(parentId);
|
||||||
CREATE INDEX IF NOT EXISTS idx_comments_question ON comments(questionId);
|
CREATE INDEX IF NOT EXISTS idx_comments_question ON comments(questionId);
|
||||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_votes_user_target ON votes(userId, targetType, targetId);
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_votes_user_target ON votes(userId, targetType, targetId);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_pinned_units_user ON pinned_units(userId);
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,16 +47,15 @@
|
||||||
{:else if error}
|
{:else if error}
|
||||||
<p class="kicker">{error}</p>
|
<p class="kicker">{error}</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="grid grid-cols-1 sm:grid-cols-2 sm:gap-x-5">
|
<div class="unit-rail">
|
||||||
{#each units as unit}
|
{#each units as unit}
|
||||||
<a
|
<a href="/units/{unit.code}" class="unit-card group flex flex-col gap-1">
|
||||||
href="/units/{unit.code}"
|
<span
|
||||||
class="group border-rule flex items-baseline justify-between gap-4 border-b py-3"
|
class="text-ink group-hover:text-primary font-serif text-base leading-tight"
|
||||||
>
|
|
||||||
<span class="text-ink group-hover:text-primary font-serif text-lg"
|
|
||||||
>{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}</span
|
>{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}</span
|
||||||
>
|
>
|
||||||
<span class="text-muted truncate text-sm">{unit.name}</span>
|
<span class="text-muted line-clamp-2 text-xs leading-snug">{unit.name}</span
|
||||||
|
>
|
||||||
</a>
|
</a>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ button {
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip {
|
.chip {
|
||||||
@apply border-rule text-ink hover:border-ink bg-surface inline-flex items-center rounded-sm border px-3 py-1.5 font-sans text-[11px] font-medium tracking-[0.12em] uppercase transition-colors;
|
@apply border-rule text-ink hover:border-ink bg-surface inline-flex items-center rounded-sm border px-3 py-1.5 font-sans text-[11px] font-medium tracking-[0.12em] whitespace-nowrap uppercase transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip-active {
|
.chip-active {
|
||||||
|
|
@ -124,6 +124,30 @@ button {
|
||||||
@apply text-ink hover:border-primary hover:text-primary bg-surface inline-flex h-8 min-w-8 items-center justify-center rounded-sm border border-transparent px-1.5 font-sans text-xs font-medium transition-colors;
|
@apply text-ink hover:border-primary hover:text-primary bg-surface inline-flex h-8 min-w-8 items-center justify-center rounded-sm border border-transparent px-1.5 font-sans text-xs font-medium transition-colors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pin-btn {
|
||||||
|
@apply text-faint hover:text-ink ml-1 inline-flex h-7 w-7 items-center justify-center rounded-sm border border-transparent transition-colors disabled:cursor-not-allowed disabled:opacity-50;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-btn-active {
|
||||||
|
@apply text-primary hover:text-primary-dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-rail {
|
||||||
|
@apply flex scrollbar-thin gap-3 overflow-x-scroll py-1 pb-2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-rail::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-card {
|
||||||
|
@apply border-rule bg-surface hover:border-ink relative w-44 shrink-0 rounded-sm border p-3 transition-colors;
|
||||||
|
}
|
||||||
|
|
||||||
|
.unit-card-active {
|
||||||
|
@apply border-primary hover:border-primary;
|
||||||
|
}
|
||||||
|
|
||||||
.thread-branch {
|
.thread-branch {
|
||||||
@apply border-rule hover:border-secondary w-3 shrink-0 cursor-pointer self-stretch border-l-2 transition hover:brightness-150;
|
@apply border-rule hover:border-secondary w-3 shrink-0 cursor-pointer self-stretch border-l-2 transition hover:brightness-150;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { query } from "$lib/api";
|
import { query } from "$lib/api";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import FeedRow from "$lib/components/FeedRow.svelte";
|
import FeedRow from "$lib/components/FeedRow.svelte";
|
||||||
|
import UnitFilter from "$lib/components/UnitFilter.svelte";
|
||||||
import { timeAgo } from "$lib/time";
|
import { timeAgo } from "$lib/time";
|
||||||
import type { NoteDoc, UnitDoc } from "$lib/types";
|
import type { NoteDoc, UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
|
|
@ -20,11 +21,6 @@
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const usedUnits = $derived.by(() => {
|
|
||||||
const ids = new Set(notes.map((n) => n.unitId));
|
|
||||||
return units.filter((u) => ids.has(u._id));
|
|
||||||
});
|
|
||||||
|
|
||||||
const visible = $derived.by(() => {
|
const visible = $derived.by(() => {
|
||||||
const q = searchQuery.trim().toLowerCase();
|
const q = searchQuery.trim().toLowerCase();
|
||||||
let list = selectedUnitId ? notes.filter((n) => n.unitId === selectedUnitId) : notes;
|
let list = selectedUnitId ? notes.filter((n) => n.unitId === selectedUnitId) : notes;
|
||||||
|
|
@ -59,24 +55,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-rule flex flex-wrap items-center justify-between gap-4 border-b py-4">
|
<div class="border-rule flex flex-wrap items-center justify-between gap-4 border-b py-4">
|
||||||
<div class="flex flex-wrap gap-2">
|
<UnitFilter bind:selectedUnitId {units} />
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="chip {selectedUnitId === '' ? 'chip-active' : ''}"
|
|
||||||
onclick={() => (selectedUnitId = "")}
|
|
||||||
>
|
|
||||||
All units
|
|
||||||
</button>
|
|
||||||
{#each usedUnits as unit}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="chip {selectedUnitId === unit._id ? 'chip-active' : ''}"
|
|
||||||
onclick={() => (selectedUnitId = unit._id)}
|
|
||||||
>
|
|
||||||
{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { query } from "$lib/api";
|
import { query } from "$lib/api";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import FeedRow from "$lib/components/FeedRow.svelte";
|
import FeedRow from "$lib/components/FeedRow.svelte";
|
||||||
|
import UnitFilter from "$lib/components/UnitFilter.svelte";
|
||||||
import { timeAgo } from "$lib/time";
|
import { timeAgo } from "$lib/time";
|
||||||
import type { QuestionDoc, UnitDoc } from "$lib/types";
|
import type { QuestionDoc, UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
|
|
@ -23,11 +24,6 @@
|
||||||
loading = false;
|
loading = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const usedUnits = $derived.by(() => {
|
|
||||||
const ids = new Set(questions.map((q) => q.unitId));
|
|
||||||
return units.filter((u) => ids.has(u._id));
|
|
||||||
});
|
|
||||||
|
|
||||||
const visible = $derived.by(() => {
|
const visible = $derived.by(() => {
|
||||||
const q = searchQuery.trim().toLowerCase();
|
const q = searchQuery.trim().toLowerCase();
|
||||||
let list = selectedUnitId
|
let list = selectedUnitId
|
||||||
|
|
@ -66,24 +62,7 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-rule flex flex-wrap items-center justify-between gap-4 border-b py-4">
|
<div class="border-rule flex flex-wrap items-center justify-between gap-4 border-b py-4">
|
||||||
<div class="flex flex-wrap gap-2">
|
<UnitFilter bind:selectedUnitId {units} />
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="chip {selectedUnitId === '' ? 'chip-active' : ''}"
|
|
||||||
onclick={() => (selectedUnitId = "")}
|
|
||||||
>
|
|
||||||
All units
|
|
||||||
</button>
|
|
||||||
{#each usedUnits as unit}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="chip {selectedUnitId === unit._id ? 'chip-active' : ''}"
|
|
||||||
onclick={() => (selectedUnitId = unit._id)}
|
|
||||||
>
|
|
||||||
{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}
|
|
||||||
</button>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
<div class="flex gap-4">
|
<div class="flex gap-4">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
146
src/routes/units/+page.svelte
Normal file
146
src/routes/units/+page.svelte
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { query } from "$lib/api";
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import FeedRow from "$lib/components/FeedRow.svelte";
|
||||||
|
import UnitRail from "$lib/components/UnitRail.svelte";
|
||||||
|
import { timeAgo } from "$lib/time";
|
||||||
|
import type { NoteDoc, QuestionDoc, UnitDoc } from "$lib/types";
|
||||||
|
|
||||||
|
let units: UnitDoc[] = $state([]);
|
||||||
|
let notes: NoteDoc[] = $state([]);
|
||||||
|
let questions: QuestionDoc[] = $state([]);
|
||||||
|
let loading = $state(true);
|
||||||
|
let contentLoading = $state(false);
|
||||||
|
let selectedUnitId = $state("");
|
||||||
|
|
||||||
|
const selectedUnit = $derived(units.find((u) => u._id === selectedUnitId) ?? null);
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
units = (await query("units:getAll")) as UnitDoc[];
|
||||||
|
selectedUnitId = units[0]?._id ?? "";
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
loadContent(selectedUnitId);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadContent(id: string) {
|
||||||
|
if (!id) {
|
||||||
|
notes = [];
|
||||||
|
questions = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
contentLoading = true;
|
||||||
|
try {
|
||||||
|
const [n, q] = await Promise.all([
|
||||||
|
query("notes:list", { unitId: id }),
|
||||||
|
query("questions:list", { unitId: id }),
|
||||||
|
]);
|
||||||
|
notes = n as NoteDoc[];
|
||||||
|
questions = q as QuestionDoc[];
|
||||||
|
} finally {
|
||||||
|
contentLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Units — Notebook</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="page">
|
||||||
|
<div class="border-rule border-b pb-6">
|
||||||
|
<h1 class="text-ink font-serif text-4xl font-medium">Units</h1>
|
||||||
|
<p class="text-muted mt-2 text-sm">
|
||||||
|
Browse notes and questions by unit. Pin the units you are studying to keep them handy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<p class="kicker py-16">Loading</p>
|
||||||
|
{:else}
|
||||||
|
<div class="border-rule border-b py-5">
|
||||||
|
<UnitRail {units} bind:selectedUnitId />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if selectedUnit}
|
||||||
|
<div class="pt-6">
|
||||||
|
<div class="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||||||
|
<h2 class="text-ink font-serif text-2xl font-medium">
|
||||||
|
{selectedUnit.code}{selectedUnit.code2 ? ` / ${selectedUnit.code2}` : ""}
|
||||||
|
</h2>
|
||||||
|
<p class="text-muted text-sm">{selectedUnit.name}</p>
|
||||||
|
</div>
|
||||||
|
{#if selectedUnit.description}
|
||||||
|
<p class="text-muted mt-1 text-sm">{selectedUnit.description}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if contentLoading}
|
||||||
|
<p class="kicker py-16">Loading</p>
|
||||||
|
{:else}
|
||||||
|
{#if notes.length > 0}
|
||||||
|
<p class="kicker border-rule mt-8 border-t pt-8">Notes</p>
|
||||||
|
{#each notes as note}
|
||||||
|
<FeedRow
|
||||||
|
href="/notes/{note._id}"
|
||||||
|
title={note.title}
|
||||||
|
unitCode={selectedUnit.code +
|
||||||
|
(selectedUnit.code2 ? ` / ${selectedUnit.code2}` : "")}
|
||||||
|
meta="{note.authorName} · {timeAgo(
|
||||||
|
note.createdAt,
|
||||||
|
)} · {note.commentCount} comment{note.commentCount === 1
|
||||||
|
? ''
|
||||||
|
: 's'}"
|
||||||
|
voteCount={note.voteCount}
|
||||||
|
targetType="note"
|
||||||
|
targetId={note._id}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if questions.length > 0}
|
||||||
|
<p class="kicker border-rule mt-8 border-t pt-8">Questions</p>
|
||||||
|
{#each questions as question}
|
||||||
|
<FeedRow
|
||||||
|
href="/questions/{question._id}"
|
||||||
|
title={question.title}
|
||||||
|
unitCode={selectedUnit.code +
|
||||||
|
(selectedUnit.code2 ? ` / ${selectedUnit.code2}` : "")}
|
||||||
|
meta="{question.authorName} · {timeAgo(
|
||||||
|
question.createdAt,
|
||||||
|
)} · {question.answerCount} answer{question.answerCount === 1
|
||||||
|
? ''
|
||||||
|
: 's'}"
|
||||||
|
voteCount={question.voteCount}
|
||||||
|
targetType="question"
|
||||||
|
targetId={question._id}
|
||||||
|
/>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if notes.length === 0 && questions.length === 0}
|
||||||
|
<p class="text-muted mt-8 text-sm">No content for this unit yet.</p>
|
||||||
|
<div class="mt-4 flex gap-4">
|
||||||
|
<a
|
||||||
|
href="/post/note"
|
||||||
|
class="text-secondary hover:text-secondary-dark text-sm"
|
||||||
|
>Post a note</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
href="/post/question"
|
||||||
|
class="text-secondary hover:text-secondary-dark text-sm"
|
||||||
|
>Ask a question</a
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-muted py-16 text-sm">Select a unit to see its notes and questions.</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Loading…
Reference in a new issue