This commit is contained in:
Justin Visser 2026-02-17 20:24:12 +01:00
parent efd21a7297
commit 441676be27
97 changed files with 10764 additions and 223 deletions

View file

@ -0,0 +1,199 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import AppPage from "@/components/layout/AppPage.vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppInput from "@/components/ui/AppInput.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
import AppTable from "@/components/ui/AppTable.vue";
import {
createAdminUser,
listAdminUsers,
setAdminUserActive,
type AdminUser,
} from "@/features/admin_users";
import { ApiRequestError } from "@/lib/api/errors";
const loading = ref(true);
const creating = ref(false);
const togglingUserId = ref<number | null>(null);
const users = ref<AdminUser[]>([]);
const email = ref("");
const password = ref("");
const createIsAdmin = ref(false);
const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null);
function formatDate(value: string): string {
const asDate = new Date(value);
if (Number.isNaN(asDate.getTime())) {
return value;
}
return asDate.toLocaleString();
}
async function loadUsers(): Promise<void> {
loading.value = true;
errorMessage.value = null;
errorRequestId.value = null;
try {
users.value = await listAdminUsers();
} catch (error) {
users.value = [];
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to load users.";
}
} finally {
loading.value = false;
}
}
async function onCreateUser(): Promise<void> {
creating.value = true;
successMessage.value = null;
errorMessage.value = null;
errorRequestId.value = null;
try {
if (!email.value.trim() || !password.value) {
throw new Error("Email and password are required.");
}
const created = await createAdminUser({
email: email.value.trim(),
password: password.value,
is_admin: createIsAdmin.value,
});
email.value = "";
password.value = "";
createIsAdmin.value = false;
successMessage.value = `User created: ${created.email}`;
await loadUsers();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else if (error instanceof Error) {
errorMessage.value = error.message;
} else {
errorMessage.value = "Unable to create user.";
}
} finally {
creating.value = false;
}
}
async function onToggleUser(user: AdminUser): Promise<void> {
togglingUserId.value = user.id;
successMessage.value = null;
try {
const updated = await setAdminUserActive(user.id, !user.is_active);
successMessage.value = `${updated.email} is now ${updated.is_active ? "active" : "inactive"}.`;
await loadUsers();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to update user.";
}
} finally {
togglingUserId.value = null;
}
}
onMounted(() => {
void loadUsers();
});
</script>
<template>
<AppPage title="Admin Users" subtitle="Create and manage user access for this instance.">
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null">
<template #title>Operation complete</template>
<p>{{ successMessage }}</p>
</AppAlert>
<AppAlert v-if="errorMessage" tone="danger">
<template #title>User management request failed</template>
<p>{{ errorMessage }}</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<section class="grid gap-4 lg:grid-cols-2">
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Create User</h2>
<form class="grid gap-3" @submit.prevent="onCreateUser">
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>Email</span>
<AppInput id="admin-user-email" v-model="email" type="email" autocomplete="off" />
</label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>Password</span>
<AppInput id="admin-user-password" v-model="password" type="password" autocomplete="new-password" />
</label>
<AppCheckbox id="admin-user-is-admin" v-model="createIsAdmin" label="Grant admin privileges" />
<AppButton type="submit" :disabled="creating">
{{ creating ? "Creating..." : "Create user" }}
</AppButton>
</form>
</AppCard>
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Users</h2>
<AppSkeleton v-if="loading" :lines="5" />
<AppEmptyState
v-else-if="users.length === 0"
title="No users available"
body="Create an account to begin assigning access."
/>
<AppTable v-else label="Admin users table">
<thead>
<tr>
<th scope="col">Email</th>
<th scope="col">Role</th>
<th scope="col">Active</th>
<th scope="col">Updated</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="user in users" :key="user.id">
<td>{{ user.email }}</td>
<td>{{ user.is_admin ? "Admin" : "User" }}</td>
<td>{{ user.is_active ? "Yes" : "No" }}</td>
<td>{{ formatDate(user.updated_at) }}</td>
<td>
<AppButton
variant="secondary"
:disabled="togglingUserId === user.id"
@click="onToggleUser(user)"
>
{{ user.is_active ? "Deactivate" : "Activate" }}
</AppButton>
</td>
</tr>
</tbody>
</AppTable>
</AppCard>
</section>
</AppPage>
</template>

View file

@ -0,0 +1,187 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard";
import { ApiRequestError } from "@/lib/api/errors";
import AppPage from "@/components/layout/AppPage.vue";
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
import { useAuthStore } from "@/stores/auth";
const loading = ref(true);
const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null);
const snapshot = ref<DashboardSnapshot | null>(null);
const auth = useAuthStore();
function formatDate(value: string | null): string {
if (!value) {
return "n/a";
}
const asDate = new Date(value);
if (Number.isNaN(asDate.getTime())) {
return value;
}
return asDate.toLocaleString();
}
async function loadSnapshot(): Promise<void> {
loading.value = true;
errorMessage.value = null;
errorRequestId.value = null;
try {
snapshot.value = await fetchDashboardSnapshot();
} catch (error) {
snapshot.value = null;
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to load dashboard data.";
}
} finally {
loading.value = false;
}
}
onMounted(() => {
void loadSnapshot();
});
</script>
<template>
<AppPage title="Dashboard" subtitle="What changed since the latest run.">
<div class="flex flex-wrap items-center justify-between gap-3">
<AppButton variant="secondary" @click="loadSnapshot" :disabled="loading">
{{ loading ? "Refreshing..." : "Refresh" }}
</AppButton>
<QueueHealthBadge
v-if="snapshot"
:queued="snapshot.queue.queued"
:retrying="snapshot.queue.retrying"
:dropped="snapshot.queue.dropped"
/>
</div>
<AppAlert v-if="errorMessage" tone="danger">
<template #title>Dashboard request failed</template>
<p>{{ errorMessage }}</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<AppSkeleton v-if="loading" :lines="6" />
<template v-else-if="snapshot">
<section class="grid gap-4 xl:grid-cols-[minmax(0,1fr)_22rem]">
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Snapshot Summary</h2>
<p class="text-sm text-secondary">Latest view mode: {{ snapshot.mode }}</p>
<ul class="grid gap-2 text-sm text-zinc-700 dark:text-zinc-300">
<li class="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60">
Latest run introduced
<span class="font-semibold text-zinc-900 dark:text-zinc-100">{{ snapshot.newCount }}</span>
publications.
</li>
<li class="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60">
Total tracked publications:
<span class="font-semibold text-zinc-900 dark:text-zinc-100">{{ snapshot.totalCount }}</span>
</li>
</ul>
</AppCard>
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Latest Run</h2>
<div v-if="snapshot.latestRun" class="space-y-2 text-sm">
<div class="flex flex-wrap items-center justify-between gap-2">
<RunStatusBadge :status="snapshot.latestRun.status" />
<RouterLink
v-if="auth.isAdmin"
:to="`/admin/runs/${snapshot.latestRun.id}`"
class="link-inline"
>
Run #{{ snapshot.latestRun.id }}
</RouterLink>
<span v-else class="text-secondary">Run #{{ snapshot.latestRun.id }}</span>
</div>
<p class="text-secondary">Started: {{ formatDate(snapshot.latestRun.start_dt) }}</p>
<p class="text-muted">
{{ snapshot.latestRun.scholar_count }} scholars, {{ snapshot.latestRun.new_publication_count }} new
publications
</p>
</div>
<AppEmptyState
v-else
title="No runs recorded"
body="Trigger a manual run from the Runs screen to begin tracking."
/>
</AppCard>
</section>
<section class="grid gap-4 xl:grid-cols-2">
<AppCard class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Recent Publications</h2>
<RouterLink
to="/publications"
class="link-inline text-sm"
>
Open publications
</RouterLink>
</div>
<AppEmptyState
v-if="snapshot.recentPublications.length === 0"
title="No new publications"
body="When a completed run discovers updates, they will appear here."
/>
<ul v-else class="grid gap-3">
<li
v-for="item in snapshot.recentPublications.slice(0, 6)"
:key="item.publication_id"
class="grid gap-1 rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60"
>
<strong class="text-zinc-900 dark:text-zinc-100">{{ item.title }}</strong>
<span class="text-secondary">{{ item.scholar_label }}</span>
</li>
</ul>
</AppCard>
<AppCard class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Recent Runs</h2>
<RouterLink
v-if="auth.isAdmin"
to="/admin/runs"
class="link-inline text-sm"
>
Open runs
</RouterLink>
</div>
<AppEmptyState
v-if="snapshot.recentRuns.length === 0"
title="No runs yet"
body="Trigger a manual run from the Runs screen to begin tracking."
/>
<ul v-else class="grid gap-3">
<li
v-for="run in snapshot.recentRuns"
:key="run.id"
class="grid gap-1 rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60"
>
<div class="flex items-center gap-2">
<RunStatusBadge :status="run.status" />
<span class="text-secondary">Run #{{ run.id }}</span>
</div>
<span class="text-secondary">{{ formatDate(run.start_dt) }}</span>
</li>
</ul>
</AppCard>
</section>
</template>
</AppPage>
</template>

View file

