feat: first commit

This commit is contained in:
sittichok Ouamsiri
2026-06-15 21:25:57 +07:00
commit 3395ab6dd3
88 changed files with 10034 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
/**
* Real backend client. Talks to the Go service at `/api/v1`.
*
* In production the SPA is served same-origin behind the gateway, so the
* default base of "" works. For cross-origin dev set VITE_API_BASE.
*
* The method surface mirrors the old mock so swapping was a one-import change —
* except auth, which is now a real OAuth/OIDC redirect (no password login), and
* PIN reveal, which the server intentionally cannot do (PINs are hashed).
*/
import type {
CreateLinkInput,
LinkPage,
OwnerStats,
ShortLink,
User,
} from "./types";
const BASE = import.meta.env.VITE_API_BASE ?? "";
const ROOT = `${BASE}/api/v1`;
export class ApiError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.status = status;
}
}
async function req<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(ROOT + path, {
credentials: "include",
headers: init?.body ? { "Content-Type": "application/json" } : undefined,
...init,
});
if (!res.ok) {
let message = res.statusText;
try {
const body = await res.json();
if (body?.error) message = body.error;
} catch {
/* non-json error */
}
throw new ApiError(message, res.status);
}
if (res.status === 204) return undefined as T;
return (await res.json()) as T;
}
export const api = {
// --- auth ---
async me(): Promise<User | null> {
try {
return await req<User>("/auth/me");
} catch (e) {
if (e instanceof ApiError && e.status === 401) return null;
throw e;
}
},
async providers(): Promise<string[]> {
try {
const r = await req<{ providers: string[] }>("/auth/providers");
return r.providers ?? [];
} catch {
return [];
}
},
/** Full-page navigation target that starts the OAuth/OIDC dance. */
loginUrl(provider: string): string {
return `${ROOT}/auth/${provider}/login`;
},
async logout(): Promise<void> {
await req<void>("/auth/logout", { method: "POST" });
},
/** Non-secret server settings (e.g. the short-link domain). */
async config(): Promise<{ shortDomain: string }> {
return req<{ shortDomain: string }>("/config");
},
// --- links ---
/** One page of the caller's links, optionally filtered by `q` — paginated and
* searched server-side so the client never downloads every link. */
async listLinks(params: {
page: number;
pageSize: number;
q?: string;
}): Promise<LinkPage> {
const sp = new URLSearchParams({
page: String(params.page),
pageSize: String(params.pageSize),
});
if (params.q) sp.set("q", params.q);
return req<LinkPage>(`/links?${sp.toString()}`);
},
/** Aggregate dashboard tiles, computed in the DB. */
async stats(): Promise<OwnerStats> {
return req<OwnerStats>("/links/stats");
},
async createLink(input: CreateLinkInput): Promise<ShortLink> {
return req<ShortLink>("/links", {
method: "POST",
body: JSON.stringify({
longUrl: input.longUrl,
mode: input.mode,
customAlias: input.customAlias,
pin: input.pin,
}),
});
},
async updateLink(
id: string,
patch: { longUrl: string },
): Promise<ShortLink> {
return req<ShortLink>(`/links/${id}`, {
method: "PATCH",
body: JSON.stringify({ longUrl: patch.longUrl }),
});
},
/** Set or replace a PIN; pass null to remove it. */
async setPin(id: string, pin: string | null): Promise<ShortLink> {
if (pin === null) {
return req<ShortLink>(`/links/${id}/pin`, { method: "DELETE" });
}
return req<ShortLink>(`/links/${id}/pin`, {
method: "PUT",
body: JSON.stringify({ pin }),
});
},
async deleteLink(id: string): Promise<void> {
await req<void>(`/links/${id}`, { method: "DELETE" });
},
};
+57
View File
@@ -0,0 +1,57 @@
// The short-link domain is configurable, not hardcoded. It defaults to the
// build-time env var and can be overridden at runtime from GET /api/v1/config
// (see setShortDomain, called on boot in main.tsx).
let shortDomain = import.meta.env.VITE_SHORT_DOMAIN || "snip.to";
export function getShortDomain(): string {
return shortDomain;
}
export function setShortDomain(domain: string): void {
if (domain) shortDomain = domain;
}
export function shortUrl(code: string): string {
return `${shortDomain}/${code}`;
}
export function fullShortUrl(code: string): string {
return `https://${shortUrl(code)}`;
}
export function compactNumber(n: number): string {
if (n < 1000) return String(n);
if (n < 1_000_000) return `${(n / 1000).toFixed(n % 1000 === 0 ? 0 : 1)}k`;
return `${(n / 1_000_000).toFixed(1)}M`;
}
export function relativeTime(iso: string): string {
const then = new Date(iso).getTime();
const diff = Date.now() - then;
const sec = Math.round(diff / 1000);
const min = Math.round(sec / 60);
const hr = Math.round(min / 60);
const day = Math.round(hr / 24);
if (sec < 45) return "just now";
if (min < 60) return `${min}m ago`;
if (hr < 24) return `${hr}h ago`;
if (day < 30) return `${day}d ago`;
return new Date(iso).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
});
}
export function weekdayLabel(iso: string): string {
return new Date(iso + "T00:00:00").toLocaleDateString(undefined, {
weekday: "short",
})[0];
}
export function prettyHost(url: string): string {
try {
return new URL(url).host.replace(/^www\./, "");
} catch {
return url;
}
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Short-code generation.
*
* Strategy (mirrors what the Go backend will do):
* - "random" → base62 encoding of a monotonically increasing counter. This
* yields the SHORTEST possible string that is still globally
* unique (no collision checks, no wasted length). A counter of
* N needs ceil(log62(N)) chars: 62^4 ≈ 14.7M, 62^5 ≈ 916M.
* To avoid leaking sequence/volume, the counter is passed
* through a reversible bit-scramble before encoding.
* - "memorable" → 3 short, easy words joined by hyphens.
* - "custom" → user supplied (validated, authenticated only).
*/
const BASE62 = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ";
export function encodeBase62(num: number): string {
if (num === 0) return BASE62[0];
let n = num;
let out = "";
while (n > 0) {
out = BASE62[n % 62] + out;
n = Math.floor(n / 62);
}
return out;
}
/**
* Reversible scramble so sequential counter values don't produce sequential
* codes. Knuth multiplicative hash over a 31-bit space with a coprime
* multiplier — keeps codes short while looking random.
*/
function scramble(id: number): number {
const PRIME = 2654435761; // 2^32 golden-ratio prime
const MOD = 0x7fffffff; // 2^31 - 1
return ((id + 1) * PRIME) % MOD;
}
export function codeFromCounter(counter: number): string {
// Bump the base so even the first link gets a pleasant 4-char code.
return encodeBase62(scramble(counter + 100_000));
}
const CUSTOM_RE = /^[a-zA-Z0-9_-]{3,32}$/;
export function validateCustomAlias(alias: string): string | null {
if (!alias) return "Pick an alias for your link.";
if (alias.length < 3) return "At least 3 characters.";
if (alias.length > 32) return "Keep it under 32 characters.";
if (!CUSTOM_RE.test(alias))
return "Only letters, numbers, hyphens and underscores.";
return null;
}
const URL_RE = /^https?:\/\/[^\s.]+\.[^\s]{2,}$/i;
export function normalizeUrl(input: string): string {
const trimmed = input.trim();
if (!trimmed) return "";
if (/^https?:\/\//i.test(trimmed)) return trimmed;
return `https://${trimmed}`;
}
export function isValidUrl(input: string): boolean {
return URL_RE.test(normalizeUrl(input));
}
+50
View File
@@ -0,0 +1,50 @@
export type UrlMode = "random" | "memorable" | "custom";
export interface DayCount {
/** ISO date (YYYY-MM-DD) */
date: string;
count: number;
}
export interface ShortLink {
id: string;
code: string;
longUrl: string;
mode: UrlMode;
/** Whether a PIN protects the destination. PIN itself is never returned by the API. */
hasPin: boolean;
createdAt: string;
totalClicks: number;
/** Click counts for the last 7 days, oldest → newest. */
last7Days: DayCount[];
/** Present only for links the current user owns. */
owned?: boolean;
}
export interface CreateLinkInput {
longUrl: string;
mode: UrlMode;
/** Required when mode === "custom". */
customAlias?: string;
/** 6-digit string, authenticated users only. */
pin?: string;
}
export interface User {
id: string;
email: string;
name: string;
}
export interface OwnerStats {
totalLinks: number;
totalClicks: number;
weekClicks: number;
}
export interface LinkPage {
items: ShortLink[];
total: number;
page: number;
pageSize: number;
}
+34
View File
@@ -0,0 +1,34 @@
/**
* Curated word pools for the "easy to remember" mode. Short, concrete,
* unambiguous words → e.g. "amber-otter-loop". 3 words from these pools gives
* ~30 × 38 × 30 ≈ 34k base combinations, expanded with a tiny numeric suffix
* on collision in the real backend.
*/
export const ADJECTIVES = [
"amber", "brave", "calm", "clever", "cosmic", "crisp", "dawn", "eager",
"fizzy", "gentle", "happy", "honey", "ivory", "jolly", "keen", "lucky",
"mellow", "noble", "olive", "plush", "quartz", "rapid", "sunny", "swift",
"teal", "tidal", "vivid", "warm", "zesty", "zen",
];
export const NOUNS = [
"otter", "falcon", "maple", "comet", "pixel", "harbor", "meadow", "ember",
"willow", "lantern", "pebble", "cactus", "marble", "puffin", "ledger",
"cobra", "violet", "thistle", "acorn", "domino", "compass", "raven",
"saffron", "juniper", "lotus", "mango", "narwhal", "orchid", "pelican",
"quokka", "robin", "sparrow", "topaz", "umbra", "walrus", "yarrow", "zephyr",
];
export const VERBS = [
"loop", "dash", "soar", "drift", "glide", "spark", "leap", "flow",
"zoom", "hop", "skip", "roam", "bounce", "swirl", "dive", "climb",
];
function pick<T>(arr: T[]): T {
return arr[Math.floor(Math.random() * arr.length)];
}
export function memorableSlug(): string {
return `${pick(ADJECTIVES)}-${pick(NOUNS)}-${pick(VERBS)}`;
}