first commit

This commit is contained in:
Soph :3 2025-12-12 21:12:47 +02:00
commit 672f0deb6a
18 changed files with 1274 additions and 0 deletions

36
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,5 @@
export default tseslint.config({
rules: {
"@typescript-eslint/no-explicit-any": "allow"
}
});

32
frontend.ts Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,4 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ["./website/**/*.{html,js,ts,svelte,md}"],
};

29
tsconfig.json Normal file
View 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
}
}