- Claude Code as an engine: chats on a "claude-code/<alias>" model run through the claude CLI (stream-json); tool approvals go through our permission system. Sign in from the web UI (URL + pasted code), CLI version and update from Settings; the Docker image contains the CLI. - Small models (8k context) for side tasks: input is cut to the model's context window; one-line chat status, "What does this do?" on approvals, "Plan as automation" (job draft from a chat). - Mobile: top bar no longer widens the page, image viewer (zoom, swipe, mark up, use in message), jump to previous own message, message menu, time under every message. - Approvals: push actions (allow/reject), "always allow in this project" (never for dangerous commands) with revoke. - Per turn: changed files with diffs and rollback; project check command with a green/red badge and "let the agent fix it". - Colour tags for chats in the colours of the active theme. - Project defaults for new chats, project page (timeline), offline queue. Tested in a throwaway container with a mock model (8k small model): 26 API checks passed (permissions, diffs, checks, defaults, job draft, side-task budget, Claude Code status/login URL, unsigned-in chat). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
116 lines
3.8 KiB
TypeScript
116 lines
3.8 KiB
TypeScript
// Minimal i18n: dictionaries per language, `{name}` placeholders, persisted choice.
|
|
// To add a language: copy en.ts, translate the values, register it in `languages`.
|
|
// TypeScript enforces that every language defines every key of `en`.
|
|
|
|
import { useSyncExternalStore } from "react";
|
|
import { en, type Messages } from "./en";
|
|
import { de } from "./de";
|
|
|
|
export type Key = keyof typeof en;
|
|
|
|
export const languages: Record<string, { name: string; messages: Messages }> = {
|
|
en: { name: "English", messages: en },
|
|
de: { name: "Deutsch", messages: de },
|
|
};
|
|
|
|
const STORAGE_KEY = "codecraft.lang";
|
|
|
|
/** English is the default; other languages are chosen in the settings. */
|
|
function detect(): string {
|
|
const saved = localStorage.getItem(STORAGE_KEY);
|
|
return saved && languages[saved] ? saved : "en";
|
|
}
|
|
|
|
let current = detect();
|
|
document.documentElement.lang = current;
|
|
const listeners = new Set<() => void>();
|
|
|
|
export function onLangChange(fn: () => void) {
|
|
listeners.add(fn);
|
|
return () => void listeners.delete(fn);
|
|
}
|
|
|
|
export function getLang() {
|
|
return current;
|
|
}
|
|
|
|
export function setLang(lang: string) {
|
|
if (!languages[lang]) return;
|
|
current = lang;
|
|
localStorage.setItem(STORAGE_KEY, lang);
|
|
document.documentElement.lang = lang;
|
|
listeners.forEach((l) => l());
|
|
}
|
|
|
|
export function t(key: Key, vars?: Record<string, string | number>): string {
|
|
let s: string = languages[current].messages[key] ?? en[key] ?? key;
|
|
if (vars) {
|
|
for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v));
|
|
}
|
|
return s;
|
|
}
|
|
|
|
/** Re-render on language change. Returns `t` for convenience. */
|
|
export function useT() {
|
|
useSyncExternalStore(
|
|
(l) => {
|
|
listeners.add(l);
|
|
return () => listeners.delete(l);
|
|
},
|
|
() => current,
|
|
);
|
|
return t;
|
|
}
|
|
|
|
// --- locale-aware formatting -------------------------------------------------
|
|
|
|
export function fmtNumber(n: number, digits = 0) {
|
|
return new Intl.NumberFormat(current, { maximumFractionDigits: digits }).format(n);
|
|
}
|
|
|
|
export function fmtCompact(n: number) {
|
|
return new Intl.NumberFormat(current, { notation: "compact", maximumFractionDigits: 1 }).format(n);
|
|
}
|
|
|
|
/** Short date and time of a message: only the time for today. */
|
|
export function fmtMsgTime(ms: number) {
|
|
const d = new Date(ms);
|
|
const today = d.toDateString() === new Date().toDateString();
|
|
return new Intl.DateTimeFormat(current, today ? { timeStyle: "short" } : { dateStyle: "short", timeStyle: "short" }).format(d);
|
|
}
|
|
|
|
export function fmtCost(n: number, currency: string) {
|
|
const digits = n !== 0 && Math.abs(n) < 1 ? 4 : 2;
|
|
try {
|
|
return new Intl.NumberFormat(current, { style: "currency", currency, maximumFractionDigits: digits }).format(n);
|
|
} catch {
|
|
return `${n.toFixed(digits)} ${currency}`;
|
|
}
|
|
}
|
|
|
|
export function fmtPercent(n: number) {
|
|
return new Intl.NumberFormat(current, { style: "percent", maximumFractionDigits: 1 }).format(n);
|
|
}
|
|
|
|
export function fmtDate(ms: number, withTime = true) {
|
|
return new Intl.DateTimeFormat(current, withTime ? { dateStyle: "medium", timeStyle: "short" } : { dateStyle: "medium" }).format(ms);
|
|
}
|
|
|
|
export function fmtRelative(ms: number) {
|
|
const diff = (ms - Date.now()) / 1000;
|
|
const rtf = new Intl.RelativeTimeFormat(current, { numeric: "auto" });
|
|
const abs = Math.abs(diff);
|
|
if (abs < 60) return rtf.format(Math.round(diff), "second");
|
|
if (abs < 3600) return rtf.format(Math.round(diff / 60), "minute");
|
|
if (abs < 86400) return rtf.format(Math.round(diff / 3600), "hour");
|
|
return rtf.format(Math.round(diff / 86400), "day");
|
|
}
|
|
|
|
export function weekdayNames(): string[] {
|
|
// Index 0 = Sunday, matching SQLite's %w.
|
|
const base = new Date(Date.UTC(2024, 0, 7)); // a Sunday
|
|
return Array.from({ length: 7 }, (_, i) =>
|
|
new Intl.DateTimeFormat(current, { weekday: "short", timeZone: "UTC" }).format(new Date(base.getTime() + i * 86400000)),
|
|
);
|
|
}
|