@ -0,0 +1,129 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import { useRouter } from "vue-router";
import AppAlert from "@/components/ui/AppAlert.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppInput from "@/components/ui/AppInput.vue";
import { ApiRequestError } from "@/lib/api/errors";
import { useAuthStore } from "@/stores/auth";
const router = useRouter();
const auth = useAuthStore();
const email = ref("");
const password = ref("");
const pending = ref(false);
const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null);
const retryAfterSeconds = ref<number | null>(null);
const canSubmit = computed(
() => !pending.value && email.value.trim().length > 0 && password.value.length > 0,
);
async function onSubmit(): Promise<void> {
if (!canSubmit.value) {
return;
}
pending.value = true;
errorMessage.value = null;
errorRequestId.value = null;
retryAfterSeconds.value = null;
try {
await auth.login(email.value.trim(), password.value);
await router.replace({ name: "dashboard" });
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
const details = error.details as { retry_after_seconds?: unknown } | null;
if (typeof details?.retry_after_seconds === "number") {
retryAfterSeconds.value = details.retry_after_seconds;
}
} else {
errorMessage.value = "Unable to sign in. Please try again.";
}
} finally {
pending.value = false;
}
}
</script>
<template>
<div class="relative min-h-screen overflow-hidden bg-zinc-100 dark:bg-zinc-950">
<div class="pointer-events-none absolute inset-0">
<div class="absolute -top-20 left-[-8rem] h-72 w-72 rounded-full bg-brand-300/35 blur-3xl dark:bg-brand-900/35" />
<div class="absolute bottom-[-7rem] right-[-6rem] h-80 w-80 rounded-full bg-emerald-300/25 blur-3xl dark:bg-emerald-900/25" />
<div class="absolute left-1/3 top-1/3 h-52 w-52 rounded-full bg-amber-300/20 blur-3xl dark:bg-amber-900/20" />
</div>
<div
class="relative mx-auto grid min-h-screen w-full max-w-6xl items-center gap-8 px-4 py-10 sm:px-6 lg:grid-cols-[minmax(0,1fr)_26rem] lg:px-8"
>
<section class="hidden space-y-5 lg:block">
<p class="inline-flex items-center rounded-full border border-zinc-300 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.12em] text-zinc-600 dark:border-zinc-700 dark:bg-zinc-900/70 dark:text-zinc-300">
Scholarr Control Center
</p>
<h1 class="max-w-xl font-display text-4xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">
Scholar tracking with reliable operational controls.
</h1>
<p class="max-w-xl text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
Use your account to review ingestion runs, publication changes, and continuation queue diagnostics from a
single workspace.
</p>
<ul class="grid max-w-xl gap-2 text-sm text-zinc-600 dark:text-zinc-400">
<li class="rounded-lg border border-zinc-200 bg-white/60 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60">
Session-based authentication with CSRF enforcement.
</li>
<li class="rounded-lg border border-zinc-200 bg-white/60 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60">
Run and queue diagnostics are available for support workflows.
</li>
<li class="rounded-lg border border-zinc-200 bg-white/60 px-3 py-2 dark:border-zinc-800 dark:bg-zinc-900/60">
Theme-aware interface with light and dark mode support.
</li>
</ul>
</section>
<AppCard class="w-full max-w-md justify-self-end space-y-6 border-zinc-200/80 bg-white/90 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/85">
<div class="space-y-2">
<h1 class="font-display text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-100">Sign In</h1>
<p class="text-sm text-secondary">
Sign in to access scholar tracking, publication updates, and run diagnostics.
</p>
</div>
<AppAlert v-if="errorMessage" tone="danger">
<template #title>Login failed</template>
<p>{{ errorMessage }}</p>
<p v-if="retryAfterSeconds !== null" class="text-secondary">Retry after {{ retryAfterSeconds }} seconds.</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<form class="grid gap-4" @submit.prevent="onSubmit">
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>Email</span>
<AppInput id="login-email" v-model="email" type="email" autocomplete="email" />
</label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>Password</span>
<AppInput
id="login-password"
v-model="password"
type="password"
autocomplete="current-password"
/>
</label>
<AppButton type="submit" :disabled="!canSubmit" class="w-full justify-center">
{{ pending ? "Signing in..." : "Sign in" }}
</AppButton>
</form>
</AppCard>
</div>
</div>
</template>

View file

