Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 9 additions & 26 deletions server/ClientApp/src/components/Dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,6 @@ interface HealthInfo {
models: Record<string, ModelStatus>;
}

interface AllModelSettings {
ocr: ModelEntry;
translate: ModelEntry;
inpaint: ModelEntry;
bubble: ModelEntry;
preferred_translation_engine: string;
}

interface ModelEntry {
repo: string;
dir: string;
enabled: boolean;
files: string;
}

// ── Sub-components ───────────────────────────────────────────────────────────

function StatusBadge(props: { ok: boolean | null; label: string }) {
Expand Down Expand Up @@ -98,6 +83,7 @@ export function Dashboard() {
onMount(fetchHealth);

function fetchHealth() {
setErr(null);
fetch("/health")
.then((r) => {
if (!r.ok) throw new Error(`HTTP ${r.status}`);
Expand Down Expand Up @@ -126,23 +112,20 @@ export function Dashboard() {
async function changeEngine(engine: string) {
const h = health();
if (!h || saving()) return;
setErr(null);
setSaving(true);
try {
// Fetch current settings to avoid overwriting other fields
const settingsRes = await fetch("/api/settings");
if (!settingsRes.ok) throw new Error("Failed to load settings");
const settings = (await settingsRes.json()) as AllModelSettings;
settings.preferred_translation_engine = engine;

const putRes = await fetch("/api/settings", {
method: "PUT",
// Narrow PATCH endpoint updates only preferred_translation_engine,
// avoiding a read-modify-write cycle that would persist env-derived values to disk.
const res = await fetch("/api/settings/engine", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
body: JSON.stringify({ engine }),
});
if (!putRes.ok) throw new Error("Failed to save settings");
if (!res.ok) throw new Error(`Failed to save settings (HTTP ${res.status})`);
setSavedEngine(engine);
} catch (e) {
console.error("Failed to update translation engine:", e);
setErr(String(e));
} finally {
setSaving(false);
}
Expand Down
104 changes: 64 additions & 40 deletions server/ClientApp/src/pages/JobsListPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,49 +55,52 @@ function JobCard(props: {
: jobOriginalUrl(props.job.id);

return (
<button
class="group flex flex-col overflow-hidden rounded-2xl bg-white text-left shadow-sm ring-1 ring-slate-200 transition hover:shadow-md hover:ring-slate-300 focus-visible:outline focus-visible:outline-2 focus-visible:outline-violet-500"
onClick={props.onClick}
>
{/* Thumbnail */}
<div class="relative aspect-3/4 w-full overflow-hidden bg-slate-100">
<img
src={thumbSrc()}
alt={props.job.title}
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
/>
<div class="absolute right-2 top-2">
<StatusBadge status={props.job.status} />
</div>
{/* Delete icon — shown on hover. Must be a div, not button, because it's nested inside the card button. */}
<div
role="button"
tabIndex={0}
class="absolute left-2 top-2 flex h-6 w-6 cursor-pointer items-center justify-center rounded-md bg-white/80 text-slate-500 opacity-0 shadow-sm ring-1 ring-slate-200 transition-opacity hover:bg-red-50 hover:text-red-600 group-hover:opacity-100"
title="Delete job"
aria-label="Delete job"
onClick={props.onDelete}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") props.onDelete(e as unknown as MouseEvent); }}
>
<Trash2 class="h-3.5 w-3.5" />
// Outer div acts as the group anchor; contains two sibling native buttons so
// neither is nested inside the other (avoids invalid interactive-in-interactive HTML).
<div class="group relative flex flex-col overflow-hidden rounded-2xl bg-white shadow-sm ring-1 ring-slate-200 transition hover:shadow-md hover:ring-slate-300">
{/* Delete button — sibling, not nested inside the nav button */}
<button
class="absolute left-2 top-2 z-10 flex h-6 w-6 items-center justify-center rounded-md bg-white/80 text-slate-500 opacity-0 shadow-sm ring-1 ring-slate-200 transition-opacity hover:bg-red-50 hover:text-red-600 focus-visible:opacity-100 group-hover:opacity-100"
title="Delete job"
aria-label="Delete job"
onClick={props.onDelete}
>
<Trash2 class="h-3.5 w-3.5" />
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.

{/* Nav button covers thumbnail + info */}
<button
class="flex w-full flex-col text-left focus-visible:outline-2 focus-visible:outline-violet-500"
onClick={props.onClick}
>
{/* Thumbnail */}
<div class="relative aspect-3/4 w-full overflow-hidden bg-slate-100">
<img
src={thumbSrc()}
alt={props.job.title}
class="h-full w-full object-cover transition-transform duration-200 group-hover:scale-105"
loading="lazy"
/>
<div class="absolute right-2 top-2">
<StatusBadge status={props.job.status} />
</div>
</div>
</div>

{/* Info */}
<div class="flex flex-col gap-1 p-3">
<p class="truncate text-sm font-medium text-slate-800">{props.job.title}</p>
<div class="flex items-center gap-3 text-xs text-slate-500">
<span class="flex items-center gap-1">
<Layers class="h-3 w-3" />
{props.job.bubbleCount} bubbles
</span>
<span class="ml-auto">
{new Date(props.job.createdAt).toLocaleDateString()}
</span>
{/* Info */}
<div class="flex flex-col gap-1 p-3">
<p class="truncate text-sm font-medium text-slate-800">{props.job.title}</p>
<div class="flex items-center gap-3 text-xs text-slate-500">
<span class="flex items-center gap-1">
<Layers class="h-3 w-3" />
{props.job.bubbleCount} bubbles
</span>
<span class="ml-auto">
{new Date(props.job.createdAt).toLocaleDateString()}
</span>
</div>
</div>
</div>
</button>
</button>
</div>
);
}

Expand Down Expand Up @@ -131,9 +134,11 @@ export function JobsListPage() {
// Delete confirm state
const [pendingDeleteId, setPendingDeleteId] = createSignal<string | null>(null);
const [isDeleting, setIsDeleting] = createSignal(false);
const [deleteError, setDeleteError] = createSignal<string | null>(null);

function requestDelete(id: string, e: MouseEvent): void {
e.stopPropagation();
setDeleteError(null);
setPendingDeleteId(id);
}

Expand All @@ -145,6 +150,9 @@ export function JobsListPage() {
await deleteJob(id);
setPendingDeleteId(null);
refetch();
} catch (err) {
setPendingDeleteId(null);
setDeleteError(err instanceof Error ? err.message : "Failed to delete job");
} finally {
setIsDeleting(false);
}
Expand Down Expand Up @@ -180,6 +188,22 @@ export function JobsListPage() {
</select>
</div>

{/* Delete error */}
<Show when={deleteError()}>
{(msg) => (
<div class="mb-4 flex items-center gap-2 rounded-xl bg-red-50 px-4 py-3 text-sm text-red-600 ring-1 ring-red-200">
<span class="flex-1">{msg()}</span>
<button
onClick={() => setDeleteError(null)}
class="shrink-0 rounded p-0.5 hover:bg-red-100"
aria-label="Dismiss error"
>
</button>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
</div>
)}
</Show>

{/* States */}
<Show when={jobs.loading}>
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
Expand Down
8 changes: 7 additions & 1 deletion server/src/ModelSettingsStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,13 @@ private static AllModelSettings Merge(AllModelSettings env, AllModelSettings fil
Translate = MergeEntry(env.Translate, file.Translate, "TRANSLATE_MODEL_REPO", "TRANSLATE_MODELS_DIR", "TRANSLATE_MODEL_ENABLED", "TRANSLATE_MODEL_FILES"),
Inpaint = MergeEntry(env.Inpaint, file.Inpaint, "INPAINT_MODEL_REPO", "INPAINT_MODELS_DIR", "INPAINT_MODEL_ENABLED", "INPAINT_MODEL_FILES"),
Bubble = MergeEntry(env.Bubble, file.Bubble, "BUBBLE_MODEL_REPO", "BUBBLE_MODELS_DIR", "BUBBLE_MODEL_ENABLED", "BUBBLE_MODEL_FILES"),
PreferredTranslationEngine = file.PreferredTranslationEngine,
// Env wins; fall back to file (only if non-empty), then the env-derived default.
PreferredTranslationEngine =
Environment.GetEnvironmentVariable("PREFERRED_TRANSLATION_ENGINE") is { Length: > 0 } pe
? pe
: string.IsNullOrWhiteSpace(file.PreferredTranslationEngine)
? env.PreferredTranslationEngine
: file.PreferredTranslationEngine,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
};
}

Expand Down
17 changes: 17 additions & 0 deletions server/src/Routes/SettingsRoutes.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
namespace WebOcrServer;

/// <summary>Narrow patch body for updating only the preferred translation engine.</summary>
public record EngineUpdateRequest(string Engine);

public static class SettingsRoutes
{
public static void MapSettingsRoutes(this WebApplication app)
Expand All @@ -18,5 +21,19 @@ public static void MapSettingsRoutes(this WebApplication app)
var saved = await store.UpdateAsync(updated);
return Results.Ok(saved);
});

// PATCH /api/settings/engine — updates only preferred_translation_engine without
// touching other settings, avoiding the read-modify-write footgun on env-derived values.
app.MapMethods("/api/settings/engine", ["PATCH"], async (
EngineUpdateRequest req, ModelSettingsStore store) =>
{
if (string.IsNullOrWhiteSpace(req.Engine))
return Results.BadRequest(new { error = "engine is required" });

var updated = store.Current;
updated.PreferredTranslationEngine = req.Engine;
var saved = await store.UpdateAsync(updated);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return Results.Ok(new { preferred_translation_engine = saved.PreferredTranslationEngine });
});
}
}
12 changes: 8 additions & 4 deletions server/src/Routes/TranslateRoutes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,17 @@ public static void MapTranslateRoutes(this WebApplication app)
IServiceScopeFactory scopeFactory,
ILogger<TranslateService> logger) =>
{
if (!boot.TranslateReady)
return Results.Json(new { error = "Translate model not ready" }, statusCode: 503);

if (string.IsNullOrWhiteSpace(req.Text))
return Results.BadRequest(new { error = "text is required" });

var engine = req.TranslateEngine ?? modelSettings.Current.PreferredTranslationEngine;
var engine = string.IsNullOrWhiteSpace(req.TranslateEngine)
? modelSettings.Current.PreferredTranslationEngine
: req.TranslateEngine;

// Only require the local model when the chosen engine is not an external API (DeepL).
var needsLocal = !engine.Equals("deepl", StringComparison.OrdinalIgnoreCase);
if (needsLocal && !boot.TranslateReady)
return Results.Json(new { error = "Translate model not ready" }, statusCode: 503);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

var tcs = new TaskCompletionSource<object>(TaskCreationOptions.RunContinuationsAsynchronously);
await queue.Writer.WriteAsync(new TranslateJob(req.Text, engine, tcs));
Expand Down
2 changes: 1 addition & 1 deletion server/wwwroot/js/app.css

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion server/wwwroot/js/app.js

Large diffs are not rendered by default.