edited admin units dashboard

This commit is contained in:
liyunze 2026-09-01 10:13:15 +08:00
parent 2ff5b92bbc
commit 830a04893a
14 changed files with 3743 additions and 36 deletions

98
dev-dist/sw.js Normal file
View file

@ -0,0 +1,98 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return (
registry[uri] ||
new Promise((resolve) => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri =
nextDefineUri ||
("document" in self ? document.currentScript.src : "") ||
location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = (depUri) => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require,
};
registry[uri] = Promise.all(
depsNames.map((depName) => specialDeps[depName] || require(depName)),
).then((deps) => {
factory(...deps);
return exports;
});
};
}
define(["./workbox-7e5eb42b"], function (workbox) {
"use strict";
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute(
[
{
url: "/",
revision: "0.agglep2qr98",
},
],
{},
);
workbox.cleanupOutdatedCaches();
workbox.registerRoute(
new workbox.NavigationRoute(workbox.createHandlerBoundToURL("/"), {
allowlist: [/^\/$/],
}),
);
});

3545
dev-dist/workbox-7e5eb42b.js Normal file

File diff suppressed because it is too large Load diff

View file

@ -255,24 +255,27 @@ function topicsGetAll(db: Db) {
// ---- units ----
function unitsGetByCode(db: Db, args: { code: string }) {
const code = args.code.toUpperCase();
return (
db
.prepare("SELECT id AS _id, code, name, description FROM units WHERE code = ?")
.get(args.code.toUpperCase()) ?? null
.prepare(
"SELECT id AS _id, code, code2, name, description FROM units WHERE code = ? OR code2 = ?",
)
.get(code, code) ?? null
);
}
function unitsGetAll(db: Db) {
return db
.prepare("SELECT id AS _id, code, name, description FROM units ORDER BY code ASC")
.prepare("SELECT id AS _id, code, code2, name, description FROM units ORDER BY code ASC")
.all();
}
function unitsCreateCustom(db: Db, args: { code: string; name: string }) {
const code = args.code.toUpperCase();
const existing = db.prepare("SELECT id AS _id FROM units WHERE code = ?").get(code) as
| { _id: string }
| undefined;
const existing = db
.prepare("SELECT id AS _id FROM units WHERE code = ? OR code2 = ?")
.get(code, code) as { _id: string } | undefined;
if (existing) return existing._id;
const id = newId();
@ -550,7 +553,7 @@ function getNoteWithDetails(db: Db, args: { id: string }) {
.prepare("SELECT id AS _id, name, slug, description FROM topics WHERE id = ?")
.get(note.topicId);
const unit = db
.prepare("SELECT id AS _id, code, name, description FROM units WHERE id = ?")
.prepare("SELECT id AS _id, code, code2, name, description FROM units WHERE id = ?")
.get(note.unitId);
const comments = commentsListByNote(db, { noteId: note._id });
@ -567,7 +570,7 @@ function getQuestionWithDetails(db: Db, args: { id: string }) {
.prepare("SELECT id AS _id, name, slug, description FROM topics WHERE id = ?")
.get(question.topicId);
const unit = db
.prepare("SELECT id AS _id, code, name, description FROM units WHERE id = ?")
.prepare("SELECT id AS _id, code, code2, name, description FROM units WHERE id = ?")
.get(question.unitId);
const answers = commentsListByQuestion(db, { questionId: question._id });
@ -635,35 +638,51 @@ function adminCompleteSetup(db: Db, args: { email: string; code: string }) {
function adminUnitsSave(
db: Db,
args: { token: string; id?: string; code: string; name: string; description?: string },
args: {
token: string;
id?: string;
code: string;
code2?: string;
name: string;
description?: string;
},
) {
requireAdmin(db, args.token);
const code = (args.code ?? "").trim().toUpperCase();
const name = (args.name ?? "").trim();
if (!code || !name) throw new Error("Code and name are required");
const code2 = (args.code2 ?? "").trim().toUpperCase() || null;
if (code2 && code2 === code)
throw new Error("The second code must differ from the primary code");
const duplicate = db
.prepare("SELECT id AS _id FROM units WHERE code = ? AND id != ?")
.get(code, args.id ?? "") as { _id: string } | undefined;
.prepare("SELECT id AS _id FROM units WHERE (code = ? OR code2 = ?) AND id != ?")
.get(code, code, args.id ?? "") as { _id: string } | undefined;
if (duplicate) throw new Error("A unit with this code already exists");
if (code2) {
const duplicate2 = db
.prepare("SELECT id AS _id FROM units WHERE (code = ? OR code2 = ?) AND id != ?")
.get(code2, code2, args.id ?? "") as { _id: string } | undefined;
if (duplicate2) throw new Error("A unit with this second code already exists");
}
const description = args.description?.trim() || null;
if (args.id) {
const existing = db.prepare("SELECT id AS _id FROM units WHERE id = ?").get(args.id);
if (!existing) throw new Error("Unit not found");
db.prepare("UPDATE units SET code = ?, name = ?, description = ? WHERE id = ?").run(
code,
name,
description,
args.id,
);
db.prepare(
"UPDATE units SET code = ?, code2 = ?, name = ?, description = ? WHERE id = ?",
).run(code, code2, name, description, args.id);
return args.id;
}
const id = newId();
db.prepare("INSERT INTO units (id, code, name, description) VALUES (?, ?, ?, ?)").run(
db.prepare("INSERT INTO units (id, code, code2, name, description) VALUES (?, ?, ?, ?, ?)").run(
id,
code,
code2,
name,
description,
);
@ -797,7 +816,8 @@ function adminNotesList(db: Db, args: { token: string }) {
n.updatedAt,
n.voteCount,
n.commentCount,
u.code AS unitCode
u.code AS unitCode,
u.code2 AS unitCode2
FROM notes n
LEFT JOIN units u ON u.id = n.unitId
ORDER BY n.createdAt DESC`,

View file

@ -30,6 +30,7 @@ function createSchema(database: DatabaseSync) {
CREATE TABLE IF NOT EXISTS units (
id TEXT PRIMARY KEY,
code TEXT NOT NULL UNIQUE,
code2 TEXT,
name TEXT NOT NULL,
description TEXT
);
@ -214,6 +215,11 @@ function migrate(database: DatabaseSync) {
if (!columns.some((c) => c.name === "role")) {
database.exec("ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT 'user'");
}
const unitColumns = database.prepare("PRAGMA table_info(units)").all() as { name: string }[];
if (!unitColumns.some((c) => c.name === "code2")) {
database.exec("ALTER TABLE units ADD COLUMN code2 TEXT");
}
}
function seed(database: DatabaseSync) {

View file

@ -20,6 +20,7 @@ export type TopicDoc = Doc<"topics"> & {
export type UnitDoc = Doc<"units"> & {
code: string;
code2?: string;
name: string;
description?: string;
};

View file

@ -54,7 +54,7 @@
class="group border-rule flex items-baseline justify-between gap-4 border-b px-5 py-3"
>
<span class="text-ink group-hover:text-primary font-serif text-lg"
>{unit.code}</span
>{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}</span
>
<span class="text-muted truncate text-sm">{unit.name}</span>
</a>

View file

@ -28,6 +28,7 @@
authorName: string;
createdAt: number;
unitCode?: string;
unitCode2?: string;
voteCount: number;
commentCount: number;
};
@ -53,7 +54,7 @@
let pageError = $state("");
// Unit editor
let unitForm = $state({ id: "", code: "", name: "", description: "" });
let unitForm = $state({ id: "", code: "", code2: "", name: "", description: "" });
let unitBusy = $state(false);
let unitError = $state("");
let unitSuccess = $state("");
@ -163,6 +164,7 @@
unitForm = {
id: unit._id,
code: unit.code,
code2: unit.code2 ?? "",
name: unit.name,
description: unit.description ?? "",
};
@ -171,7 +173,7 @@
}
function resetUnitForm() {
unitForm = { id: "", code: "", name: "", description: "" };
unitForm = { id: "", code: "", code2: "", name: "", description: "" };
unitError = "";
unitSuccess = "";
}
@ -186,6 +188,7 @@
token: adminToken(),
id: unitForm.id || undefined,
code: unitForm.code,
code2: unitForm.code2,
name: unitForm.name,
description: unitForm.description,
});
@ -534,7 +537,9 @@
class="border-rule flex items-center justify-between gap-4 border-b py-3"
>
<div class="min-w-0">
<p class="text-ink font-medium">{unit.code}</p>
<p class="text-ink font-medium">
{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}
</p>
<p class="text-muted truncate text-sm">{unit.name}</p>
</div>
<div class="flex shrink-0 gap-3">
@ -558,7 +563,10 @@
</div>
</div>
<form onsubmit={saveUnit} class="border-rule h-fit space-y-5 border p-5">
<form
onsubmit={saveUnit}
class="border-rule sticky top-5 h-fit space-y-5 border p-5"
>
<p class="kicker">{unitForm.id ? "Edit unit" : "Add unit"}</p>
<div>
<label for="unit-code" class="kicker mb-2 block">Code</label>
@ -571,6 +579,18 @@
required
/>
</div>
<div>
<label for="unit-code2" class="kicker mb-2 block"
>Second code (optional)</label
>
<input
id="unit-code2"
type="text"
bind:value={unitForm.code2}
placeholder="e.g., SIT103"
class="field"
/>
</div>
<div>
<label for="unit-name" class="kicker mb-2 block">Name</label>
<input
@ -747,7 +767,10 @@
<div class="min-w-0">
<p class="text-ink font-medium">{note.title}</p>
<p class="text-muted mt-1 truncate text-sm">
{note.unitCode ?? "—"} · {note.authorName} · {timeAgo(
{note.unitCode
? note.unitCode +
(note.unitCode2 ? ` / ${note.unitCode2}` : "")
: "—"} · {note.authorName} · {timeAgo(
note.createdAt,
)}
</p>

View file

@ -56,7 +56,7 @@
class="chip {selectedUnitId === unit._id ? 'chip-active' : ''}"
onclick={() => (selectedUnitId = unit._id)}
>
{unit.code}
{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}
</button>
{/each}
</div>
@ -85,7 +85,9 @@
<FeedRow
href="/notes/{note._id}"
title={note.title}
unitCode={note.unit?.code}
unitCode={note.unit
? note.unit.code + (note.unit.code2 ? ` / ${note.unit.code2}` : "")
: undefined}
meta="{note.authorName} · {timeAgo(
note.createdAt,
)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"

View file

@ -89,7 +89,9 @@
<VoteStack count={note.voteCount} targetType="note" targetId={note._id} />
<div class="min-w-0 flex-1">
<p class="kicker">
{#if unit}{unit.code}{/if}{#if unit && topic}
{#if unit}{unit.code}{unit.code2
? ` / ${unit.code2}`
: ""}{/if}{#if unit && topic}
·
{/if}{#if topic}{topic.name}{/if}
</p>

View file

@ -148,7 +148,9 @@
<select id="unit" bind:value={selectedUnitId} class="field">
<option value="">Select a unit...</option>
{#each units as unit}
<option value={unit._id}>{unit.code} {unit.name}</option>
<option value={unit._id}
>{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}{unit.name}</option
>
{/each}
</select>
{/if}

View file

@ -148,7 +148,9 @@
<select id="unit" bind:value={selectedUnitId} class="field">
<option value="">Select a unit...</option>
{#each units as unit}
<option value={unit._id}>{unit.code} {unit.name}</option>
<option value={unit._id}
>{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}{unit.name}</option
>
{/each}
</select>
{/if}

View file

@ -61,7 +61,7 @@
class="chip {selectedUnitId === unit._id ? 'chip-active' : ''}"
onclick={() => (selectedUnitId = unit._id)}
>
{unit.code}
{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}
</button>
{/each}
</div>
@ -90,7 +90,9 @@
<FeedRow
href="/questions/{question._id}"
title={question.title}
unitCode={question.unit?.code}
unitCode={question.unit
? question.unit.code + (question.unit.code2 ? ` / ${question.unit.code2}` : "")
: undefined}
meta="{question.authorName} · {timeAgo(
question.createdAt,
)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}"

View file

@ -86,7 +86,9 @@
<VoteStack count={question.voteCount} targetType="question" targetId={question._id} />
<div class="min-w-0 flex-1">
<p class="kicker">
{#if unit}{unit.code}{/if}{#if unit && topic}
{#if unit}{unit.code}{unit.code2
? ` / ${unit.code2}`
: ""}{/if}{#if unit && topic}
·
{/if}{#if topic}{topic.name}{/if}
{#if question.solved}

View file

@ -36,7 +36,9 @@
<p class="kicker py-16">Loading</p>
{:else if unit}
<p class="kicker"><a href="/" class="hover:text-primary">Home</a> · Unit</p>
<h1 class="text-ink mt-2 font-serif text-4xl font-medium">{unit.code}</h1>
<h1 class="text-ink mt-2 font-serif text-4xl font-medium">
{unit.code}{unit.code2 ? ` / ${unit.code2}` : ""}
</h1>
<p class="text-muted mt-1 text-[15px]">{unit.name}</p>
{#if unit.description}
<p class="text-muted mt-2 text-sm">{unit.description}</p>
@ -48,7 +50,7 @@
<FeedRow
href="/notes/{note._id}"
title={note.title}
unitCode={unit.code}
unitCode={unit.code + (unit.code2 ? ` / ${unit.code2}` : "")}
meta="{note.authorName} · {timeAgo(
note.createdAt,
)} · {note.commentCount} comment{note.commentCount === 1 ? '' : 's'}"
@ -65,7 +67,7 @@
<FeedRow
href="/questions/{question._id}"
title={question.title}
unitCode={unit.code}
unitCode={unit.code + (unit.code2 ? ` / ${unit.code2}` : "")}
meta="{question.authorName} · {timeAgo(
question.createdAt,
)} · {question.answerCount} answer{question.answerCount === 1 ? '' : 's'}"