@ -0,0 +1,449 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import AppPage from "@/components/layout/AppPage.vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import AppBadge from "@/components/ui/AppBadge.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppInput from "@/components/ui/AppInput.vue";
import AppSelect from "@/components/ui/AppSelect.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
import AppTable from "@/components/ui/AppTable.vue";
import {
listPublications,
markAllRead,
markSelectedRead,
type PublicationItem,
type PublicationMode,
type PublicationsResult,
} from "@/features/publications";
import { listScholars, type ScholarProfile } from "@/features/scholars";
import { ApiRequestError } from "@/lib/api/errors";
const loading = ref(true);
const publishingAll = ref(false);
const publishingSelected = ref(false);
const mode = ref<PublicationMode>("new");
const selectedScholarFilter = ref("");
const searchQuery = ref("");
const scholars = ref<ScholarProfile[]>([]);
const listState = ref<PublicationsResult | null>(null);
const selectedPublicationKeys = ref<Set<string>>(new Set());
const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const route = useRoute();
const router = useRouter();
function normalizeScholarFilterQuery(value: unknown): string {
if (Array.isArray(value)) {
return normalizeScholarFilterQuery(value[0]);
}
if (typeof value !== "string") {
return "";
}
const parsed = Number(value);
return Number.isInteger(parsed) && parsed > 0 ? String(parsed) : "";
}
function syncScholarFilterFromRoute(): boolean {
const nextValue = normalizeScholarFilterQuery(route.query.scholar);
if (selectedScholarFilter.value === nextValue) {
return false;
}
selectedScholarFilter.value = nextValue;
return true;
}
async function syncScholarFilterToRoute(): Promise<void> {
const nextValue = selectedScholarFilter.value.trim();
const currentValue = normalizeScholarFilterQuery(route.query.scholar);
if (nextValue === currentValue) {
return;
}
const nextQuery = {
...route.query,
scholar: nextValue || undefined,
};
await router.replace({ query: nextQuery });
}
function formatDate(value: string): string {
const asDate = new Date(value);
if (Number.isNaN(asDate.getTime())) {
return value;
}
return asDate.toLocaleDateString();
}
function publicationKey(item: PublicationItem): string {
return `${item.scholar_profile_id}:${item.publication_id}`;
}
const filteredPublications = computed(() => {
const base = listState.value?.publications ?? [];
const normalized = searchQuery.value.trim().toLowerCase();
if (!normalized) {
return base;
}
return base.filter((item) => {
const year = item.year === null ? "" : String(item.year);
return [item.title, item.scholar_label, item.venue_text || "", year]
.join(" ")
.toLowerCase()
.includes(normalized);
});
});
const visibleUnreadKeys = computed(() => {
const keys = new Set<string>();
for (const item of filteredPublications.value) {
if (!item.is_read) {
keys.add(publicationKey(item));
}
}
return keys;
});
const selectedCount = computed(() => selectedPublicationKeys.value.size);
const allVisibleUnreadSelected = computed(() => {
if (visibleUnreadKeys.value.size === 0) {
return false;
}
for (const key of visibleUnreadKeys.value) {
if (!selectedPublicationKeys.value.has(key)) {
return false;
}
}
return true;
});
watch(filteredPublications, (items) => {
const validKeys = new Set(items.filter((item) => !item.is_read).map((item) => publicationKey(item)));
const next = new Set<string>();
for (const key of selectedPublicationKeys.value) {
if (validKeys.has(key)) {
next.add(key);
}
}
if (next.size !== selectedPublicationKeys.value.size) {
selectedPublicationKeys.value = next;
}
});
async function loadScholarFilters(): Promise<void> {
try {
scholars.value = await listScholars();
} catch {
scholars.value = [];
}
}
function selectedScholarId(): number | undefined {
const parsed = Number(selectedScholarFilter.value);
return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined;
}
async function loadPublications(): Promise<void> {
loading.value = true;
errorMessage.value = null;
errorRequestId.value = null;
try {
listState.value = await listPublications({
mode: mode.value,
scholarProfileId: selectedScholarId(),
limit: 400,
});
selectedPublicationKeys.value = new Set();
} catch (error) {
listState.value = null;
selectedPublicationKeys.value = new Set();
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to load publications.";
}
} finally {
loading.value = false;
}
}
async function setMode(nextMode: PublicationMode): Promise<void> {
if (mode.value === nextMode) {
return;
}
mode.value = nextMode;
await loadPublications();
}
async function onScholarFilterChanged(): Promise<void> {
await syncScholarFilterToRoute();
await loadPublications();
}
function onToggleAllVisible(event: Event): void {
const checked = (event.target as HTMLInputElement).checked;
const next = new Set(selectedPublicationKeys.value);
for (const key of visibleUnreadKeys.value) {
if (checked) {
next.add(key);
} else {
next.delete(key);
}
}
selectedPublicationKeys.value = next;
}
function onToggleRowSelection(item: PublicationItem, event: Event): void {
const checked = (event.target as HTMLInputElement).checked;
const key = publicationKey(item);
const next = new Set(selectedPublicationKeys.value);
if (checked) {
next.add(key);
} else {
next.delete(key);
}
selectedPublicationKeys.value = next;
}
async function onMarkSelectedRead(): Promise<void> {
if (selectedPublicationKeys.value.size === 0 || !listState.value) {
return;
}
const selectedLookup = new Set(selectedPublicationKeys.value);
const selections = listState.value.publications
.filter((item) => selectedLookup.has(publicationKey(item)))
.map((item) => ({
scholar_profile_id: item.scholar_profile_id,
publication_id: item.publication_id,
}));
publishingSelected.value = true;
successMessage.value = null;
errorMessage.value = null;
errorRequestId.value = null;
try {
const response = await markSelectedRead(selections);
successMessage.value = `${response.updated_count} publication${response.updated_count === 1 ? "" : "s"} marked as read.`;
await loadPublications();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to mark selected publications as read.";
}
} finally {
publishingSelected.value = false;
}
}
async function onMarkAllRead(): Promise<void> {
publishingAll.value = true;
successMessage.value = null;
errorMessage.value = null;
errorRequestId.value = null;
try {
const response = await markAllRead();
successMessage.value = response.message;
await loadPublications();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to mark publications as read.";
}
} finally {
publishingAll.value = false;
}
}
onMounted(() => {
syncScholarFilterFromRoute();
void Promise.all([loadScholarFilters(), loadPublications()]);
});
watch(
() => route.query.scholar,
async () => {
const changed = syncScholarFilterFromRoute();
if (!changed) {
return;
}
await loadPublications();
},
);
</script>
<template>
<AppPage title="Publications" subtitle="Filter, search, and triage publication status quickly.">
<div class="grid gap-3 xl:grid-cols-[minmax(0,1fr)_auto] xl:items-end">
<div class="grid gap-3 md:grid-cols-[auto_auto_minmax(0,1fr)] md:items-end">
<div class="grid gap-1 text-xs text-secondary">
<span>Scope</span>
<div class="flex min-h-10 flex-wrap items-center gap-2">
<AppButton :variant="mode === 'new' ? 'primary' : 'secondary'" @click="setMode('new')">
New
</AppButton>
<AppButton :variant="mode === 'all' ? 'primary' : 'secondary'" @click="setMode('all')">
All
</AppButton>
</div>
</div>
<label class="grid gap-1 text-xs text-secondary" for="publications-scholar-filter">
<span>Scholar</span>
<AppSelect
id="publications-scholar-filter"
v-model="selectedScholarFilter"
:disabled="loading || publishingAll || publishingSelected"
@change="onScholarFilterChanged"
>
<option value="">All scholars</option>
<option v-for="scholar in scholars" :key="scholar.id" :value="String(scholar.id)">
{{ scholar.display_name || scholar.scholar_id }}
</option>
</AppSelect>
</label>
<label class="grid gap-1 text-xs text-secondary" for="publications-search-input">
<span>Search</span>
<AppInput
id="publications-search-input"
v-model="searchQuery"
placeholder="Search title, scholar, venue, year"
:disabled="loading"
/>
</label>
</div>
<div class="flex flex-wrap items-center gap-2">
<AppButton
variant="secondary"
:disabled="selectedCount === 0 || loading || publishingSelected || publishingAll"
@click="onMarkSelectedRead"
>
{{ publishingSelected ? "Marking..." : `Mark selected read (${selectedCount})` }}
</AppButton>
<AppButton variant="secondary" :disabled="publishingAll || loading || publishingSelected" @click="onMarkAllRead">
{{ publishingAll ? "Marking..." : "Mark all unread as read" }}
</AppButton>
<AppButton variant="ghost" :disabled="loading" @click="loadPublications">
{{ loading ? "Refreshing..." : "Refresh" }}
</AppButton>
</div>
</div>
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null">
<template #title>Update complete</template>
<p>{{ successMessage }}</p>
</AppAlert>
<AppAlert v-if="errorMessage" tone="danger">
<template #title>Publication request failed</template>
<p>{{ errorMessage }}</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<AppSkeleton v-if="loading" :lines="8" />
<template v-else-if="listState">
<AppCard class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Publication list</h2>
<div class="flex flex-wrap items-center gap-2">
<AppBadge tone="info">Mode: {{ listState.mode }}</AppBadge>
<AppBadge tone="neutral">Visible: {{ filteredPublications.length }}</AppBadge>
</div>
</div>
<AppEmptyState
v-if="filteredPublications.length === 0"
title="No publications found"
body="Try changing mode, scholar filter, or search terms."
/>
<AppTable v-else label="Publication list table">
<thead>
<tr>
<th scope="col" class="w-12">
<input
type="checkbox"
class="h-4 w-4 rounded border-zinc-400 text-brand-600 focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
:checked="allVisibleUnreadSelected"
:disabled="visibleUnreadKeys.size === 0"
aria-label="Select all visible unread publications"
@change="onToggleAllVisible"
/>
</th>
<th scope="col">Title</th>
<th scope="col">Scholar</th>
<th scope="col">Year</th>
<th scope="col">Citations</th>
<th scope="col">Status</th>
<th scope="col">First seen</th>
</tr>
</thead>
<tbody>
<tr v-for="item in filteredPublications" :key="publicationKey(item)">
<td>
<input
type="checkbox"
class="h-4 w-4 rounded border-zinc-400 text-brand-600 focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-600 dark:bg-zinc-900 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
:checked="selectedPublicationKeys.has(publicationKey(item))"
:disabled="item.is_read"
:aria-label="`Select publication ${item.title}`"
@change="onToggleRowSelection(item, $event)"
/>
</td>
<td>
<a
v-if="item.pub_url"
:href="item.pub_url"
target="_blank"
rel="noreferrer"
class="link-inline"
>
{{ item.title }}
</a>
<span v-else>{{ item.title }}</span>
</td>
<td>{{ item.scholar_label }}</td>
<td>{{ item.year ?? "n/a" }}</td>
<td>{{ item.citation_count }}</td>
<td>
<div class="flex flex-wrap items-center gap-2">
<AppBadge :tone="item.is_new_in_latest_run ? 'info' : 'neutral'">
{{ item.is_new_in_latest_run ? "New" : "Existing" }}
</AppBadge>
<AppBadge :tone="item.is_read ? 'success' : 'warning'">
{{ item.is_read ? "Read" : "Unread" }}
</AppBadge>
</div>
</td>
<td>{{ formatDate(item.first_seen_at) }}</td>
</tr>
</tbody>
</AppTable>
</AppCard>
</template>
</AppPage>
</template>

View file

@ -0,0 +1,134 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import AppPage from "@/components/layout/AppPage.vue";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
import AppTable from "@/components/ui/AppTable.vue";
import { getRunDetail, type RunDetail } from "@/features/runs";
import { ApiRequestError } from "@/lib/api/errors";
const route = useRoute();
const loading = ref(true);
const detail = ref<RunDetail | null>(null);
const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null);
const runId = computed(() => Number(route.params.id));
function formatDate(value: string | null): string {
if (!value) {
return "n/a";
}
const asDate = new Date(value);
if (Number.isNaN(asDate.getTime())) {
return value;
}
return asDate.toLocaleString();
}
async function loadDetail(): Promise<void> {
loading.value = true;
errorMessage.value = null;
errorRequestId.value = null;
if (!Number.isFinite(runId.value) || runId.value <= 0) {
detail.value = null;
errorMessage.value = "Invalid run id.";
loading.value = false;
return;
}
try {
detail.value = await getRunDetail(runId.value);
} catch (error) {
detail.value = null;
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to load run details.";
}
} finally {
loading.value = false;
}
}
onMounted(() => {
void loadDetail();
});
</script>
<template>
<AppPage title="Run Detail" subtitle="Per-scholar diagnostics for the selected run.">
<div class="flex justify-end">
<AppButton variant="secondary" @click="loadDetail" :disabled="loading">
{{ loading ? "Refreshing..." : "Refresh" }}
</AppButton>
</div>
<AppAlert v-if="errorMessage" tone="danger">
<template #title>Run detail request failed</template>
<p>{{ errorMessage }}</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<AppSkeleton v-if="loading" :lines="8" />
<template v-else-if="detail">
<AppCard class="space-y-4">
<div class="flex flex-wrap items-start justify-between gap-3">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Run #{{ detail.run.id }}</h2>
<p class="text-sm text-secondary">Started: {{ formatDate(detail.run.start_dt) }}</p>
<p class="text-sm text-secondary">Ended: {{ formatDate(detail.run.end_dt) }}</p>
</div>
<RunStatusBadge :status="detail.run.status" />
</div>
<p class="text-sm text-zinc-700 dark:text-zinc-300">
Outcome summary: {{ detail.summary.succeeded_count }} succeeded, {{ detail.summary.partial_count }} partial,
{{ detail.summary.failed_count }} failed.
</p>
</AppCard>
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Scholar Results</h2>
<AppEmptyState
v-if="detail.scholar_results.length === 0"
title="No scholar diagnostics"
body="This run did not include per-scholar diagnostics payloads."
/>
<AppTable v-else label="Run detail scholar diagnostics table">
<thead>
<tr>
<th scope="col">Scholar ID</th>
<th scope="col">Outcome</th>
<th scope="col">State</th>
<th scope="col">Publications</th>
<th scope="col">Attempts</th>
<th scope="col">Warnings</th>
</tr>
</thead>
<tbody>
<tr v-for="result in detail.scholar_results" :key="`${result.scholar_profile_id}-${result.scholar_id}`">
<td><code>{{ result.scholar_id }}</code></td>
<td>{{ result.outcome }}</td>
<td>{{ result.state }}<span v-if="result.state_reason"> ({{ result.state_reason }})</span></td>
<td>{{ result.publication_count }}</td>
<td>{{ result.attempt_count }}</td>
<td>{{ result.warnings.length ? result.warnings.join(", ") : "n/a" }}</td>
</tr>
</tbody>
</AppTable>
</AppCard>
</template>
</AppPage>
</template>

