From 672f0deb6aedd7a62d4698fea29c90f498dcba19 Mon Sep 17 00:00:00 2001 From: yourfriendoss Date: Fri, 12 Dec 2025 21:12:47 +0200 Subject: [PATCH] first commit --- .gitignore | 36 +++++ README.md | 10 ++ backend/db.ts | 71 ++++++++++ backend/index.ts | 276 ++++++++++++++++++++++++++++++++++++++ bun.lock | 257 +++++++++++++++++++++++++++++++++++ common/lib.ts | 9 ++ eslint.config.mjs | 5 + frontend.ts | 32 +++++ frontend/assets/style.css | 58 ++++++++ frontend/dashboard.html | 60 +++++++++ frontend/index.html | 19 +++ frontend/ts/api.ts | 88 ++++++++++++ frontend/ts/dashboard.ts | 206 ++++++++++++++++++++++++++++ frontend/ts/login.ts | 35 +++++ frontend/ts/toast.ts | 55 ++++++++ package.json | 24 ++++ tailwind.config.js | 4 + tsconfig.json | 29 ++++ 18 files changed, 1274 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 backend/db.ts create mode 100644 backend/index.ts create mode 100644 bun.lock create mode 100644 common/lib.ts create mode 100644 eslint.config.mjs create mode 100644 frontend.ts create mode 100644 frontend/assets/style.css create mode 100644 frontend/dashboard.html create mode 100644 frontend/index.html create mode 100644 frontend/ts/api.ts create mode 100644 frontend/ts/dashboard.ts create mode 100644 frontend/ts/login.ts create mode 100644 frontend/ts/toast.ts create mode 100644 package.json create mode 100644 tailwind.config.js create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3bce1ef --- /dev/null +++ b/.gitignore @@ -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.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..d773901 --- /dev/null +++ b/README.md @@ -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" +``` diff --git a/backend/db.ts b/backend/db.ts new file mode 100644 index 0000000..3546972 --- /dev/null +++ b/backend/db.ts @@ -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 = ? +`); diff --git a/backend/index.ts b/backend/index.ts new file mode 100644 index 0000000..b257e80 --- /dev/null +++ b/backend/index.ts @@ -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 = ` + + + + + ${title} + + + + + + + + Redirecting to ${link.target} + + + `; + + 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); diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..ab7f0be --- /dev/null +++ b/bun.lock @@ -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=="], + } +} diff --git a/common/lib.ts b/common/lib.ts new file mode 100644 index 0000000..1652664 --- /dev/null +++ b/common/lib.ts @@ -0,0 +1,9 @@ +export interface LinkEntry { + id: string; + target: string; + short: string; + ogTitle?: string; + ogDesc?: string; + ogImage?: string; + clicks: number; +} diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..fc26d68 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,5 @@ +export default tseslint.config({ + rules: { + "@typescript-eslint/no-explicit-any": "allow" + } +}); diff --git a/frontend.ts b/frontend.ts new file mode 100644 index 0000000..1cdb484 --- /dev/null +++ b/frontend.ts @@ -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(); diff --git a/frontend/assets/style.css b/frontend/assets/style.css new file mode 100644 index 0000000..c873237 --- /dev/null +++ b/frontend/assets/style.css @@ -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; +} diff --git a/frontend/dashboard.html b/frontend/dashboard.html new file mode 100644 index 0000000..b70b34d --- /dev/null +++ b/frontend/dashboard.html @@ -0,0 +1,60 @@ + + + + + Dashboard + + + + + +
+ +
+ + + + diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..afae7da --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,19 @@ + + + + + Login + + + +
+

Login

+
+ + + +
+
+ + + diff --git a/frontend/ts/api.ts b/frontend/ts/api.ts new file mode 100644 index 0000000..22a1c8c --- /dev/null +++ b/frontend/ts/api.ts @@ -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 { + 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(url: string, options: RequestInit = {}): Promise> { + 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> { + localStorage.setItem("password", password); + const res = await request(`login`, { method: "POST" }); + return res; +} + +export async function logout(): Promise> { + localStorage.removeItem("password"); + return { success: true }; +} + +export async function getLinks(): Promise> { + return request("links"); +} + +export async function getLink(id: string): Promise> { + return request(`links/${id}`); +} + +export async function createLink(link: Partial): Promise> { + return request("links", { + method: "POST", + body: JSON.stringify(link), + headers: { "Content-Type": "application/json" }, + }); +} + +export async function updateLink(id: string, link: Partial): Promise> { + return request(`links/${id}`, { + method: "PUT", + body: JSON.stringify(link), + headers: { "Content-Type": "application/json" }, + }); +} + +export async function deleteLink(id: string): Promise> { + return request(`links/${id}`, { method: "DELETE" }); +} + +export async function getLinkStats(id: string): Promise> { + return request<{ clicks: number }>(`links/${id}/stats`); +} diff --git a/frontend/ts/dashboard.ts b/frontend/ts/dashboard.ts new file mode 100644 index 0000000..070e0c5 --- /dev/null +++ b/frontend/ts/dashboard.ts @@ -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 = { + 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 = ` +
+ +
+ `; + return; + } + + if (!hasImg && hasText) { + ogPreview.innerHTML = ` +
+ ${ + hasTitle ? `

${title}

` : "" + } + ${ + hasDesc ? `

${desc}

` : "" + } +
+ `; + return; + } + + ogPreview.innerHTML = ` +
+ OG Image + +
+ + ${ + hasTitle + ? `

${title}

` + : "" + } + ${ + hasDesc + ? `

${desc}

` + : "" + } +
+
+ `; +} + +(async () => { + const res = await login(localStorage.password); + if (!res.success) { + toast.show(res.error || "Login failed."); + localStorage.removeItem("password"); + location.href = "/"; + return; + } + await renderList(); +})(); diff --git a/frontend/ts/login.ts b/frontend/ts/login.ts new file mode 100644 index 0000000..5ae648a --- /dev/null +++ b/frontend/ts/login.ts @@ -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"); + } +}); diff --git a/frontend/ts/toast.ts b/frontend/ts/toast.ts new file mode 100644 index 0000000..617aee8 --- /dev/null +++ b/frontend/ts/toast.ts @@ -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(); + }); + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..ee25def --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/tailwind.config.js b/tailwind.config.js new file mode 100644 index 0000000..6ab65fd --- /dev/null +++ b/tailwind.config.js @@ -0,0 +1,4 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: ["./website/**/*.{html,js,ts,svelte,md}"], +}; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -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 + } +}