mirror of
https://github.com/ThisTine/Snip.git
synced 2026-08-19 07:28:46 +07:00
feat: first commit
This commit is contained in:
+141
@@ -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" });
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)}`;
|
||||
}
|
||||
Reference in New Issue
Block a user