View file

@ -0,0 +1,266 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import AppPage from "@/components/layout/AppPage.vue";
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
import AppTable from "@/components/ui/AppTable.vue";
import {
clearQueueItem,
dropQueueItem,
listQueueItems,
listRuns,
retryQueueItem,
triggerManualRun,
type QueueItem,
type RunListItem,
} from "@/features/runs";
import { ApiRequestError } from "@/lib/api/errors";
const loading = ref(true);
const pendingRun = ref(false);
const runs = ref<RunListItem[]>([]);
const queueItems = ref<QueueItem[]>([]);
const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const activeQueueItemId = ref<number | null>(null);
function formatDate(value: string | null): string {
if (!value) {
return "n/a";
}
const asDate = new Date(value);
if (Number.isNaN(asDate.getTime())) {
return value;
}
return asDate.toLocaleString();
}
function queueHealth() {
const counts = { queued: 0, retrying: 0, dropped: 0 };
for (const item of queueItems.value) {
if (item.status === "queued") {
counts.queued += 1;
} else if (item.status === "retrying") {
counts.retrying += 1;
} else if (item.status === "dropped") {
counts.dropped += 1;
}
}
return counts;
}
const queueCounts = computed(() => queueHealth());
async function loadData(): Promise<void> {
loading.value = true;
errorMessage.value = null;
errorRequestId.value = null;
try {
const [loadedRuns, loadedQueue] = await Promise.all([listRuns({ limit: 100 }), listQueueItems(200)]);
runs.value = loadedRuns;
queueItems.value = loadedQueue;
} catch (error) {
runs.value = [];
queueItems.value = [];
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to load runs and queue diagnostics.";
}
} finally {
loading.value = false;
}
}
async function onTriggerManualRun(): Promise<void> {
pendingRun.value = true;
successMessage.value = null;
try {
const result = await triggerManualRun();
successMessage.value = `Manual run queued as #${result.run_id} (${result.status}).`;
await loadData();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to trigger manual run.";
}
} finally {
pendingRun.value = false;
}
}
async function runQueueAction(itemId: number, action: "retry" | "drop" | "clear"): Promise<void> {
activeQueueItemId.value = itemId;
successMessage.value = null;
try {
if (action === "retry") {
await retryQueueItem(itemId);
successMessage.value = `Queue item #${itemId} moved to retry.`;
} else if (action === "drop") {
await dropQueueItem(itemId);
successMessage.value = `Queue item #${itemId} dropped.`;
} else {
await clearQueueItem(itemId);
successMessage.value = `Queue item #${itemId} cleared.`;
}
await loadData();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to update queue item.";
}
} finally {
activeQueueItemId.value = null;
}
}
onMounted(() => {
void loadData();
});
</script>
<template>
<AppPage title="Runs" subtitle="Manual run controls and continuation queue diagnostics.">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex flex-wrap items-center gap-2">
<AppButton :disabled="pendingRun" @click="onTriggerManualRun">
{{ pendingRun ? "Triggering..." : "Run now" }}
</AppButton>
<AppButton variant="secondary" :disabled="loading" @click="loadData">
{{ loading ? "Refreshing..." : "Refresh" }}
</AppButton>
</div>
<QueueHealthBadge
:queued="queueCounts.queued"
:retrying="queueCounts.retrying"
:dropped="queueCounts.dropped"
/>
</div>
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null">
<template #title>Operation complete</template>
<p>{{ successMessage }}</p>
</AppAlert>
<AppAlert v-if="errorMessage" tone="danger">
<template #title>Run diagnostics request failed</template>
<p>{{ errorMessage }}</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<AppSkeleton v-if="loading" :lines="8" />
<template v-else>
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Recent Runs</h2>
<AppEmptyState
v-if="runs.length === 0"
title="No runs found"
body="Manual or scheduled ingestion runs will appear here."
/>
<AppTable v-else label="Recent runs table">
<thead>
<tr>
<th scope="col">Run</th>
<th scope="col">Status</th>
<th scope="col">Started</th>
<th scope="col">Scholars</th>
<th scope="col">New pubs</th>
<th scope="col">Failures</th>
</tr>
</thead>
<tbody>
<tr v-for="run in runs" :key="run.id">
<td>
<RouterLink
:to="`/admin/runs/${run.id}`"
class="link-inline"
>
Run #{{ run.id }}
</RouterLink>
</td>
<td><RunStatusBadge :status="run.status" /></td>
<td>{{ formatDate(run.start_dt) }}</td>
<td>{{ run.scholar_count }}</td>
<td>{{ run.new_publication_count }}</td>
<td>{{ run.failed_count + run.partial_count }}</td>
</tr>
</tbody>
</AppTable>
</AppCard>
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Continuation Queue</h2>
<AppEmptyState
v-if="queueItems.length === 0"
title="Queue is empty"
body="Retrying and dropped continuation items appear here for admin action."
/>
<AppTable v-else label="Continuation queue table">
<thead>
<tr>
<th scope="col">ID</th>
<th scope="col">Scholar</th>
<th scope="col">Status</th>
<th scope="col">Attempts</th>
<th scope="col">Next attempt</th>
<th scope="col">Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="item in queueItems" :key="item.id">
<td>{{ item.id }}</td>
<td>{{ item.scholar_label }}</td>
<td>{{ item.status }}</td>
<td>{{ item.attempt_count }}</td>
<td>{{ formatDate(item.next_attempt_dt) }}</td>
<td>
<div class="flex flex-wrap items-center gap-2">
<AppButton
variant="ghost"
:disabled="activeQueueItemId === item.id"
@click="runQueueAction(item.id, 'retry')"
>
Retry
</AppButton>
<AppButton
variant="danger"
:disabled="activeQueueItemId === item.id"
@click="runQueueAction(item.id, 'drop')"
>
Drop
</AppButton>
<AppButton
variant="secondary"
:disabled="activeQueueItemId === item.id"
@click="runQueueAction(item.id, 'clear')"
>
Clear
</AppButton>
</div>
</td>
</tr>
</tbody>
</AppTable>
</AppCard>
</template>
</AppPage>
</template>

View file

@ -0,0 +1,921 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import AppPage from "@/components/layout/AppPage.vue";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import AppBadge from "@/components/ui/AppBadge.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
import AppInput from "@/components/ui/AppInput.vue";
import AppModal from "@/components/ui/AppModal.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
import AppTable from "@/components/ui/AppTable.vue";
import {
clearScholarImage,
createScholar,
deleteScholar,
listScholars,
searchScholarsByName,
setScholarImageUrl,
toggleScholar,
uploadScholarImage,
type ScholarProfile,
type ScholarSearchCandidate,
type ScholarSearchResult,
} from "@/features/scholars";
import { ApiRequestError } from "@/lib/api/errors";
const loading = ref(true);
const saving = ref(false);
const searchingByName = ref(false);
const activeScholarId = ref<number | null>(null);
const imageSavingScholarId = ref<number | null>(null);
const imageUploadingScholarId = ref<number | null>(null);
const addingCandidateScholarId = ref<string | null>(null);
const activeScholarSettingsId = ref<number | null>(null);
const scholars = ref<ScholarProfile[]>([]);
const imageUrlDraftByScholarId = ref<Record<number, string>>({});
const failedImageByKey = ref<Record<string, boolean>>({});
const scholarBatchInput = ref("");
const searchQuery = ref("");
const searchResult = ref<ScholarSearchResult | null>(null);
const searchErrorMessage = ref<string | null>(null);
const searchErrorRequestId = ref<string | null>(null);
const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/;
const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i;
const trackedScholarIds = computed(() => new Set(scholars.value.map((item) => item.scholar_id)));
const activeScholarSettings = computed(
() => scholars.value.find((item) => item.id === activeScholarSettingsId.value) ?? null,
);
const searchIsDegraded = computed(() => {
if (!searchResult.value) {
return false;
}
return searchResult.value.state === "blocked_or_captcha" || searchResult.value.state === "network_error";
});
function formatDate(value: string | null, compact = false): string {
if (!value) {
return "n/a";
}
const asDate = new Date(value);
if (Number.isNaN(asDate.getTime())) {
return value;
}
if (compact) {
return asDate.toLocaleString(undefined, {
dateStyle: "short",
timeStyle: "short",
});
}
return asDate.toLocaleString();
}
function scholarProfileUrl(scholarId: string): string {
return `https://scholar.google.com/citations?hl=en&user=${encodeURIComponent(scholarId)}`;
}
function scholarPublicationsRoute(profile: ScholarProfile): { name: string; query: { scholar: string } } {
return {
name: "publications",
query: { scholar: String(profile.id) },
};
}
function parseScholarIds(raw: string): string[] {
const ordered: string[] = [];
const seen = new Set<string>();
const tokens = raw
.split(/[\s,;]+/)
.map((value) => value.trim())
.filter((value) => value.length > 0);
for (const token of tokens) {
let candidate: string | null = null;
if (SCHOLAR_ID_PATTERN.test(token)) {
candidate = token;
}
if (!candidate) {
const directParamMatch = token.match(URL_USER_PARAM_PATTERN);
if (directParamMatch) {
candidate = directParamMatch[1];
}
}
if (!candidate && token.includes("scholar.google")) {
try {
const parsed = new URL(token);
const userParam = parsed.searchParams.get("user");
if (userParam && SCHOLAR_ID_PATTERN.test(userParam)) {
candidate = userParam;
}
} catch (_error) {
// Ignore non-URL tokens.
}
}
if (!candidate || seen.has(candidate)) {
continue;
}
seen.add(candidate);
ordered.push(candidate);
}
return ordered;
}
function formatImageSource(value: ScholarProfile["profile_image_source"]): string {
if (value === "upload") {
return "Uploaded";
}
if (value === "override") {
return "Custom URL";
}
if (value === "scraped") {
return "Scraped";
}
return "Fallback";
}
function sourceTone(value: ScholarProfile["profile_image_source"]): "neutral" | "info" | "success" {
if (value === "upload") {
return "success";
}
if (value === "override" || value === "scraped") {
return "info";
}
return "neutral";
}
function makeInitials(label: string | null | undefined, scholarId: string): string {
const source = (label || "").trim();
if (source.length === 0) {
return scholarId.slice(0, 2).toUpperCase();
}
const tokens = source.split(/\s+/).filter(Boolean);
if (tokens.length === 1) {
return tokens[0].slice(0, 2).toUpperCase();
}
return `${tokens[0].charAt(0)}${tokens[1].charAt(0)}`.toUpperCase();
}
function imageKey(prefix: string, id: string | number, imageUrl: string | null): string {
return `${prefix}:${String(id)}:${imageUrl || "none"}`;
}
function canRenderImage(prefix: string, id: string | number, imageUrl: string | null): boolean {
if (!imageUrl) {
return false;
}
return !failedImageByKey.value[imageKey(prefix, id, imageUrl)];
}
function markImageFailed(prefix: string, id: string | number, imageUrl: string | null): void {
failedImageByKey.value[imageKey(prefix, id, imageUrl)] = true;
}
function scholarLabel(profile: ScholarProfile): string {
return profile.display_name || profile.scholar_id;
}
function isImageBusy(scholarProfileId: number): boolean {
return (
imageSavingScholarId.value === scholarProfileId ||
imageUploadingScholarId.value === scholarProfileId ||
activeScholarId.value === scholarProfileId
);
}
function openScholarSettings(profile: ScholarProfile): void {
activeScholarSettingsId.value = profile.id;
}
function closeScholarSettings(): void {
activeScholarSettingsId.value = null;
}
function syncImageDrafts(): void {
const next: Record<number, string> = {};
for (const item of scholars.value) {
const existing = imageUrlDraftByScholarId.value[item.id];
if (typeof existing === "string" && existing.length > 0) {
next[item.id] = existing;
continue;
}
next[item.id] = item.profile_image_source === "override" ? (item.profile_image_url ?? "") : "";
}
imageUrlDraftByScholarId.value = next;
}
async function loadScholars(): Promise<void> {
loading.value = true;
try {
scholars.value = await listScholars();
syncImageDrafts();
} catch (error) {
scholars.value = [];
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to load scholars.";
}
} finally {
loading.value = false;
}
}
async function onAddScholars(): Promise<void> {
saving.value = true;
errorMessage.value = null;
errorRequestId.value = null;
successMessage.value = null;
try {
const scholarIds = parseScholarIds(scholarBatchInput.value);
if (scholarIds.length === 0) {
throw new Error("Provide at least one valid Scholar ID or profile URL.");
}
const settled = await Promise.allSettled(
scholarIds.map((scholarId) => createScholar({ scholar_id: scholarId })),
);
const failures: string[] = [];
let requestIdFromFailures: string | null = null;
let createdCount = 0;
settled.forEach((result, index) => {
if (result.status === "fulfilled") {
createdCount += 1;
return;
}
const scholarId = scholarIds[index];
if (result.reason instanceof ApiRequestError) {
failures.push(`${scholarId}: ${result.reason.message}`);
if (!requestIdFromFailures && result.reason.requestId) {
requestIdFromFailures = result.reason.requestId;
}
} else if (result.reason instanceof Error) {
failures.push(`${scholarId}: ${result.reason.message}`);
} else {
failures.push(`${scholarId}: Unable to create scholar.`);
}
});
if (createdCount > 0) {
const total = scholarIds.length;
successMessage.value = `Added ${createdCount} of ${total} scholar${total === 1 ? "" : "s"}.`;
scholarBatchInput.value = "";
}
if (failures.length > 0) {
const preview = failures.slice(0, 3).join(" | ");
const remainder = failures.length > 3 ? ` (+${failures.length - 3} more)` : "";
errorMessage.value = `Failed to add ${failures.length} scholar${failures.length === 1 ? "" : "s"}: ${preview}${remainder}`;
errorRequestId.value = requestIdFromFailures;
}
await loadScholars();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else if (error instanceof Error) {
errorMessage.value = error.message;
} else {
errorMessage.value = "Unable to create scholar records.";
}
} finally {
saving.value = false;
}
}
async function onSearchByName(): Promise<void> {
searchingByName.value = true;
searchErrorMessage.value = null;
searchErrorRequestId.value = null;
try {
const normalized = searchQuery.value.trim();
if (normalized.length < 2) {
throw new Error("Enter at least 2 characters to search.");
}
searchResult.value = await searchScholarsByName(normalized, 12);
} catch (error) {
searchResult.value = null;
if (error instanceof ApiRequestError) {
searchErrorMessage.value = error.message;
searchErrorRequestId.value = error.requestId;
} else if (error instanceof Error) {
searchErrorMessage.value = error.message;
} else {
searchErrorMessage.value = "Unable to search scholars by name.";
}
} finally {
searchingByName.value = false;
}
}
async function onAddCandidate(candidate: ScholarSearchCandidate): Promise<void> {
addingCandidateScholarId.value = candidate.scholar_id;
errorMessage.value = null;
errorRequestId.value = null;
successMessage.value = null;
try {
await createScholar({
scholar_id: candidate.scholar_id,
profile_image_url: candidate.profile_image_url ?? undefined,
});
successMessage.value = `${candidate.display_name} added.`;
await loadScholars();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to add scholar from search result.";
}
} finally {
addingCandidateScholarId.value = null;
}
}
async function onToggleScholar(profile: ScholarProfile): Promise<void> {
activeScholarId.value = profile.id;
errorMessage.value = null;
errorRequestId.value = null;
successMessage.value = null;
try {
await toggleScholar(profile.id);
successMessage.value = `${scholarLabel(profile)} ${profile.is_enabled ? "disabled" : "enabled"}.`;
await loadScholars();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to update scholar status.";
}
} finally {
activeScholarId.value = null;
}
}
async function onDeleteScholar(profile: ScholarProfile): Promise<void> {
const label = scholarLabel(profile);
const shouldDelete = window.confirm(`Delete scholar ${label}? This removes all linked publications and queue data.`);
if (!shouldDelete) {
return;
}
activeScholarId.value = profile.id;
errorMessage.value = null;
errorRequestId.value = null;
successMessage.value = null;
try {
await deleteScholar(profile.id);
successMessage.value = `${label} deleted.`;
if (activeScholarSettingsId.value === profile.id) {
closeScholarSettings();
}
await loadScholars();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to delete scholar.";
}
} finally {
activeScholarId.value = null;
}
}
async function onSaveImageUrl(profile: ScholarProfile): Promise<void> {
const candidate = (imageUrlDraftByScholarId.value[profile.id] || "").trim();
if (!candidate) {
errorMessage.value = "Enter an image URL before saving, or use Reset image.";
return;
}
imageSavingScholarId.value = profile.id;
errorMessage.value = null;
errorRequestId.value = null;
successMessage.value = null;
try {
await setScholarImageUrl(profile.id, candidate);
successMessage.value = `Image URL updated for ${scholarLabel(profile)}.`;
await loadScholars();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to update scholar image URL.";
}
} finally {
imageSavingScholarId.value = null;
}
}
async function onUploadImage(profile: ScholarProfile, event: Event): Promise<void> {
const input = event.target as HTMLInputElement | null;
const file = input?.files?.[0] ?? null;
if (!file) {
return;
}
imageUploadingScholarId.value = profile.id;
errorMessage.value = null;
errorRequestId.value = null;
successMessage.value = null;
try {
await uploadScholarImage(profile.id, file);
successMessage.value = `Uploaded image for ${scholarLabel(profile)}.`;
await loadScholars();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to upload scholar image.";
}
} finally {
imageUploadingScholarId.value = null;
if (input) {
input.value = "";
}
}
}
async function onResetImage(profile: ScholarProfile): Promise<void> {
imageSavingScholarId.value = profile.id;
errorMessage.value = null;
errorRequestId.value = null;
successMessage.value = null;
try {
await clearScholarImage(profile.id);
successMessage.value = `Image reset for ${scholarLabel(profile)}.`;
await loadScholars();
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to reset scholar image.";
}
} finally {
imageSavingScholarId.value = null;
}
}
onMounted(() => {
void loadScholars();
});
</script>
<template>
<AppPage title="Scholars" subtitle="Track scholars and manage profile behavior with less noise.">
<div class="flex justify-end">
<AppButton variant="secondary" @click="loadScholars" :disabled="loading || saving || searchingByName">
{{ loading ? "Refreshing..." : "Refresh" }}
</AppButton>
</div>
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null">
<template #title>Scholar update complete</template>
<p>{{ successMessage }}</p>
</AppAlert>
<AppAlert v-if="errorMessage" tone="danger">
<template #title>Scholar request failed</template>
<p>{{ errorMessage }}</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<section class="grid gap-4 xl:grid-cols-[minmax(0,34rem)_minmax(0,1fr)]">
<div class="grid content-start gap-4">
<AppCard class="space-y-4">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Add Scholars</h2>
<p class="text-sm text-secondary">
Paste one or many Scholar IDs or profile URLs. Duplicate and already-tracked IDs are ignored per response.
</p>
</div>
<form class="grid gap-3" @submit.prevent="onAddScholars">
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>Scholar IDs or profile URLs</span>
<textarea
id="scholar-batch-input"
v-model="scholarBatchInput"
rows="5"
placeholder="A-UbBTPM15wL\nhttps://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL"
class="w-full rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none ring-brand-300 transition placeholder:text-zinc-400 focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:border-zinc-700 dark:bg-zinc-900 dark:text-zinc-100 dark:ring-brand-400 dark:focus-visible:ring-offset-zinc-950 dark:placeholder:text-zinc-500"
/>
</label>
<div class="flex flex-wrap items-center justify-between gap-2">
<AppButton type="submit" :disabled="saving || loading">
{{ saving ? "Adding..." : "Add scholars" }}
</AppButton>
<span class="text-xs text-secondary">Accepted pattern: <code>[a-zA-Z0-9_-]{12}</code></span>
</div>
</form>
</AppCard>
<AppCard class="space-y-4">
<div class="space-y-1">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Search by Name</h2>
<p class="text-sm text-secondary">
Best-effort helper. For reliable adds, use Scholar URL/ID above.
</p>
</div>
<form class="flex flex-wrap items-center gap-2" @submit.prevent="onSearchByName">
<div class="min-w-0 flex-1">
<AppInput
id="scholar-search-name"
v-model="searchQuery"
placeholder="e.g. Geoffrey Hinton"
:disabled="searchingByName"
/>
</div>
<AppButton type="submit" :disabled="searchingByName || searchQuery.trim().length < 2">
{{ searchingByName ? "Searching..." : "Search" }}
</AppButton>
</form>
<AppAlert v-if="searchErrorMessage" tone="danger">
<template #title>Search failed</template>
<p>{{ searchErrorMessage }}</p>
<p class="text-secondary">Request ID: {{ searchErrorRequestId || "n/a" }}</p>
</AppAlert>
<AppSkeleton v-else-if="searchingByName" :lines="4" />
<template v-else-if="searchResult">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm text-secondary">
{{ searchResult.candidates.length }} candidate{{ searchResult.candidates.length === 1 ? "" : "s" }} for
<strong class="text-zinc-900 dark:text-zinc-100">{{ searchResult.query }}</strong>
</p>
<AppBadge
:tone="
searchResult.state === 'ok'
? 'success'
: searchResult.state === 'blocked_or_captcha' || searchResult.state === 'network_error'
? 'warning'
: 'neutral'
"
>
{{ searchResult.state }}
</AppBadge>
</div>
<p
v-if="searchResult.state !== 'ok' || searchResult.warnings.length > 0"
class="text-xs text-secondary"
>
<span>State reason: <code>{{ searchResult.state_reason }}</code></span>
<span v-if="searchResult.action_hint">. {{ searchResult.action_hint }}</span>
<span v-if="searchResult.warnings.length > 0">
. Warnings: {{ searchResult.warnings.join(", ") }}
</span>
</p>
<AppAlert v-if="searchIsDegraded" tone="warning">
<template #title>Name search is degraded</template>
<p>
To avoid blocks, this feature is throttled and may temporarily pause itself. Use Scholar URL/ID adds
for dependable tracking.
</p>
</AppAlert>
<AppEmptyState
v-if="searchResult.candidates.length === 0"
title="No scholar matches returned"
body="Try again later or paste Scholar profile URLs/IDs to continue safely."
/>
<ul v-else class="grid gap-3">
<li
v-for="candidate in searchResult.candidates"
:key="candidate.scholar_id"
class="rounded-xl border border-zinc-200 bg-zinc-50/70 p-3 dark:border-zinc-800 dark:bg-zinc-900/50"
>
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-200 bg-zinc-100 text-xs font-semibold text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
>
<img
v-if="canRenderImage('candidate', candidate.scholar_id, candidate.profile_image_url)"
:src="candidate.profile_image_url || ''"
:alt="`${candidate.display_name} profile image`"
class="h-full w-full object-cover"
loading="lazy"
referrerpolicy="no-referrer"
@error="markImageFailed('candidate', candidate.scholar_id, candidate.profile_image_url)"
/>
<span v-else>{{ makeInitials(candidate.display_name, candidate.scholar_id) }}</span>
</div>
<div class="min-w-0 flex-1 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<strong class="text-sm text-zinc-900 dark:text-zinc-100">{{ candidate.display_name }}</strong>
<code class="text-xs text-secondary">{{ candidate.scholar_id }}</code>
<AppBadge v-if="trackedScholarIds.has(candidate.scholar_id)" tone="success">Tracked</AppBadge>
</div>
<p class="truncate text-xs text-secondary">
{{ candidate.affiliation || "No affiliation provided" }}
</p>
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
<span v-if="candidate.email_domain">Email: {{ candidate.email_domain }}</span>
<span v-if="candidate.cited_by_count !== null">Cited by: {{ candidate.cited_by_count }}</span>
</div>
<div class="flex flex-wrap items-center gap-1">
<AppBadge v-for="interest in candidate.interests.slice(0, 3)" :key="interest" tone="neutral">
{{ interest }}
</AppBadge>
</div>
</div>
<div class="grid shrink-0 gap-2">
<AppButton
:disabled="trackedScholarIds.has(candidate.scholar_id) || addingCandidateScholarId === candidate.scholar_id"
@click="onAddCandidate(candidate)"
>
{{ addingCandidateScholarId === candidate.scholar_id ? "Adding..." : "Add" }}
</AppButton>
<a :href="candidate.profile_url" target="_blank" rel="noreferrer" class="link-inline text-xs text-center">
Open profile
</a>
</div>
</div>
</li>
</ul>
</template>
<p v-else class="text-sm text-secondary">Run a name search to see matching scholar candidates.</p>
</AppCard>
</div>
<AppCard class="space-y-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Tracked Scholars</h2>
<span class="text-sm text-secondary">{{ scholars.length }} total</span>
</div>
<AppSkeleton v-if="loading" :lines="6" />
<AppEmptyState
v-else-if="scholars.length === 0"
title="No scholars tracked"
body="Add a Scholar ID directly or search by name to start ingestion tracking."
/>
<div v-else class="space-y-3">
<ul class="grid gap-3 lg:hidden">
<li
v-for="item in scholars"
:key="item.id"
class="rounded-xl border border-zinc-200 bg-zinc-50/70 p-3 dark:border-zinc-800 dark:bg-zinc-900/50"
>
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-200 bg-zinc-100 text-xs font-semibold text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
>
<img
v-if="canRenderImage('scholar', item.id, item.profile_image_url)"
:src="item.profile_image_url || ''"
:alt="`${scholarLabel(item)} profile image`"
class="h-full w-full object-cover"
loading="lazy"
referrerpolicy="no-referrer"
@error="markImageFailed('scholar', item.id, item.profile_image_url)"
/>
<span v-else>{{ makeInitials(item.display_name, item.scholar_id) }}</span>
</div>
<div class="min-w-0 flex-1 space-y-1">
<p class="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">{{ scholarLabel(item) }}</p>
<p class="text-xs text-secondary"><code>{{ item.scholar_id }}</code></p>
<div class="flex flex-wrap items-center gap-2">
<AppBadge :tone="item.is_enabled ? 'success' : 'warning'">
{{ item.is_enabled ? "Enabled" : "Disabled" }}
</AppBadge>
<RunStatusBadge :status="item.last_run_status || 'unknown'" />
<span class="text-xs text-secondary">{{ formatDate(item.last_run_dt, true) }}</span>
</div>
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">
View publications
</RouterLink>
</div>
<AppButton variant="secondary" :disabled="saving" @click="openScholarSettings(item)">Manage</AppButton>
</div>
</li>
</ul>
<AppTable class="hidden lg:block" label="Scholars table">
<thead>
<tr>
<th scope="col">Scholar</th>
<th scope="col">Scholar ID</th>
<th scope="col">Enabled</th>
<th scope="col" class="w-[15rem]">Last run</th>
<th scope="col">Manage</th>
</tr>
</thead>
<tbody>
<tr v-for="item in scholars" :key="item.id">
<td>
<div class="flex items-start gap-3">
<div
class="flex h-12 w-12 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-200 bg-zinc-100 text-xs font-semibold text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
>
<img
v-if="canRenderImage('scholar', item.id, item.profile_image_url)"
:src="item.profile_image_url || ''"
:alt="`${scholarLabel(item)} profile image`"
class="h-full w-full object-cover"
loading="lazy"
referrerpolicy="no-referrer"
@error="markImageFailed('scholar', item.id, item.profile_image_url)"
/>
<span v-else>{{ makeInitials(item.display_name, item.scholar_id) }}</span>
</div>
<div class="grid min-w-0 gap-1">
<strong class="truncate text-zinc-900 dark:text-zinc-100">{{ scholarLabel(item) }}</strong>
<div class="flex flex-wrap items-center gap-2">
<RouterLink :to="scholarPublicationsRoute(item)" class="link-inline text-xs">
Publications
</RouterLink>
<a :href="scholarProfileUrl(item.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">
Open profile
</a>
<AppBadge :tone="sourceTone(item.profile_image_source)">
{{ formatImageSource(item.profile_image_source) }}
</AppBadge>
</div>
</div>
</div>
</td>
<td><code class="text-xs">{{ item.scholar_id }}</code></td>
<td>
<AppBadge :tone="item.is_enabled ? 'success' : 'warning'">
{{ item.is_enabled ? "Enabled" : "Disabled" }}
</AppBadge>
</td>
<td>
<div class="grid gap-1 text-xs">
<RunStatusBadge :status="item.last_run_status || 'unknown'" />
<span class="whitespace-nowrap text-secondary">{{ formatDate(item.last_run_dt, true) }}</span>
</div>
</td>
<td>
<AppButton variant="secondary" :disabled="saving" @click="openScholarSettings(item)">
Manage
</AppButton>
</td>
</tr>
</tbody>
</AppTable>
</div>
</AppCard>
</section>
<AppModal :open="activeScholarSettings !== null" title="Scholar settings" @close="closeScholarSettings">
<div v-if="activeScholarSettings" class="grid gap-4">
<div class="flex items-start gap-3">
<div
class="flex h-14 w-14 shrink-0 items-center justify-center overflow-hidden rounded-full border border-zinc-200 bg-zinc-100 text-xs font-semibold text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"
>
<img
v-if="canRenderImage('scholar', activeScholarSettings.id, activeScholarSettings.profile_image_url)"
:src="activeScholarSettings.profile_image_url || ''"
:alt="`${scholarLabel(activeScholarSettings)} profile image`"
class="h-full w-full object-cover"
loading="lazy"
referrerpolicy="no-referrer"
@error="markImageFailed('scholar', activeScholarSettings.id, activeScholarSettings.profile_image_url)"
/>
<span v-else>{{ makeInitials(activeScholarSettings.display_name, activeScholarSettings.scholar_id) }}</span>
</div>
<div class="min-w-0 space-y-1">
<p class="truncate text-sm font-semibold text-zinc-900 dark:text-zinc-100">
{{ scholarLabel(activeScholarSettings) }}
</p>
<p class="text-xs text-secondary">
ID: <code>{{ activeScholarSettings.scholar_id }}</code>
</p>
<div class="flex flex-wrap items-center gap-3">
<RouterLink :to="scholarPublicationsRoute(activeScholarSettings)" class="link-inline text-xs">
View publications
</RouterLink>
<a
:href="scholarProfileUrl(activeScholarSettings.scholar_id)"
target="_blank"
rel="noreferrer"
class="link-inline text-xs"
>
Open Google Scholar profile
</a>
</div>
</div>
</div>
<div class="grid gap-2">
<label class="text-sm font-medium text-zinc-700 dark:text-zinc-300" :for="`scholar-image-url-${activeScholarSettings.id}`">
Profile image URL override
</label>
<div class="flex flex-wrap items-center gap-2">
<div class="min-w-0 flex-1">
<AppInput
:id="`scholar-image-url-${activeScholarSettings.id}`"
v-model="imageUrlDraftByScholarId[activeScholarSettings.id]"
placeholder="https://example.com/avatar.jpg"
:disabled="isImageBusy(activeScholarSettings.id)"
/>
</div>
<AppButton
variant="secondary"
:disabled="isImageBusy(activeScholarSettings.id)"
@click="onSaveImageUrl(activeScholarSettings)"
>
{{ imageSavingScholarId === activeScholarSettings.id ? "Saving..." : "Save URL" }}
</AppButton>
</div>
<div class="flex flex-wrap items-center gap-2">
<label
:for="`scholar-image-upload-${activeScholarSettings.id}`"
class="inline-flex min-h-10 cursor-pointer items-center justify-center rounded-lg border border-zinc-300 bg-zinc-100 px-3 py-2 text-sm font-semibold text-zinc-900 transition hover:bg-zinc-200 focus-within:outline-none focus-within:ring-2 focus-within:ring-brand-500 focus-within:ring-offset-2 focus-within:ring-offset-zinc-100 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700 dark:focus-within:ring-brand-400 dark:focus-within:ring-offset-zinc-950"
:class="{ 'pointer-events-none opacity-60': isImageBusy(activeScholarSettings.id) }"
>
{{ imageUploadingScholarId === activeScholarSettings.id ? "Uploading..." : "Upload image" }}
</label>
<input
:id="`scholar-image-upload-${activeScholarSettings.id}`"
type="file"
class="sr-only"
accept="image/jpeg,image/png,image/webp,image/gif"
:disabled="isImageBusy(activeScholarSettings.id)"
@change="onUploadImage(activeScholarSettings, $event)"
/>
<AppButton variant="ghost" :disabled="isImageBusy(activeScholarSettings.id)" @click="onResetImage(activeScholarSettings)">
Reset image
</AppButton>
</div>
</div>
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-zinc-200 pt-3 dark:border-zinc-800">
<AppButton
variant="secondary"
:disabled="isImageBusy(activeScholarSettings.id) || saving"
@click="onToggleScholar(activeScholarSettings)"
>
{{ activeScholarSettings.is_enabled ? "Disable scholar" : "Enable scholar" }}
</AppButton>
<AppButton
variant="danger"
:disabled="isImageBusy(activeScholarSettings.id) || saving"
@click="onDeleteScholar(activeScholarSettings)"
>
Delete scholar
</AppButton>
</div>
</div>
</AppModal>
</AppPage>
</template>

