first commit
This commit is contained in:
commit
672f0deb6a
18 changed files with 1274 additions and 0 deletions
36
.gitignore
vendored
Normal file
36
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
# dependencies (bun install)
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# output
|
||||||
|
out
|
||||||
|
dist
|
||||||
|
*.tgz
|
||||||
|
|
||||||
|
# code coverage
|
||||||
|
coverage
|
||||||
|
*.lcov
|
||||||
|
|
||||||
|
# logs
|
||||||
|
logs
|
||||||
|
_.log
|
||||||
|
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||||
|
|
||||||
|
# dotenv environment variable files
|
||||||
|
.env
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
.env.local
|
||||||
|
|
||||||
|
# caches
|
||||||
|
.eslintcache
|
||||||
|
.cache
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
# IntelliJ based IDEs
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Finder (MacOS) folder config
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
links.*
|
||||||
10
README.md
Normal file
10
README.md
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
# link_shortener
|
||||||
|
|
||||||
|
sssg based link shortener.
|
||||||
|
Env:
|
||||||
|
```
|
||||||
|
PASSWORD="this is a valid password that can be used yesyes"
|
||||||
|
PORT=3001
|
||||||
|
HOST="0.0.0.0"
|
||||||
|
ORIGIN="http://localhost:8080"
|
||||||
|
```
|
||||||
71
backend/db.ts
Normal file
71
backend/db.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { Database } from "bun:sqlite";
|
||||||
|
|
||||||
|
export const db = new Database("links.sqlite", { create: true });
|
||||||
|
|
||||||
|
db.run("PRAGMA journal_mode = WAL;");
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS links (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
short TEXT NOT NULL UNIQUE,
|
||||||
|
ogTitle TEXT,
|
||||||
|
ogDesc TEXT,
|
||||||
|
ogImage TEXT
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
db.run(`
|
||||||
|
CREATE TABLE IF NOT EXISTS stats (
|
||||||
|
linkId TEXT PRIMARY KEY,
|
||||||
|
clicks INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Use positional placeholders ?
|
||||||
|
export const insertLinkStmt = db.query(`
|
||||||
|
INSERT INTO links (id, target, short, ogTitle, ogDesc, ogImage)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const insertStatsStmt = db.query(`
|
||||||
|
INSERT INTO stats (linkId, clicks) VALUES (?, 0)
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const getAllLinksStmt = db.query(`
|
||||||
|
SELECT l.*, s.clicks
|
||||||
|
FROM links l
|
||||||
|
LEFT JOIN stats s ON l.id = s.linkId
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const getLinkByIdStmt = db.query(`
|
||||||
|
SELECT l.*, s.clicks
|
||||||
|
FROM links l
|
||||||
|
LEFT JOIN stats s ON l.id = s.linkId
|
||||||
|
WHERE l.id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const getLinkByShortStmt = db.query(`
|
||||||
|
SELECT * FROM links WHERE short = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const updateLinkStmt = db.query(`
|
||||||
|
UPDATE links SET
|
||||||
|
target = COALESCE(?, target),
|
||||||
|
short = COALESCE(?, short),
|
||||||
|
ogTitle = COALESCE(?, ogTitle),
|
||||||
|
ogDesc = COALESCE(?, ogDesc),
|
||||||
|
ogImage = COALESCE(?, ogImage)
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const deleteLinkStmt = db.query(`DELETE FROM links WHERE id = ?`);
|
||||||
|
export const deleteStatsStmt = db.query(`DELETE FROM stats WHERE linkId = ?`);
|
||||||
|
|
||||||
|
export const incClickStmt = db.query(`
|
||||||
|
UPDATE stats SET clicks = clicks + 1 WHERE linkId = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
export const getStatsByIdStmt = db.query(`
|
||||||
|
SELECT clicks FROM stats WHERE linkId = ?
|
||||||
|
`);
|
||||||
276
backend/index.ts
Normal file
276
backend/index.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
import * as fs from "fs";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { deleteLinkStmt,
|
||||||
|
getAllLinksStmt,
|
||||||
|
getLinkByIdStmt,
|
||||||
|
getLinkByShortStmt,
|
||||||
|
getStatsByIdStmt,
|
||||||
|
incClickStmt, insertLinkStmt, insertStatsStmt, updateLinkStmt } from "./db";
|
||||||
|
|
||||||
|
import type { LinkEntry } from "../common/lib";
|
||||||
|
|
||||||
|
const linkSchema = z.object({
|
||||||
|
target: z.string().url({ message: "target must be a valid URL" }),
|
||||||
|
short: z.string().min(1, { message: "short cannot be empty" }),
|
||||||
|
ogTitle: z.string().max(60).optional(),
|
||||||
|
ogDesc: z.string().max(120).optional(),
|
||||||
|
ogImage: z.union([
|
||||||
|
z.literal(""),
|
||||||
|
z.string().trim().url({ message: "ogImage must be a valid URL" })
|
||||||
|
])
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
function requireAuth(req: Request) {
|
||||||
|
const pwd = req.headers.get("X-Password");
|
||||||
|
if (pwd !== process.env.PASSWORD) {
|
||||||
|
return new Response(JSON.stringify({ success: false, error: "Unauthorized" }), { status: 403 });
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class ClientResponse extends Response {
|
||||||
|
constructor(body?: BodyInit, init?: ResponseInit) {
|
||||||
|
super(body, init);
|
||||||
|
this.headers.set("Access-Control-Allow-Origin", process.env.ORIGIN!);
|
||||||
|
this.headers.set("Access-Control-Allow-Methods", "OPTIONS, GET, POST, PUT, DELETE");
|
||||||
|
this.headers.set("Access-Control-Allow-Headers", "Content-Type, X-Password");
|
||||||
|
}
|
||||||
|
|
||||||
|
static override json(body: unknown, init?: ResponseInit): Response {
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"Access-Control-Allow-Origin": process.env.ORIGIN!,
|
||||||
|
"Access-Control-Allow-Methods": "OPTIONS, GET, POST, PUT, DELETE",
|
||||||
|
"Access-Control-Allow-Headers": "Content-Type, X-Password",
|
||||||
|
...init?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------- Server ---------------------
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
hostname: process.env.HOST || "127.0.0.1",
|
||||||
|
routes: {
|
||||||
|
"/api/status": () => new ClientResponse("OK"),
|
||||||
|
|
||||||
|
"/api/login": {
|
||||||
|
OPTIONS: () => new ClientResponse("Departed", { status: 204 }),
|
||||||
|
POST: (req) => {
|
||||||
|
const pwd = req.headers.get("X-Password");
|
||||||
|
if (pwd !== process.env.PASSWORD) {
|
||||||
|
return ClientResponse.json({ success: false, error: "Invalid password" }, { status: 403 });
|
||||||
|
}
|
||||||
|
return ClientResponse.json({ success: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"/api/links": {
|
||||||
|
OPTIONS: () => new ClientResponse("Departed", { status: 204 }),
|
||||||
|
GET: (req) => {
|
||||||
|
const authErr = requireAuth(req);
|
||||||
|
if (authErr) return authErr;
|
||||||
|
|
||||||
|
const rows = getAllLinksStmt.all();
|
||||||
|
return ClientResponse.json({ success: true, data: rows });
|
||||||
|
},
|
||||||
|
|
||||||
|
POST: async (req) => {
|
||||||
|
const authErr = requireAuth(req);
|
||||||
|
if (authErr) return authErr;
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const parsed = linkSchema.safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return ClientResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: Object.values(z.treeifyError(parsed.error).properties!)
|
||||||
|
.flatMap(z => z.errors)
|
||||||
|
.join(", "),
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = Math.random().toString(36).slice(2, 11);
|
||||||
|
const safe = {
|
||||||
|
id,
|
||||||
|
target: parsed.data.target,
|
||||||
|
short: parsed.data.short,
|
||||||
|
ogTitle: parsed.data.ogTitle ?? "",
|
||||||
|
ogDesc: parsed.data.ogDesc ?? "",
|
||||||
|
ogImage: parsed.data.ogImage ?? "",
|
||||||
|
};
|
||||||
|
insertLinkStmt.run(
|
||||||
|
safe.id,
|
||||||
|
safe.target,
|
||||||
|
safe.short,
|
||||||
|
safe.ogTitle,
|
||||||
|
safe.ogDesc,
|
||||||
|
safe.ogImage
|
||||||
|
);
|
||||||
|
insertStatsStmt.run(id);
|
||||||
|
|
||||||
|
const newLink = getLinkByIdStmt.get(id);
|
||||||
|
return ClientResponse.json({ success: true, data: newLink });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/link/:short": (req) => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new ClientResponse("Departed", { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const short = req.params.short;
|
||||||
|
const link = getLinkByShortStmt.get(short) as LinkEntry;
|
||||||
|
|
||||||
|
if (!link) {
|
||||||
|
return new ClientResponse("Missing", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
incClickStmt.run(link.id);
|
||||||
|
|
||||||
|
const ua = req.headers.get("user-agent")?.toLowerCase() || "";
|
||||||
|
|
||||||
|
const isBot =
|
||||||
|
ua.includes("facebookexternalhit") ||
|
||||||
|
ua.includes("twitterbot") ||
|
||||||
|
ua.includes("discordbot") ||
|
||||||
|
ua.includes("linkedinbot") ||
|
||||||
|
ua.includes("slackbot") ||
|
||||||
|
ua.includes("telegrambot");;
|
||||||
|
|
||||||
|
if (isBot && (link.ogTitle || link.ogDesc || link.ogImage)) {
|
||||||
|
const title = link.ogTitle || link.short;
|
||||||
|
const description = link.ogDesc || "";
|
||||||
|
const image = link.ogImage || "";
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>${title}</title>
|
||||||
|
<meta property="og:title" content="${title}">
|
||||||
|
<meta property="og:description" content="${description}">
|
||||||
|
<meta property="og:image" content="${image}">
|
||||||
|
<meta property="og:url" content="${link.target}">
|
||||||
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
Redirecting to <a href="${link.target}">${link.target}</a>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`;
|
||||||
|
|
||||||
|
return new Response(html, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/html",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// normal redirect
|
||||||
|
return new Response(null, {
|
||||||
|
status: 302,
|
||||||
|
headers: { Location: link.target },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// CRUD for 1 link
|
||||||
|
"/api/links/:id": {
|
||||||
|
OPTIONS: () => new ClientResponse("Departed", { status: 204 }),
|
||||||
|
|
||||||
|
GET: (req) => {
|
||||||
|
const authErr = requireAuth(req);
|
||||||
|
if (authErr) return authErr;
|
||||||
|
|
||||||
|
const id = req.params.id;
|
||||||
|
const row = getLinkByIdStmt.get(id);
|
||||||
|
if (!row) {
|
||||||
|
return ClientResponse.json({ success: false, error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
return ClientResponse.json({ success: true, data: row });
|
||||||
|
},
|
||||||
|
|
||||||
|
PUT: async (req) => {
|
||||||
|
const authErr = requireAuth(req);
|
||||||
|
if (authErr) return authErr;
|
||||||
|
|
||||||
|
const id = req.params.id;
|
||||||
|
const row = getLinkByIdStmt.get(id);
|
||||||
|
if (!row) {
|
||||||
|
return ClientResponse.json({ success: false, error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await req.json();
|
||||||
|
const parsed = linkSchema.partial().safeParse(body);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return ClientResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: Object.values(z.treeifyError(parsed.error).properties!)
|
||||||
|
.flatMap(z => z.errors)
|
||||||
|
.join(", "),
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateLinkStmt.run(
|
||||||
|
parsed.data.target ?? null,
|
||||||
|
parsed.data.short ?? null,
|
||||||
|
parsed.data.ogTitle ?? null,
|
||||||
|
parsed.data.ogDesc ?? null,
|
||||||
|
parsed.data.ogImage ?? null,
|
||||||
|
id
|
||||||
|
);
|
||||||
|
|
||||||
|
const updated = getLinkByIdStmt.get(id);
|
||||||
|
|
||||||
|
return ClientResponse.json({ success: true, data: updated });
|
||||||
|
},
|
||||||
|
|
||||||
|
DELETE: (req) => {
|
||||||
|
const authErr = requireAuth(req);
|
||||||
|
if (authErr) return authErr;
|
||||||
|
|
||||||
|
const id = req.params.id;
|
||||||
|
deleteLinkStmt.run(id);
|
||||||
|
deleteLinkStmt.run(id);
|
||||||
|
|
||||||
|
return ClientResponse.json({ success: true });
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
"/api/links/:id/stats": (req) => {
|
||||||
|
if (req.method === "OPTIONS") {
|
||||||
|
return new ClientResponse("Departed", { status: 204 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const authErr = requireAuth(req);
|
||||||
|
if (authErr) return authErr;
|
||||||
|
|
||||||
|
const id = req.params.id;
|
||||||
|
const row = getStatsByIdStmt.get(id);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return ClientResponse.json({ success: false, error: "Not found" }, { status: 404 });
|
||||||
|
}
|
||||||
|
return ClientResponse.json({ success: true, data: row });
|
||||||
|
},
|
||||||
|
|
||||||
|
"/api/*": () =>
|
||||||
|
ClientResponse.json({ success: false, error: "Not found" }, { status: 404 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Server running on http://localhost:" + server.port);
|
||||||
257
bun.lock
Normal file
257
bun.lock
Normal file
|
|
@ -0,0 +1,257 @@
|
||||||
|
{
|
||||||
|
"lockfileVersion": 1,
|
||||||
|
"configVersion": 1,
|
||||||
|
"workspaces": {
|
||||||
|
"": {
|
||||||
|
"name": "link_shortener",
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"sssg": "git+https://git.sad.ovh/sophie/sssg#e183025a165f1c12b5a270710f994482f33fb9f5",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"zod": "^4.1.13",
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest",
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"packages": {
|
||||||
|
"@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="],
|
||||||
|
|
||||||
|
"@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
|
||||||
|
|
||||||
|
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.23.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm": ["@esbuild/android-arm@0.23.1", "", { "os": "android", "cpu": "arm" }, "sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ=="],
|
||||||
|
|
||||||
|
"@esbuild/android-arm64": ["@esbuild/android-arm64@0.23.1", "", { "os": "android", "cpu": "arm64" }, "sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw=="],
|
||||||
|
|
||||||
|
"@esbuild/android-x64": ["@esbuild/android-x64@0.23.1", "", { "os": "android", "cpu": "x64" }, "sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.23.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q=="],
|
||||||
|
|
||||||
|
"@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.23.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.23.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA=="],
|
||||||
|
|
||||||
|
"@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.23.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm": ["@esbuild/linux-arm@0.23.1", "", { "os": "linux", "cpu": "arm" }, "sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.23.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.23.1", "", { "os": "linux", "cpu": "ia32" }, "sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.23.1", "", { "os": "linux", "cpu": "none" }, "sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.23.1", "", { "os": "linux", "cpu": "none" }, "sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.23.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.23.1", "", { "os": "linux", "cpu": "none" }, "sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.23.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw=="],
|
||||||
|
|
||||||
|
"@esbuild/linux-x64": ["@esbuild/linux-x64@0.23.1", "", { "os": "linux", "cpu": "x64" }, "sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ=="],
|
||||||
|
|
||||||
|
"@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.23.1", "", { "os": "none", "cpu": "x64" }, "sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.23.1", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q=="],
|
||||||
|
|
||||||
|
"@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.23.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA=="],
|
||||||
|
|
||||||
|
"@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.23.1", "", { "os": "sunos", "cpu": "x64" }, "sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.23.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.23.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ=="],
|
||||||
|
|
||||||
|
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.23.1", "", { "os": "win32", "cpu": "x64" }, "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.0.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.0.4" }, "os": "darwin", "cpu": "x64" }, "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.0.5", "", { "os": "linux", "cpu": "arm" }, "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.0.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA=="],
|
||||||
|
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.0.5" }, "os": "linux", "cpu": "arm" }, "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.0.4" }, "os": "linux", "cpu": "s390x" }, "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q=="],
|
||||||
|
|
||||||
|
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA=="],
|
||||||
|
|
||||||
|
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.0.4" }, "os": "linux", "cpu": "arm64" }, "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g=="],
|
||||||
|
|
||||||
|
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.33.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.0.4" }, "os": "linux", "cpu": "x64" }, "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw=="],
|
||||||
|
|
||||||
|
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.33.5", "", { "dependencies": { "@emnapi/runtime": "^1.2.0" }, "cpu": "none" }, "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.33.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ=="],
|
||||||
|
|
||||||
|
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.33.5", "", { "os": "win32", "cpu": "x64" }, "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg=="],
|
||||||
|
|
||||||
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
|
|
||||||
|
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
|
||||||
|
|
||||||
|
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
|
||||||
|
|
||||||
|
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="],
|
||||||
|
|
||||||
|
"@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="],
|
||||||
|
|
||||||
|
"@types/bun": ["@types/bun@1.3.4", "", { "dependencies": { "bun-types": "1.3.4" } }, "sha512-EEPTKXHP+zKGPkhRLv+HI0UEX8/o+65hqARxLy8Ov5rIxMBPNTjeZww00CIihrIQGEQBYg+0roO5qOnS/7boGA=="],
|
||||||
|
|
||||||
|
"@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="],
|
||||||
|
|
||||||
|
"@types/node": ["@types/node@24.10.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-gqkrWUsS8hcm0r44yn7/xZeV1ERva/nLgrLxFRUGb7aoNMIJfZJ3AC261zDQuOAKC7MiXai1WCpYc48jAHoShQ=="],
|
||||||
|
|
||||||
|
"bun-types": ["bun-types@1.3.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-5ua817+BZPZOlNaRgGBpZJOSAQ9RQ17pkwPD0yR7CfJg+r8DgIILByFifDTa+IPDDxzf5VNhtNlcKqFzDgJvlQ=="],
|
||||||
|
|
||||||
|
"color": ["color@4.2.3", "", { "dependencies": { "color-convert": "^2.0.1", "color-string": "^1.9.0" } }, "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A=="],
|
||||||
|
|
||||||
|
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
|
||||||
|
|
||||||
|
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
|
||||||
|
|
||||||
|
"color-string": ["color-string@1.9.1", "", { "dependencies": { "color-name": "^1.0.0", "simple-swizzle": "^0.2.2" } }, "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg=="],
|
||||||
|
|
||||||
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
|
"enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="],
|
||||||
|
|
||||||
|
"esbuild": ["esbuild@0.23.1", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.23.1", "@esbuild/android-arm": "0.23.1", "@esbuild/android-arm64": "0.23.1", "@esbuild/android-x64": "0.23.1", "@esbuild/darwin-arm64": "0.23.1", "@esbuild/darwin-x64": "0.23.1", "@esbuild/freebsd-arm64": "0.23.1", "@esbuild/freebsd-x64": "0.23.1", "@esbuild/linux-arm": "0.23.1", "@esbuild/linux-arm64": "0.23.1", "@esbuild/linux-ia32": "0.23.1", "@esbuild/linux-loong64": "0.23.1", "@esbuild/linux-mips64el": "0.23.1", "@esbuild/linux-ppc64": "0.23.1", "@esbuild/linux-riscv64": "0.23.1", "@esbuild/linux-s390x": "0.23.1", "@esbuild/linux-x64": "0.23.1", "@esbuild/netbsd-x64": "0.23.1", "@esbuild/openbsd-arm64": "0.23.1", "@esbuild/openbsd-x64": "0.23.1", "@esbuild/sunos-x64": "0.23.1", "@esbuild/win32-arm64": "0.23.1", "@esbuild/win32-ia32": "0.23.1", "@esbuild/win32-x64": "0.23.1" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg=="],
|
||||||
|
|
||||||
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"is-arrayish": ["is-arrayish@0.3.4", "", {}, "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA=="],
|
||||||
|
|
||||||
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
|
"lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="],
|
||||||
|
|
||||||
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="],
|
||||||
|
|
||||||
|
"lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="],
|
||||||
|
|
||||||
|
"lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="],
|
||||||
|
|
||||||
|
"lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="],
|
||||||
|
|
||||||
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="],
|
||||||
|
|
||||||
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"marked": ["marked@14.1.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-vkVZ8ONmUdPnjCKc5uTRvmkRbx4EAi2OkTOXmfTDhZz3OFqMNBM1oTTWwTr4HY4uAEojhzPf+Fy8F1DWa3Sndg=="],
|
||||||
|
|
||||||
|
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||||
|
|
||||||
|
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
|
||||||
|
|
||||||
|
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
|
||||||
|
|
||||||
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"preact": ["preact@10.28.0", "", {}, "sha512-rytDAoiXr3+t6OIP3WGlDd0ouCUG1iCWzkcY3++Nreuoi17y6T5i/zRhe6uYfoVcxq6YU+sBtJouuRDsq8vvqA=="],
|
||||||
|
|
||||||
|
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||||
|
|
||||||
|
"sharp": ["sharp@0.33.5", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.3", "semver": "^7.6.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.33.5", "@img/sharp-darwin-x64": "0.33.5", "@img/sharp-libvips-darwin-arm64": "1.0.4", "@img/sharp-libvips-darwin-x64": "1.0.4", "@img/sharp-libvips-linux-arm": "1.0.5", "@img/sharp-libvips-linux-arm64": "1.0.4", "@img/sharp-libvips-linux-s390x": "1.0.4", "@img/sharp-libvips-linux-x64": "1.0.4", "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", "@img/sharp-libvips-linuxmusl-x64": "1.0.4", "@img/sharp-linux-arm": "0.33.5", "@img/sharp-linux-arm64": "0.33.5", "@img/sharp-linux-s390x": "0.33.5", "@img/sharp-linux-x64": "0.33.5", "@img/sharp-linuxmusl-arm64": "0.33.5", "@img/sharp-linuxmusl-x64": "0.33.5", "@img/sharp-wasm32": "0.33.5", "@img/sharp-win32-ia32": "0.33.5", "@img/sharp-win32-x64": "0.33.5" } }, "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw=="],
|
||||||
|
|
||||||
|
"simple-swizzle": ["simple-swizzle@0.2.4", "", { "dependencies": { "is-arrayish": "^0.3.1" } }, "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw=="],
|
||||||
|
|
||||||
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"sssg": ["sssg@git+https://git.sad.ovh/sophie/sssg#e183025a165f1c12b5a270710f994482f33fb9f5", { "dependencies": { "@types/mime-types": "^2.1.4", "@types/node": "^24.7.1", "esbuild": "^0.23.1", "marked": "^14.1.0", "mime-types": "^2.1.35", "postcss": "^8.4.41", "preact": "^10.23.2", "sharp": "^0.33.5" }, "peerDependencies": { "typescript": "^5.0.0" } }, "e183025a165f1c12b5a270710f994482f33fb9f5"],
|
||||||
|
|
||||||
|
"tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="],
|
||||||
|
|
||||||
|
"tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="],
|
||||||
|
|
||||||
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||||
|
|
||||||
|
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||||
|
|
||||||
|
"zod": ["zod@4.1.13", "", {}, "sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.7.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.7.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.0", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"bun-types/@types/node": ["@types/node@25.0.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-czWPzKIAXucn9PtsttxmumiQ9N0ok9FrBwgRWrwmVLlp86BrMExzvXRLFYRJ+Ex3g6yqj+KuaxfX1JTgV2lpfg=="],
|
||||||
|
}
|
||||||
|
}
|
||||||
9
common/lib.ts
Normal file
9
common/lib.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
export interface LinkEntry {
|
||||||
|
id: string;
|
||||||
|
target: string;
|
||||||
|
short: string;
|
||||||
|
ogTitle?: string;
|
||||||
|
ogDesc?: string;
|
||||||
|
ogImage?: string;
|
||||||
|
clicks: number;
|
||||||
|
}
|
||||||
5
eslint.config.mjs
Normal file
5
eslint.config.mjs
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
export default tseslint.config({
|
||||||
|
rules: {
|
||||||
|
"@typescript-eslint/no-explicit-any": "allow"
|
||||||
|
}
|
||||||
|
});
|
||||||
32
frontend.ts
Normal file
32
frontend.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
import SSSG, { Plugin } from "sssg";
|
||||||
|
import Dev from "sssg/src/plugins/dev";
|
||||||
|
import TSCompiler from "sssg/src/plugins/ts-compiler";
|
||||||
|
import PostCSS from "sssg/src/plugins/postcss"
|
||||||
|
import tailwindcss from "@tailwindcss/postcss"
|
||||||
|
import * as path from "path";
|
||||||
|
|
||||||
|
let isProd = process.argv.at(2) == "prod"
|
||||||
|
|
||||||
|
const sssg = new SSSG({
|
||||||
|
outputFolder: path.join(import.meta.dir, "dist"),
|
||||||
|
inputFolder: path.join(import.meta.dir, "frontend"),
|
||||||
|
});
|
||||||
|
|
||||||
|
const plugins: Plugin[] = [
|
||||||
|
new TSCompiler(isProd),
|
||||||
|
new PostCSS([
|
||||||
|
tailwindcss({
|
||||||
|
base: path.join(import.meta.dir, "frontend"),
|
||||||
|
})
|
||||||
|
]),
|
||||||
|
]
|
||||||
|
|
||||||
|
if(!isProd) {
|
||||||
|
plugins.push(new Dev(sssg, {}, 8080))
|
||||||
|
}
|
||||||
|
|
||||||
|
await sssg.run({
|
||||||
|
plugins,
|
||||||
|
});
|
||||||
|
|
||||||
|
await sssg.build();
|
||||||
58
frontend/assets/style.css
Normal file
58
frontend/assets/style.css
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Dark theme variables */
|
||||||
|
:root {
|
||||||
|
--primary: #83c5be;
|
||||||
|
--secondary: #006d77;
|
||||||
|
--accent: #d4a373;
|
||||||
|
--bg: #121212;
|
||||||
|
--text: #edf6f9;
|
||||||
|
--input-bg: #1e1e1e;
|
||||||
|
--input-border: #83c5be;
|
||||||
|
--card-bg: #1e1e1e;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
@apply bg-[var(--bg)] text-[var(--text)] font-sans;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
@apply px-4 py-2 rounded-lg font-semibold text-white;
|
||||||
|
background-color: var(--primary);
|
||||||
|
transition: background-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
background-color: var(--secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
@apply opacity-50 cursor-not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input {
|
||||||
|
@apply border rounded-md px-3 py-2 w-full;
|
||||||
|
background-color: var(--input-bg);
|
||||||
|
border-color: var(--input-border);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input::placeholder {
|
||||||
|
color: #aaaaaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
@apply bg-[var(--card-bg)] p-6 rounded-xl shadow-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
@apply bg-[#1b1b1b] p-4 flex flex-col;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar hr {
|
||||||
|
@apply border-gray-700 my-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-list {
|
||||||
|
@apply flex flex-col gap-2 overflow-y-auto;
|
||||||
|
}
|
||||||
60
frontend/dashboard.html
Normal file
60
frontend/dashboard.html
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Dashboard</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="flex h-screen">
|
||||||
|
<div class="sidebar w-72">
|
||||||
|
<button id="new-entry" class="btn mb-4 w-full">+ New Link</button>
|
||||||
|
<hr>
|
||||||
|
<div id="link-list" class="sidebar-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 p-8 overflow-y-auto">
|
||||||
|
<div id="editor" class="card hidden">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Edit Link</h2>
|
||||||
|
<form id="link-form" class="flex flex-col gap-4">
|
||||||
|
<label>Link Target URL
|
||||||
|
<input type="text" id="link-target" class="input" placeholder="https://example.com">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>Shortened Link
|
||||||
|
<input type="text" id="link-short" class="input" placeholder="example">
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend class="font-semibold mb-2">OpenGraph Settings</legend>
|
||||||
|
<label>Title
|
||||||
|
<input type="text" id="og-title" class="input" placeholder="Page Title">
|
||||||
|
</label>
|
||||||
|
<label>Description
|
||||||
|
<textarea id="og-desc" class="input" placeholder="Description"></textarea>
|
||||||
|
</label>
|
||||||
|
<label>Image URL
|
||||||
|
<input type="text" id="og-image" class="input" placeholder="https://image.url">
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<h3 class="font-semibold mb-2">OpenGraph Preview</h3>
|
||||||
|
<div id="og-preview" class="flex"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend class="font-semibold mb-2">Statistics</legend>
|
||||||
|
<p id="stats" class="text-sm text-gray-400">No stats yet</p>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="flex gap-2 mt-4">
|
||||||
|
<button type="submit" id="save-btn" class="btn flex-1" disabled>Save</button>
|
||||||
|
<button type="button" id="delete-btn" class="btn flex-1 bg-red-600 hover:bg-red-500">Delete</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script type="module" src="/ts/dashboard.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
19
frontend/index.html
Normal file
19
frontend/index.html
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Login</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css">
|
||||||
|
</head>
|
||||||
|
<body class="flex items-center justify-center min-h-screen">
|
||||||
|
<div class="bg-[#1e1e1e] p-8 rounded-xl shadow-lg w-96">
|
||||||
|
<h1 class="text-2xl font-bold mb-6 text-[var(--primary)]">Login</h1>
|
||||||
|
<form>
|
||||||
|
<label for="password" class="block mb-2 font-medium text-[var(--text)]">Password</label>
|
||||||
|
<input type="password" id="password" class="input mb-4" placeholder="Enter your password">
|
||||||
|
<button type="submit" class="btn w-full">Log In</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/ts/login.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
88
frontend/ts/api.ts
Normal file
88
frontend/ts/api.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
import type { LinkEntry } from "../../common/lib";
|
||||||
|
|
||||||
|
const API_BASE = location.hostname == "localhost" || location.hostname == "127.0.0.1" ? "http://localhost:3001" : "";
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAuthHeader() {
|
||||||
|
const password = localStorage.getItem("password");
|
||||||
|
if (!password) throw new Error("No password found in localStorage");
|
||||||
|
return { "X-Password": password };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request<T>(url: string, options: RequestInit = {}): Promise<ApiResponse<T>> {
|
||||||
|
const headers = { ...options.headers, ...getAuthHeader() };
|
||||||
|
let res: Response;
|
||||||
|
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_BASE}/api/${url}`, { ...options, headers });
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: "Network error" };
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = null;
|
||||||
|
try {
|
||||||
|
json = await res.json();
|
||||||
|
} catch {
|
||||||
|
return { success: false, error: `Server returned status ${res.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: json?.error || `Request failed with status ${res.status}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: json as T
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function login(password: string): Promise<ApiResponse<void>> {
|
||||||
|
localStorage.setItem("password", password);
|
||||||
|
const res = await request<void>(`login`, { method: "POST" });
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<ApiResponse<void>> {
|
||||||
|
localStorage.removeItem("password");
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLinks(): Promise<ApiResponse<LinkEntry[]>> {
|
||||||
|
return request<LinkEntry[]>("links");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLink(id: string): Promise<ApiResponse<LinkEntry>> {
|
||||||
|
return request<LinkEntry>(`links/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createLink(link: Partial<LinkEntry>): Promise<ApiResponse<LinkEntry>> {
|
||||||
|
return request<LinkEntry>("links", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(link),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateLink(id: string, link: Partial<LinkEntry>): Promise<ApiResponse<LinkEntry>> {
|
||||||
|
return request<LinkEntry>(`links/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(link),
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteLink(id: string): Promise<ApiResponse<void>> {
|
||||||
|
return request<void>(`links/${id}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getLinkStats(id: string): Promise<ApiResponse<{ clicks: number }>> {
|
||||||
|
return request<{ clicks: number }>(`links/${id}/stats`);
|
||||||
|
}
|
||||||
206
frontend/ts/dashboard.ts
Normal file
206
frontend/ts/dashboard.ts
Normal file
|
|
@ -0,0 +1,206 @@
|
||||||
|
import type { LinkEntry } from "../../common/lib.ts";
|
||||||
|
import {
|
||||||
|
getLinks,
|
||||||
|
createLink,
|
||||||
|
updateLink,
|
||||||
|
deleteLink,
|
||||||
|
login
|
||||||
|
} from "./api.ts";
|
||||||
|
import { Toast } from "./toast.ts";
|
||||||
|
|
||||||
|
const toast = new Toast();
|
||||||
|
|
||||||
|
const linkList = document.getElementById("link-list")!;
|
||||||
|
const editor = document.getElementById("editor")!;
|
||||||
|
const form = document.getElementById("link-form") as HTMLFormElement;
|
||||||
|
const saveBtn = document.getElementById("save-btn") as HTMLButtonElement;
|
||||||
|
const statsEl = document.getElementById("stats")!;
|
||||||
|
const ogPreview = document.getElementById("og-preview")!;
|
||||||
|
|
||||||
|
let currentEditing: LinkEntry | null = null;
|
||||||
|
let tempIdCounter = 0;
|
||||||
|
|
||||||
|
async function renderList() {
|
||||||
|
linkList.innerHTML = "";
|
||||||
|
const res = await getLinks();
|
||||||
|
if (!res.success) {
|
||||||
|
toast.show(res.error || "Failed to fetch links.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.data?.data?.forEach((link) => {
|
||||||
|
const el = document.createElement("button");
|
||||||
|
el.className = "btn text-left flex justify-between items-center";
|
||||||
|
el.textContent = `${link.short || "(empty)"} → ${link.target || "(empty)"}`;
|
||||||
|
el.addEventListener("click", () => editLink(link));
|
||||||
|
linkList.appendChild(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editLink(link: LinkEntry) {
|
||||||
|
currentEditing = link;
|
||||||
|
editor.classList.remove("hidden");
|
||||||
|
|
||||||
|
(document.getElementById("link-target") as HTMLInputElement).value = link.target;
|
||||||
|
(document.getElementById("link-short") as HTMLInputElement).value = link.short;
|
||||||
|
(document.getElementById("og-title") as HTMLInputElement).value = link.ogTitle || "";
|
||||||
|
(document.getElementById("og-desc") as HTMLTextAreaElement).value = link.ogDesc || "";
|
||||||
|
(document.getElementById("og-image") as HTMLInputElement).value = link.ogImage || "";
|
||||||
|
|
||||||
|
updateStats();
|
||||||
|
updateSaveState();
|
||||||
|
updateOGPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSaveState() {
|
||||||
|
const end = (document.getElementById("link-target") as HTMLInputElement).value.trim();
|
||||||
|
const short = (document.getElementById("link-short") as HTMLInputElement).value.trim();
|
||||||
|
saveBtn.disabled = !(end && short);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateStats() {
|
||||||
|
if (!currentEditing) return;
|
||||||
|
|
||||||
|
statsEl.textContent = `Clicks: ${currentEditing?.clicks ?? 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("new-entry")!.addEventListener("click", () => {
|
||||||
|
const tempLink: LinkEntry = {
|
||||||
|
id: `temp-${tempIdCounter++}`,
|
||||||
|
target: "",
|
||||||
|
short: "",
|
||||||
|
clicks: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
editLink(tempLink);
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("input", () => {
|
||||||
|
updateSaveState();
|
||||||
|
updateOGPreview();
|
||||||
|
});
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!currentEditing) return;
|
||||||
|
|
||||||
|
const updatedLink: Partial<LinkEntry> = {
|
||||||
|
target: (document.getElementById("link-target") as HTMLInputElement).value,
|
||||||
|
short: (document.getElementById("link-short") as HTMLInputElement).value,
|
||||||
|
ogTitle: (document.getElementById("og-title") as HTMLInputElement).value,
|
||||||
|
ogDesc: (document.getElementById("og-desc") as HTMLTextAreaElement).value,
|
||||||
|
ogImage: (document.getElementById("og-image") as HTMLInputElement).value,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (currentEditing.id.startsWith("temp-")) {
|
||||||
|
const res = await createLink(updatedLink);
|
||||||
|
if (!res.success) {
|
||||||
|
toast.show(res.error || "Failed to create link.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentEditing = res.data?.data!;
|
||||||
|
} else {
|
||||||
|
const res = await updateLink(currentEditing.id, updatedLink);
|
||||||
|
if (!res.success) {
|
||||||
|
toast.show(res.error || "Failed to update link.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentEditing = res.data?.data!;
|
||||||
|
}
|
||||||
|
|
||||||
|
await renderList();
|
||||||
|
updateSaveState();
|
||||||
|
updateStats();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("delete-btn")!.addEventListener("click", async () => {
|
||||||
|
if (!currentEditing) return;
|
||||||
|
const res = await deleteLink(currentEditing.id);
|
||||||
|
if (!res.success) {
|
||||||
|
toast.show(res.error || "Failed to delete link.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentEditing = null;
|
||||||
|
editor.classList.add("hidden");
|
||||||
|
await renderList();
|
||||||
|
});
|
||||||
|
function updateOGPreview() {
|
||||||
|
if (!currentEditing) return;
|
||||||
|
|
||||||
|
const title = (document.getElementById("og-title") as HTMLInputElement).value.trim();
|
||||||
|
const desc = (document.getElementById("og-desc") as HTMLTextAreaElement).value.trim();
|
||||||
|
const img = (document.getElementById("og-image") as HTMLInputElement).value.trim();
|
||||||
|
|
||||||
|
const hasTitle = title.length > 0;
|
||||||
|
const hasDesc = desc.length > 0;
|
||||||
|
const hasText = hasTitle || hasDesc;
|
||||||
|
const hasImg = img.length > 0;
|
||||||
|
|
||||||
|
if (!hasTitle && !hasDesc && !hasImg) {
|
||||||
|
ogPreview.innerHTML = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasText && hasImg) {
|
||||||
|
ogPreview.innerHTML = `
|
||||||
|
<div class="w-80 h-40 border border-gray-600 rounded-lg overflow-hidden">
|
||||||
|
<img src="${img}" class="w-full h-full object-cover">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasImg && hasText) {
|
||||||
|
ogPreview.innerHTML = `
|
||||||
|
<div class="w-80 border border-gray-600 rounded-lg p-3 flex flex-col justify-center">
|
||||||
|
${
|
||||||
|
hasTitle ? `<h3 class="font-bold text-[var(--text)]">${title}</h3>` : ""
|
||||||
|
}
|
||||||
|
${
|
||||||
|
hasDesc ? `<p class="text-gray-400 text-sm mt-1">${desc}</p>` : ""
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ogPreview.innerHTML = `
|
||||||
|
<div class="flex border border-gray-600 rounded-lg overflow-hidden w-80">
|
||||||
|
<img src="${img}" alt="OG Image" class="w-24 h-24 object-cover">
|
||||||
|
|
||||||
|
<div class="p-2 flex flex-col ${
|
||||||
|
hasTitle && hasDesc
|
||||||
|
? "justify-center"
|
||||||
|
: hasTitle && !hasDesc
|
||||||
|
? "justify-center"
|
||||||
|
: !hasTitle && hasDesc
|
||||||
|
? "justify-start"
|
||||||
|
: "justify-center"
|
||||||
|
}">
|
||||||
|
|
||||||
|
${
|
||||||
|
hasTitle
|
||||||
|
? `<h3 class="font-bold text-[var(--text)]">${title}</h3>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
${
|
||||||
|
hasDesc
|
||||||
|
? `<p class="text-gray-400 text-sm mt-1">${desc}</p>`
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const res = await login(localStorage.password);
|
||||||
|
if (!res.success) {
|
||||||
|
toast.show(res.error || "Login failed.");
|
||||||
|
localStorage.removeItem("password");
|
||||||
|
location.href = "/";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await renderList();
|
||||||
|
})();
|
||||||
35
frontend/ts/login.ts
Normal file
35
frontend/ts/login.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import { login } from "./api";
|
||||||
|
|
||||||
|
const form = document.querySelector("form")!;
|
||||||
|
const passwordInput = document.getElementById("password") as HTMLInputElement;
|
||||||
|
|
||||||
|
const errorEl = document.createElement("p");
|
||||||
|
errorEl.className = "text-red-500 mt-2 text-sm hidden";
|
||||||
|
form.appendChild(errorEl);
|
||||||
|
|
||||||
|
form.addEventListener("submit", async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
errorEl.classList.add("hidden");
|
||||||
|
|
||||||
|
const password = passwordInput.value.trim();
|
||||||
|
if (!password) {
|
||||||
|
errorEl.textContent = "Please enter a password.";
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await login(password);
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
localStorage.setItem("password", password);
|
||||||
|
window.location.href = "/dashboard.html";
|
||||||
|
} else {
|
||||||
|
errorEl.textContent = res.error || "Invalid password.";
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errorEl.textContent = "Network error. Please try again.";
|
||||||
|
errorEl.classList.remove("hidden");
|
||||||
|
}
|
||||||
|
});
|
||||||
55
frontend/ts/toast.ts
Normal file
55
frontend/ts/toast.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
type ToastOptions = {
|
||||||
|
duration?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Toast {
|
||||||
|
private container: HTMLDivElement;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.container = document.createElement('div');
|
||||||
|
this.container.style.position = 'fixed';
|
||||||
|
this.container.style.top = '10px';
|
||||||
|
this.container.style.right = '10px';
|
||||||
|
this.container.style.display = 'flex';
|
||||||
|
this.container.style.flexDirection = 'column';
|
||||||
|
this.container.style.gap = '10px';
|
||||||
|
this.container.style.zIndex = '9999';
|
||||||
|
document.body.appendChild(this.container);
|
||||||
|
}
|
||||||
|
|
||||||
|
show(message: string, options?: ToastOptions) {
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.textContent = message;
|
||||||
|
|
||||||
|
Object.assign(toast.style, {
|
||||||
|
background: 'rgba(0,0,0,0.8)',
|
||||||
|
color: 'white',
|
||||||
|
padding: '10px 15px',
|
||||||
|
borderRadius: '5px',
|
||||||
|
fontFamily: 'sans-serif',
|
||||||
|
fontSize: '14px',
|
||||||
|
boxShadow: '0 2px 5px rgba(0,0,0,0.3)',
|
||||||
|
opacity: '0', // start invisible
|
||||||
|
transform: 'translateX(100%)', // slide in from right
|
||||||
|
transition: 'opacity 0.3s, transform 0.3s'
|
||||||
|
});
|
||||||
|
|
||||||
|
this.container.appendChild(toast);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
toast.style.opacity = '1';
|
||||||
|
toast.style.transform = 'translateX(0)';
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = options?.duration ?? 3000;
|
||||||
|
setTimeout(() => this.removeToast(toast), duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
private removeToast(toast: HTMLDivElement) {
|
||||||
|
toast.style.opacity = '0';
|
||||||
|
toast.style.transform = 'translateX(100%)';
|
||||||
|
toast.addEventListener('transitionend', () => {
|
||||||
|
toast.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
24
package.json
Normal file
24
package.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"name": "link_shortener",
|
||||||
|
"module": "index.ts",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/bun": "latest"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"typescript": "^5"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build-frontend:dev": "bun run frontend.ts",
|
||||||
|
"build-frontend:prod": "bun run frontend.ts prod",
|
||||||
|
"backend": "bun run backend/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.18",
|
||||||
|
"postcss": "^8.5.6",
|
||||||
|
"sssg": "git+https://git.sad.ovh/sophie/sssg#e183025a165f1c12b5a270710f994482f33fb9f5",
|
||||||
|
"tailwindcss": "^4.1.18",
|
||||||
|
"zod": "^4.1.13"
|
||||||
|
}
|
||||||
|
}
|
||||||
4
tailwind.config.js
Normal file
4
tailwind.config.js
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ["./website/**/*.{html,js,ts,svelte,md}"],
|
||||||
|
};
|
||||||
29
tsconfig.json
Normal file
29
tsconfig.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
// Environment setup & latest features
|
||||||
|
"lib": ["ESNext"],
|
||||||
|
"target": "ESNext",
|
||||||
|
"module": "Preserve",
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"allowJs": true,
|
||||||
|
|
||||||
|
// Bundler mode
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// Best practices
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
|
||||||
|
// Some stricter flags (disabled by default)
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noPropertyAccessFromIndexSignature": false
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue