clone

a tiny url shortener with four-character slugs, backed by a kv store

visual update

Dami committed defd99a
commit
defd99a07daad034d62f509c08e35c293db016aa
parent
7eda4b232c394cadc953037d6807d620d3881714
author
Dami <dami.etoile@gmail.com>
1 file changed, +277 -66
Mmain.ts343++++++++++++++++++++++++------
M main.ts +277 -66
@@ -2,27 +2,55 @@ import { Application, Status } from "jsr:@oak/oak/";
22 import { Router } from "jsr:@oak/oak/router";33 44 const kv = await Deno.openKv();5+const slugPattern = /^[0-9a-f]{4}$/i;6+const maxSlugAttempts = 8;57 6-const router = new Router();7-router.get("/", (ctx) => {8- ctx.response.body = `<!DOCTYPE html>8+const escapeHtml = (value: string): string =>9+ value10+ .replaceAll("&", "&amp;")11+ .replaceAll("<", "&lt;")12+ .replaceAll(">", "&gt;")13+ .replaceAll('"', "&quot;")14+ .replaceAll("'", "&#39;");15+16+const renderHomePage = (shortURL?: string): string => {17+ const safeShortURL = shortURL ? escapeHtml(shortURL) : "";18+ const resultSection = shortURL19+ ? `20+ <section class="result-card" aria-live="polite">21+ <p class="result-label">Rune Ready</p>22+ <a href="${safeShortURL}" class="result-link">${safeShortURL}</a>23+ <button type="button" class="copy-button" data-copy="${safeShortURL}">Copy Portal</button>24+ </section>25+ `26+ : "";27+28+ return `<!DOCTYPE html>929 <html lang="en">1030 <head>1131 <meta charset="UTF-8">1232 <meta name="viewport" content="width=device-width, initial-scale=1.0">1333 <title>Hex As a Service</title>1434 <style>35+ @import url("https://fonts.googleapis.com/css2?family=Cinzel:wght@600;800&family=VT323&display=swap");36+1537 :root {16- --background-color: #121212;17- --form-background: #1e1e1e;18- --input-background: #2a2a2a;19- --button-background: #9f7aea;20- --button-hover-background: #805ad5;21- --text-color: #e2e8f0;22- --placeholder-color: #718096;23- --border-color: #4a5568;24- --focus-ring-color: rgba(159, 122, 234, 0.25);25- --shadow-color: rgba(0, 0, 0, 0.25);38+ --sky-top: #f3dfb6;39+ --sky-mid: #f0c980;40+ --sand-base: #d8a25f;41+ --sand-deep: #b87a39;42+ --card-bg: #f6e1b4;43+ --card-border: #7a4b20;44+ --ink: #4b290f;45+ --muted: #775029;46+ --input-bg: #f3d9ab;47+ --button-bg: #8d4f23;48+ --button-hover: #6f3d1b;49+ --rune-glow: #d0802a;50+ --focus-ring: rgba(208, 128, 42, 0.35);51+ --shadow-color: rgba(92, 50, 19, 0.3);52+ --success-bg: rgba(208, 128, 42, 0.18);53+ --success-border: #a75e25;2654 }2755 2856 * {
@@ -36,36 +64,75 @@ router.get("/", (ctx) => {
3664 }3765 3866 body {39- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol";40- background-color: var(--background-color);41- color: var(--text-color);67+ font-family: "VT323", monospace;68+ background:69+ radial-gradient(circle at 15% 18%, rgba(255, 250, 221, 0.75) 0 10%, transparent 11%),70+ linear-gradient(180deg, var(--sky-top) 0%, var(--sky-mid) 48%, var(--sand-base) 49%, var(--sand-deep) 100%);71+ color: var(--ink);4272 display: flex;4373 align-items: center;4474 justify-content: center;4575 padding: 1rem;76+ position: relative;77+ }78+79+ body::after {80+ content: "";81+ position: fixed;82+ inset: 0;83+ pointer-events: none;84+ background-image: repeating-linear-gradient(85+ 135deg,86+ rgba(122, 75, 32, 0.06) 0 4px,87+ transparent 4px 8px88+ );89+ opacity: 0.6;4690 }4791 4892 .form-container {4993 width: 100%;50- max-width: 500px;51- background-color: var(--form-background);94+ max-width: 560px;95+ background-color: var(--card-bg);5296 padding: 2.5rem;53- border-radius: 16px;54- border: 1px solid var(--border-color);55- box-shadow: 0 8px 32px var(--shadow-color);97+ border-radius: 0;98+ border: 4px solid var(--card-border);99+ box-shadow:100+ 0 0 0 4px rgba(241, 191, 112, 0.9),101+ 10px 10px 0 var(--card-border),102+ 0 14px 36px var(--shadow-color);56103 text-align: center;104+ position: relative;105+ z-index: 1;106+ }107+108+ .form-container::before {109+ content: "☀ FelineStateMachine";110+ position: absolute;111+ top: -0.9rem;112+ left: 50%;113+ transform: translateX(-50%);114+ background: var(--card-border);115+ color: #f7dfac;116+ padding: 0.2rem 0.8rem;117+ font-size: 1.1rem;118+ letter-spacing: 0.04em;57119 }58120 59121 h1 {60- font-size: 1.75rem;61- font-weight: 700;62- margin-bottom: 0.5rem;63- color: var(--button-background);122+ font-family: "Cinzel", serif;123+ font-size: clamp(1.7rem, 4vw, 2.25rem);124+ font-weight: 800;125+ margin: 0.75rem 0 0.5rem;126+ color: var(--card-border);127+ letter-spacing: 0.03em;128+ text-transform: uppercase;129+ text-shadow: 2px 2px 0 rgba(255, 236, 180, 0.75);64130 }65131 66132 p {67- margin-bottom: 2rem;68- color: var(--placeholder-color);133+ margin-bottom: 1.75rem;134+ color: var(--muted);135+ font-size: 1.25rem;69136 }70137 71138 form {
@@ -76,44 +143,53 @@ router.get("/", (ctx) => {
76143 77144 .url-input {78145 width: 100%;79- padding: 1rem;80- border: 1px solid var(--border-color);81- border-radius: 8px;82- background-color: var(--input-background);83- font-size: 1rem;84- color: var(--text-color);146+ padding: 0.95rem;147+ border: 3px solid var(--card-border);148+ border-radius: 0;149+ background-color: var(--input-bg);150+ font-size: 1.35rem;151+ color: var(--ink);85152 transition: border-color 0.2s ease, box-shadow 0.2s ease;153+ font-family: inherit;86154 }87155 88156 .url-input::placeholder {89- color: var(--placeholder-color);157+ color: var(--muted);90158 }91159 92160 .url-input:focus {93161 outline: none;94- border-color: var(--button-background);95- box-shadow: 0 0 0 4px var(--focus-ring-color);162+ border-color: var(--button-bg);163+ box-shadow: 0 0 0 4px var(--focus-ring);96164 }97165 98166 .submit-button {99167 width: 100%;100- padding: 1rem;101- border: none;102- border-radius: 8px;103- background-color: var(--button-background);104- color: white;105- font-size: 1rem;106- font-weight: 600;168+ padding: 0.9rem;169+ border: 3px solid #4d2a12;170+ border-radius: 0;171+ background:172+ linear-gradient(180deg, #a9632f 0%, var(--button-bg) 70%);173+ color: #f8e4bf;174+ font-size: 1.25rem;175+ font-weight: 700;176+ letter-spacing: 0.05em;177+ text-transform: uppercase;107178 cursor: pointer;108- transition: background-color 0.2s ease, transform 0.1s ease;179+ transition: filter 0.18s ease, transform 0.1s ease;180+ font-family: "Cinzel", serif;181+ box-shadow: 4px 4px 0 #4d2a12;109182 }110183 111184 .submit-button:hover {112- background-color: var(--button-hover-background);185+ filter: brightness(1.08);186+ background:187+ linear-gradient(180deg, #935123 0%, var(--button-hover) 70%);113188 }114189 115190 .submit-button:active {116191 transform: scale(0.98);192+ box-shadow: 2px 2px 0 #4d2a12;117193 }118194 119195 .submit-button {
@@ -123,29 +199,140 @@ router.get("/", (ctx) => {
123199 .url-input:not(:placeholder-shown) + .submit-button {124200 display: block;125201 }202+203+ .result-card {204+ margin-top: 1.5rem;205+ border: 3px solid var(--success-border);206+ background: var(--success-bg);207+ border-radius: 0;208+ padding: 1rem;209+ display: flex;210+ flex-direction: column;211+ gap: 0.75rem;212+ animation: rise-in 180ms ease-out;213+ box-shadow: inset 0 0 0 1px rgba(255, 231, 168, 0.8);214+ }215+216+ .result-label {217+ margin: 0;218+ color: var(--card-border);219+ font-size: 1rem;220+ font-weight: 700;221+ text-transform: uppercase;222+ letter-spacing: 0.06em;223+ }224+225+ .result-link {226+ color: var(--ink);227+ word-break: break-all;228+ font-weight: 600;229+ text-decoration: none;230+ font-size: 1.15rem;231+ }232+233+ .result-link:hover {234+ text-decoration: underline;235+ }236+237+ .copy-button {238+ border: 2px solid var(--success-border);239+ border-radius: 0;240+ background: #f7dfb2;241+ color: var(--ink);242+ font-size: 1.15rem;243+ font-weight: 600;244+ padding: 0.55rem 0.85rem;245+ cursor: pointer;246+ font-family: inherit;247+ }248+249+ .copy-button:hover {250+ border-color: var(--button-bg);251+ color: #321b0d;252+ background: #f5d69e;253+ }254+255+ @keyframes rise-in {256+ from {257+ opacity: 0;258+ transform: translateY(6px);259+ }260+ to {261+ opacity: 1;262+ transform: translateY(0);263+ }264+ }265+266+ @media (max-width: 560px) {267+ .form-container {268+ padding: 2.2rem 1rem 1.4rem;269+ box-shadow:270+ 0 0 0 4px rgba(241, 191, 112, 0.9),271+ 6px 6px 0 var(--card-border),272+ 0 10px 24px var(--shadow-color);273+ }274+275+ p {276+ font-size: 1.1rem;277+ }278+ }126279 </style>127280 </head>128281 <body>129282 130283 <div class="form-container">131- <h1>Meowdy, Mortal</h1>132- <p>Condense any URL into a 4-digit hex code.</p>284+ <h1>Hex as a Service</h1>285+ <p>Cast a travel rune for any link. Four glyphs, quick portal.</p>133286 134287 <form action="/new" method="post">135288 <input 289+ type="url"136290 name="url" 137291 class="url-input" 138- placeholder="Hex a URL" 292+ placeholder="https://your-quest.example" 293+ autocomplete="url"139294 required140295 >141- <button type="submit" class="submit-button">Hex It</button>296+ <button type="submit" class="submit-button">Carve Rune</button>142297 </form>298+ ${resultSection}143299 </div>144300 301+ <script>302+ const copyButton = document.querySelector(".copy-button");303+ if (copyButton) {304+ copyButton.addEventListener("click", async () => {305+ const shortURL = copyButton.getAttribute("data-copy");306+ if (!shortURL) return;307+308+ try {309+ await navigator.clipboard.writeText(shortURL);310+ copyButton.textContent = "Rune Copied";311+ setTimeout(() => {312+ copyButton.textContent = "Copy Portal";313+ }, 1200);314+ } catch {315+ copyButton.textContent = "Copy Failed";316+ setTimeout(() => {317+ copyButton.textContent = "Copy Portal";318+ }, 1200);319+ }320+ });321+ }322+ </script>323+145324 </body>146325 </html>326+`;327+};147328 148- `;329+const router = new Router();330+router.get("/", (ctx) => {331+ const hex = ctx.request.url.searchParams.get("hex");332+ const shortURL = hex && slugPattern.test(hex)333+ ? `${ctx.request.url.origin}/${hex.toLowerCase()}`334+ : undefined;335+ ctx.response.body = renderHomePage(shortURL);149336 });150337 151338 router.post("/new", async (ctx) => {
@@ -157,41 +344,65 @@ router.post("/new", async (ctx) => {
157344 const formData = await ctx.request.body.formData();158345 const longURL = formData.get("url");159346 if (!URL.canParse(longURL)) {160- console.error(`[UnprocessableEntity] ${Date.now()}`)347+ console.error(`[UnprocessableEntity] ${Date.now()}`);161348 ctx.response.status = Status.UnprocessableEntity;162- ctx.response.body = {error: "The hex fizzles on non-urls."};349+ ctx.response.body = { error: "The hex fizzles on non-urls." };163350 return;164351 }165- 166- const slug = crypto.randomUUID().substring(0, 4);352+353+ let slug: string | undefined;354+ for (let attempt = 0; attempt < maxSlugAttempts; attempt++) {355+ const candidate = crypto.randomUUID().substring(0, 4);356+ const existing = await kv.get(["urls", candidate]);357+ if (existing.value === null) {358+ slug = candidate;359+ break;360+ }361+ }362+363+ if (!slug) {364+ ctx.response.status = Status.ServiceUnavailable;365+ ctx.response.body = { error: "The hex grid is saturated. Try again." };366+ return;367+ }368+167369 await kv.set(["urls", slug], longURL);168- 370+169371 console.log(`[Created] ${slug.padStart(6)} ${Date.now()}`);170- 171- ctx.response.headers.set("Location", `/${slug}`);172- ctx.response.status = Status.Created;173- ctx.response.body = { URL: longURL, hex: slug };372+373+ const preferredType = ctx.request.accepts("json", "html");374+ if (preferredType === "json") {375+ ctx.response.headers.set("Location", `/${slug}`);376+ ctx.response.status = Status.Created;377+ ctx.response.body = { URL: longURL, hex: slug };378+ return;379+ }380+381+ ctx.response.headers.set("Location", `/?hex=${slug}`);382+ ctx.response.status = Status.SeeOther;174383 });175384 176385 router.get("/:slug", async (ctx) => {177386 const slug = ctx.params.slug;178- if (slug.length !== 4) {387+ if (slug.length !== 4 || !slugPattern.test(slug)) {179388 console.error(`[BadRequest] ${Date.now()}`);180389 ctx.response.status = Status.BadRequest;181- ctx.response.body = {error: "You ain't gotta do all that. Hexes that big don't exist!"};390+ ctx.response.body = {391+ error: "You ain't gotta do all that. Hexes that big don't exist!",392+ };182393 return;183394 }184- 395+185396 console.log(`[Requested] ${slug.padStart(6)} ${Date.now()}`);186- 397+187398 const entry = await kv.get(["urls", slug]);188399 if (entry.value === null) {189400 console.warn(`[NotFound] ${slug.padStart(6)} ${Date.now()}`);190401 ctx.response.status = Status.NotFound;191- ctx.response.body = {error: "Your URL is in another castle."};402+ ctx.response.body = { error: "Your URL is in another castle." };192403 return;193- };194- 404+ }405+195406 console.log(`[Found] ${slug.padStart(6)} ${Date.now()}`);196407 ctx.response.status = Status.MovedPermanently;197408 ctx.response.headers.set("Location", entry.value as string);