.
This commit is contained in:
@@ -138,7 +138,7 @@ export function AdminCenter() {
|
|||||||
key={t.id}
|
key={t.id}
|
||||||
onClick={() => setTab(t.id)}
|
onClick={() => setTab(t.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
|
"relative flex flex-1 items-center justify-center gap-2 whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium transition",
|
||||||
tab === t.id
|
tab === t.id
|
||||||
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
|
? "bg-white text-stone-900 shadow dark:bg-zinc-800 dark:text-zinc-100"
|
||||||
: "opacity-60 hover:opacity-100",
|
: "opacity-60 hover:opacity-100",
|
||||||
|
|||||||
@@ -155,7 +155,8 @@ function ModelRow({ model, onChanged, onError }: {
|
|||||||
<span className="block truncate text-sm font-medium">{model.label || model.id}</span>
|
<span className="block truncate text-sm font-medium">{model.label || model.id}</span>
|
||||||
<span className="block truncate font-mono text-xs opacity-50">{model.id}</span>
|
<span className="block truncate font-mono text-xs opacity-50">{model.id}</span>
|
||||||
</span>
|
</span>
|
||||||
<label className="flex items-center gap-1.5 text-xs opacity-80">
|
<label className="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-xs opacity-80">
|
||||||
|
<span className="w-24 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -169,11 +170,12 @@ function ModelRow({ model, onChanged, onError }: {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
}}
|
}}
|
||||||
className="w-24"
|
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
{t("models.hourly")}
|
{t("models.hourly")}
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-1.5 text-xs opacity-80">
|
<label className="flex shrink-0 items-center gap-1.5 whitespace-nowrap text-xs opacity-80">
|
||||||
|
<span className="w-24 shrink-0">
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
@@ -187,8 +189,8 @@ function ModelRow({ model, onChanged, onError }: {
|
|||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
|
||||||
}}
|
}}
|
||||||
className="w-24"
|
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
{t("models.daily")}
|
{t("models.daily")}
|
||||||
</label>
|
</label>
|
||||||
<Switch
|
<Switch
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
--ka-accent-700: 4 120 87;
|
--ka-accent-700: 4 120 87;
|
||||||
--ka-accent-800: 6 95 70;
|
--ka-accent-800: 6 95 70;
|
||||||
--ka-accent-900: 6 78 59;
|
--ka-accent-900: 6 78 59;
|
||||||
|
--ka-accent-950: 2 44 34;
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body, #root {
|
html, body, #root {
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Runtime accent color (settings → accent color). Predefined palettes plus a
|
* Runtime accent color (settings → accent color). Predefined palettes plus a
|
||||||
* custom picker; shades 50–900 are derived from a single base hex via HSL and
|
* custom picker; shades 50–950 are derived from a single base hex via HSL and
|
||||||
* written to the --ka-accent-* CSS variables that back Tailwind's `accent`
|
* written to the --ka-accent-* CSS variables that back Tailwind's `accent`
|
||||||
* palette. Persisted per-browser (localStorage), like the feature flags.
|
* palette. Persisted per-browser (localStorage), like the feature flags.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type AccentShades = Record<"50" | "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900", string>;
|
export type AccentShades = Record<"50" | "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900" | "950", string>;
|
||||||
|
|
||||||
export interface AccentPreset {
|
export interface AccentPreset {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,11 +25,11 @@ export const ACCENT_PRESETS: AccentPreset[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const STORAGE_KEY = "ka:accent";
|
const STORAGE_KEY = "ka:accent";
|
||||||
const SHADE_KEYS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900"] as const;
|
const SHADE_KEYS = ["50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950"] as const;
|
||||||
// Lightness ramps for 50→900 around a mid 500 (HSL L values in %).
|
// Lightness ramps for 50→950 around a mid 500 (HSL L values in %).
|
||||||
const RAMP: Record<(typeof SHADE_KEYS)[number], number> = {
|
const RAMP: Record<(typeof SHADE_KEYS)[number], number> = {
|
||||||
"50": 97, "100": 93, "200": 86, "300": 76, "400": 64,
|
"50": 97, "100": 93, "200": 86, "300": 76, "400": 64,
|
||||||
"500": 52, "600": 45, "700": 38, "800": 30, "900": 24,
|
"500": 52, "600": 45, "700": 38, "800": 30, "900": 24, "950": 15,
|
||||||
};
|
};
|
||||||
|
|
||||||
function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
function hexToHsl(hex: string): { h: number; s: number; l: number } {
|
||||||
@@ -66,7 +66,7 @@ function hslToRgbTriplet(h: number, s: number, l: number): string {
|
|||||||
return `${to(r)} ${to(g)} ${to(b)}`;
|
return `${to(r)} ${to(g)} ${to(b)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Build the 50–900 ramp from a base hex (HSL ramp, emerald-style). */
|
/** Build the 50–950 ramp from a base hex (HSL ramp, emerald-style). */
|
||||||
export function shadesFor(baseHex: string): AccentShades {
|
export function shadesFor(baseHex: string): AccentShades {
|
||||||
const { h, s } = hexToHsl(baseHex);
|
const { h, s } = hexToHsl(baseHex);
|
||||||
const out = {} as AccentShades;
|
const out = {} as AccentShades;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default {
|
|||||||
700: "rgb(var(--ka-accent-700) / <alpha-value>)",
|
700: "rgb(var(--ka-accent-700) / <alpha-value>)",
|
||||||
800: "rgb(var(--ka-accent-800) / <alpha-value>)",
|
800: "rgb(var(--ka-accent-800) / <alpha-value>)",
|
||||||
900: "rgb(var(--ka-accent-900) / <alpha-value>)",
|
900: "rgb(var(--ka-accent-900) / <alpha-value>)",
|
||||||
|
950: "rgb(var(--ka-accent-950) / <alpha-value>)",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
+13
-3
@@ -172,6 +172,16 @@ function cleanStr(v: unknown, max: number): string | null {
|
|||||||
return t;
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Optional free-text fields (report details, admin notes, decision reasons):
|
||||||
|
// missing or blank → "", otherwise length-checked. Without this, sending ""
|
||||||
|
// (which is what the frontend does when the field is left empty) would hit
|
||||||
|
// cleanStr's empty check and wrongly 400 with a "max N chars" error.
|
||||||
|
function cleanOptionalStr(v: unknown, max: number): string | null {
|
||||||
|
if (v === undefined) return "";
|
||||||
|
if (typeof v === "string" && v.trim() === "") return "";
|
||||||
|
return cleanStr(v, max);
|
||||||
|
}
|
||||||
|
|
||||||
function validTheme(v: unknown): v is string {
|
function validTheme(v: unknown): v is string {
|
||||||
return v === "auto" || v === "light" || v === "dark";
|
return v === "auto" || v === "light" || v === "dark";
|
||||||
}
|
}
|
||||||
@@ -962,7 +972,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
if (!reason || !(REPORT_REASONS as readonly string[]).includes(reason)) {
|
if (!reason || !(REPORT_REASONS as readonly string[]).includes(reason)) {
|
||||||
return json({ error: "reason must be copyright|gore|falseinfo|bug" }, 400);
|
return json({ error: "reason must be copyright|gore|falseinfo|bug" }, 400);
|
||||||
}
|
}
|
||||||
const details = body.details === undefined ? "" : cleanStr(body.details, 2000);
|
const details = cleanOptionalStr(body.details, 2000);
|
||||||
if (details === null) return json({ error: "details: max 2000 chars" }, 400);
|
if (details === null) return json({ error: "details: max 2000 chars" }, 400);
|
||||||
const clientContent = typeof body.content === "string" ? body.content.slice(0, 20000) : "";
|
const clientContent = typeof body.content === "string" ? body.content.slice(0, 20000) : "";
|
||||||
const clientPrompt = typeof body.prompt === "string" ? body.prompt.slice(0, 20000) : "";
|
const clientPrompt = typeof body.prompt === "string" ? body.prompt.slice(0, 20000) : "";
|
||||||
@@ -1022,7 +1032,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
if (!status || !(REPORT_STATUSES as readonly string[]).includes(status)) {
|
if (!status || !(REPORT_STATUSES as readonly string[]).includes(status)) {
|
||||||
return json({ error: "status must be open|reviewing|resolved|dismissed" }, 400);
|
return json({ error: "status must be open|reviewing|resolved|dismissed" }, 400);
|
||||||
}
|
}
|
||||||
const adminNote = body.adminNote === undefined ? "" : cleanStr(body.adminNote, 1000);
|
const adminNote = cleanOptionalStr(body.adminNote, 1000);
|
||||||
if (adminNote === null) return json({ error: "adminNote: max 1000 chars" }, 400);
|
if (adminNote === null) return json({ error: "adminNote: max 1000 chars" }, 400);
|
||||||
return json({ report: setReportStatus(target.id, status, adminNote) });
|
return json({ report: setReportStatus(target.id, status, adminNote) });
|
||||||
}
|
}
|
||||||
@@ -1097,7 +1107,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({
|
|||||||
if (status !== "pending" && status !== "reviewing" && status !== "accepted" && status !== "refused") {
|
if (status !== "pending" && status !== "reviewing" && status !== "accepted" && status !== "refused") {
|
||||||
return json({ error: "status must be pending|reviewing|accepted|refused" }, 400);
|
return json({ error: "status must be pending|reviewing|accepted|refused" }, 400);
|
||||||
}
|
}
|
||||||
const reason = body.reason === undefined ? "" : cleanStr(body.reason, 500);
|
const reason = cleanOptionalStr(body.reason, 500);
|
||||||
if (reason === null) return json({ error: "reason: max 500 chars" }, 400);
|
if (reason === null) return json({ error: "reason: max 500 chars" }, 400);
|
||||||
if ((target.status === "accepted" || target.status === "refused") && status !== target.status) {
|
if ((target.status === "accepted" || target.status === "refused") && status !== target.status) {
|
||||||
return json({ error: "ticket closed" }, 409);
|
return json({ error: "ticket closed" }, 409);
|
||||||
|
|||||||
Reference in New Issue
Block a user