View file

@ -0,0 +1,255 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import AppPage from "@/components/layout/AppPage.vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
import AppInput from "@/components/ui/AppInput.vue";
import AppModal from "@/components/ui/AppModal.vue";
import AppSkeleton from "@/components/ui/AppSkeleton.vue";
import {
changePassword,
fetchSettings,
updateSettings,
type UserSettings,
} from "@/features/settings";
import { ApiRequestError } from "@/lib/api/errors";
const loading = ref(true);
const saving = ref(false);
const updatingPassword = ref(false);
const autoRunEnabled = ref(false);
const runIntervalMinutes = ref("60");
const requestDelaySeconds = ref("2");
const currentPassword = ref("");
const newPassword = ref("");
const confirmPassword = ref("");
const errorMessage = ref<string | null>(null);
const errorRequestId = ref<string | null>(null);
const successMessage = ref<string | null>(null);
const showIngestionModal = ref(false);
const showPasswordModal = ref(false);
function hydrateSettings(settings: UserSettings): void {
autoRunEnabled.value = settings.auto_run_enabled;
runIntervalMinutes.value = String(settings.run_interval_minutes);
requestDelaySeconds.value = String(settings.request_delay_seconds);
}
function parsePositiveInteger(value: string, label: string): number {
const parsed = Number(value);
if (!Number.isInteger(parsed) || parsed <= 0) {
throw new Error(`${label} must be a positive integer.`);
}
return parsed;
}
async function loadSettings(): Promise<void> {
loading.value = true;
errorMessage.value = null;
errorRequestId.value = null;
try {
const settings = await fetchSettings();
hydrateSettings(settings);
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else {
errorMessage.value = "Unable to load user settings.";
}
} finally {
loading.value = false;
}
}
async function onSaveSettings(): Promise<void> {
saving.value = true;
errorMessage.value = null;
errorRequestId.value = null;
successMessage.value = null;
try {
const payload: UserSettings = {
auto_run_enabled: autoRunEnabled.value,
run_interval_minutes: parsePositiveInteger(runIntervalMinutes.value, "Run interval"),
request_delay_seconds: parsePositiveInteger(requestDelaySeconds.value, "Request delay"),
};
const saved = await updateSettings(payload);
hydrateSettings(saved);
successMessage.value = "Settings updated.";
showIngestionModal.value = false;
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else if (error instanceof Error) {
errorMessage.value = error.message;
} else {
errorMessage.value = "Unable to save settings.";
}
} finally {
saving.value = false;
}
}
async function onChangePassword(): Promise<void> {
updatingPassword.value = true;
errorMessage.value = null;
errorRequestId.value = null;
successMessage.value = null;
try {
if (!currentPassword.value || !newPassword.value || !confirmPassword.value) {
throw new Error("All password fields are required.");
}
const response = await changePassword({
current_password: currentPassword.value,
new_password: newPassword.value,
confirm_password: confirmPassword.value,
});
currentPassword.value = "";
newPassword.value = "";
confirmPassword.value = "";
successMessage.value = response.message;
showPasswordModal.value = false;
} catch (error) {
if (error instanceof ApiRequestError) {
errorMessage.value = error.message;
errorRequestId.value = error.requestId;
} else if (error instanceof Error) {
errorMessage.value = error.message;
} else {
errorMessage.value = "Unable to change password.";
}
} finally {
updatingPassword.value = false;
}
}
onMounted(() => {
void loadSettings();
});
</script>
<template>
<AppPage title="Settings" subtitle="Configure ingestion and account controls.">
<AppAlert v-if="successMessage" tone="success" dismissible @dismiss="successMessage = null">
<template #title>Saved</template>
<p>{{ successMessage }}</p>
</AppAlert>
<AppAlert v-if="errorMessage" tone="danger">
<template #title>Settings request failed</template>
<p>{{ errorMessage }}</p>
<p class="text-secondary">Request ID: {{ errorRequestId || "n/a" }}</p>
</AppAlert>
<AppSkeleton v-if="loading" :lines="7" />
<template v-else>
<section class="grid gap-4 lg:grid-cols-2">
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Scholar ingestion defaults</h2>
<dl class="grid gap-2 text-sm text-secondary">
<div class="flex items-center justify-between gap-2">
<dt>Scheduler</dt>
<dd class="font-semibold text-zinc-900 dark:text-zinc-100">
{{ autoRunEnabled ? "Enabled" : "Disabled" }}
</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt>Run interval</dt>
<dd class="font-semibold text-zinc-900 dark:text-zinc-100">{{ runIntervalMinutes }} min</dd>
</div>
<div class="flex items-center justify-between gap-2">
<dt>Request delay</dt>
<dd class="font-semibold text-zinc-900 dark:text-zinc-100">{{ requestDelaySeconds }} sec</dd>
</div>
</dl>
<AppButton variant="secondary" @click="showIngestionModal = true">
Manage ingestion settings
</AppButton>
</AppCard>
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Account security</h2>
<p class="text-sm text-secondary">Password changes are handled in a dedicated overlay to reduce clutter.</p>
<AppButton variant="secondary" @click="showPasswordModal = true">Manage password</AppButton>
</AppCard>
</section>
</template>
<AppModal :open="showIngestionModal" title="Ingestion settings" @close="showIngestionModal = false">
<form class="grid gap-3" @submit.prevent="onSaveSettings">
<AppCheckbox id="auto-run-enabled" v-model="autoRunEnabled" label="Enable scheduler auto-run" />
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>Run interval (minutes)</span>
<AppInput id="run-interval" v-model="runIntervalMinutes" type="number" min="1" />
</label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>Request delay (seconds)</span>
<AppInput id="request-delay" v-model="requestDelaySeconds" type="number" min="1" />
</label>
<div class="mt-2 flex flex-wrap justify-end gap-2">
<AppButton
variant="ghost"
type="button"
:disabled="saving"
@click="showIngestionModal = false"
>
Cancel
</AppButton>
<AppButton type="submit" :disabled="saving">
{{ saving ? "Saving..." : "Save settings" }}
</AppButton>
</div>
</form>
</AppModal>
<AppModal :open="showPasswordModal" title="Change password" @close="showPasswordModal = false">
<form class="grid gap-3" @submit.prevent="onChangePassword">
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>Current password</span>
<AppInput v-model="currentPassword" type="password" autocomplete="current-password" />
</label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>New password</span>
<AppInput v-model="newPassword" type="password" autocomplete="new-password" />
</label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>Confirm new password</span>
<AppInput v-model="confirmPassword" type="password" autocomplete="new-password" />
</label>
<div class="mt-2 flex flex-wrap justify-end gap-2">
<AppButton
variant="ghost"
type="button"
:disabled="updatingPassword"
@click="showPasswordModal = false"
>
Cancel
</AppButton>
<AppButton type="submit" :disabled="updatingPassword">
{{ updatingPassword ? "Updating..." : "Change password" }}
</AppButton>
</div>
</form>
</AppModal>
</AppPage>
</template>

View file

@ -0,0 +1,138 @@
<script setup lang="ts">
import { ref } from "vue";
import AppPage from "@/components/layout/AppPage.vue";
import QueueHealthBadge from "@/components/patterns/QueueHealthBadge.vue";
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
import AppAlert from "@/components/ui/AppAlert.vue";
import AppBadge from "@/components/ui/AppBadge.vue";
import AppButton from "@/components/ui/AppButton.vue";
import AppCard from "@/components/ui/AppCard.vue";
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
import AppInput from "@/components/ui/AppInput.vue";
import AppSelect from "@/components/ui/AppSelect.vue";
import AppTable from "@/components/ui/AppTable.vue";
const sampleName = ref("Ada Lovelace");
const sampleEmail = ref("ada@example.com");
const sampleRole = ref("operator");
const sampleEnabled = ref(true);
const sampleRows = [
{ id: 101, title: "Entity Resolution Improvements", status: "running", owner: "Scheduler" },
{ id: 102, title: "Retry Queue Drain", status: "partial_failure", owner: "Ops" },
{ id: 103, title: "Weekly Publication Sync", status: "success", owner: "Ingestion" },
];
</script>
<template>
<AppPage
title="Style Guide"
subtitle="Reference page for Tailwind component patterns used across the application."
>
<section class="grid gap-4 xl:grid-cols-2">
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Buttons</h2>
<div class="flex flex-wrap items-center gap-2">
<AppButton>Primary</AppButton>
<AppButton variant="secondary">Secondary</AppButton>
<AppButton variant="ghost">Ghost</AppButton>
<AppButton variant="danger">Danger</AppButton>
<AppButton :disabled="true">Disabled</AppButton>
</div>
<p class="text-sm text-secondary">Use `primary` for main actions and `danger` for destructive actions.</p>
</AppCard>
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Status System</h2>
<div class="flex flex-wrap items-center gap-2">
<AppBadge tone="neutral">Neutral</AppBadge>
<AppBadge tone="info">Info</AppBadge>
<AppBadge tone="success">Success</AppBadge>
<AppBadge tone="warning">Warning</AppBadge>
<AppBadge tone="danger">Danger</AppBadge>
</div>
<div class="flex flex-wrap items-center gap-2">
<RunStatusBadge status="running" />
<RunStatusBadge status="success" />
<RunStatusBadge status="partial_failure" />
<RunStatusBadge status="failed" />
</div>
<QueueHealthBadge :queued="2" :retrying="1" :dropped="1" />
</AppCard>
</section>
<section class="grid gap-4 xl:grid-cols-2">
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Forms</h2>
<form class="grid gap-3" @submit.prevent>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>Name</span>
<AppInput v-model="sampleName" autocomplete="name" />
</label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>Email</span>
<AppInput v-model="sampleEmail" type="email" autocomplete="email" />
</label>
<label class="grid gap-2 text-sm font-medium text-zinc-700 dark:text-zinc-300">
<span>Role</span>
<AppSelect v-model="sampleRole">
<option value="viewer">Viewer</option>
<option value="operator">Operator</option>
<option value="admin">Admin</option>
</AppSelect>
</label>
<AppCheckbox id="style-guide-enabled" v-model="sampleEnabled" label="Enabled" />
<div class="flex flex-wrap items-center gap-2">
<AppButton>Submit</AppButton>
<AppButton variant="secondary" type="reset">Reset</AppButton>
</div>
</form>
</AppCard>
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Alerts</h2>
<AppAlert tone="info">
<template #title>Informational</template>
<p>Use for contextual guidance and non-critical notices.</p>
</AppAlert>
<AppAlert tone="success">
<template #title>Success</template>
<p>Use after completed mutations to confirm outcomes.</p>
</AppAlert>
<AppAlert tone="warning">
<template #title>Warning</template>
<p>Use when user intervention may be required soon.</p>
</AppAlert>
</AppCard>
</section>
<AppCard class="space-y-4">
<h2 class="text-lg font-semibold text-zinc-900 dark:text-zinc-100">Table Pattern</h2>
<AppTable label="Style guide sample run table">
<thead>
<tr>
<th scope="col">Run</th>
<th scope="col">Title</th>
<th scope="col">Owner</th>
<th scope="col">Status</th>
<th scope="col">Action</th>
</tr>
</thead>
<tbody>
<tr v-for="row in sampleRows" :key="row.id">
<td>#{{ row.id }}</td>
<td>{{ row.title }}</td>
<td>{{ row.owner }}</td>
<td><RunStatusBadge :status="row.status" /></td>
<td><AppButton variant="ghost">Inspect</AppButton></td>
</tr>
</tbody>
</AppTable>
</AppCard>
</AppPage>
</template>