test
This commit is contained in:
parent
efd21a7297
commit
441676be27
97 changed files with 10764 additions and 223 deletions
3
frontend/.gitignore
vendored
Normal file
3
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
31
frontend/index.html
Normal file
31
frontend/index.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<!doctype html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>scholarr</title>
|
||||
<script>
|
||||
(function () {
|
||||
var storageKey = "scholarr-theme-preference";
|
||||
var root = document.documentElement;
|
||||
var stored = null;
|
||||
try {
|
||||
stored = localStorage.getItem(storageKey);
|
||||
} catch (_err) {
|
||||
stored = null;
|
||||
}
|
||||
|
||||
var preference = stored === "light" || stored === "dark" || stored === "system" ? stored : "system";
|
||||
var systemDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
var effective = preference === "system" ? (systemDark ? "dark" : "light") : preference;
|
||||
|
||||
root.classList.toggle("dark", effective === "dark");
|
||||
root.setAttribute("data-theme-preference", preference);
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body class="h-full">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
2985
frontend/package-lock.json
generated
Normal file
2985
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
{
|
||||
"name": "scholarr-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"build": "vue-tsc --noEmit && vite build",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 4173",
|
||||
"typecheck": "vue-tsc --noEmit",
|
||||
"test": "vitest",
|
||||
"test:run": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"pinia": "^2.1.7",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@vitejs/plugin-vue": "^5.2.1",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^5.4.11",
|
||||
"vitest": "^2.1.8",
|
||||
"vue-tsc": "^2.1.10"
|
||||
}
|
||||
}
|
||||
6
frontend/postcss.config.cjs
Normal file
6
frontend/postcss.config.cjs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
35
frontend/src/app/AppShell.vue
Normal file
35
frontend/src/app/AppShell.vue
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { RouterView } from "vue-router";
|
||||
|
||||
import AppHeader from "@/components/layout/AppHeader.vue";
|
||||
import AppNav from "@/components/layout/AppNav.vue";
|
||||
import RequestErrorPanel from "@/components/patterns/RequestErrorPanel.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const showChrome = computed(() => auth.isAuthenticated);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen overflow-x-clip">
|
||||
<a href="#app-main" class="skip-link">Skip to main content</a>
|
||||
<RequestErrorPanel />
|
||||
|
||||
<template v-if="showChrome">
|
||||
<AppHeader />
|
||||
<div
|
||||
class="grid min-h-[calc(100dvh-4.5rem)] grid-cols-1 lg:h-[calc(100dvh-4.5rem)] lg:min-h-[calc(100dvh-4.5rem)] lg:grid-cols-[17rem_minmax(0,1fr)]"
|
||||
>
|
||||
<AppNav class="lg:min-h-0 lg:overflow-y-auto" />
|
||||
<main id="app-main" class="min-w-0 px-4 py-6 sm:px-6 lg:min-h-0 lg:overflow-y-auto lg:px-8">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<main id="app-main" v-else class="min-h-screen overflow-x-clip">
|
||||
<RouterView />
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
23
frontend/src/app/guards.ts
Normal file
23
frontend/src/app/guards.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import type { Router } from "vue-router";
|
||||
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
export function applyRouteGuards(router: Router): void {
|
||||
router.beforeEach((to) => {
|
||||
const auth = useAuthStore();
|
||||
|
||||
if (to.meta.requiresAuth && !auth.isAuthenticated) {
|
||||
return { name: "login" };
|
||||
}
|
||||
|
||||
if (to.meta.requiresAdmin && !auth.isAdmin) {
|
||||
return { name: "dashboard" };
|
||||
}
|
||||
|
||||
if (to.meta.guestOnly && auth.isAuthenticated) {
|
||||
return { name: "dashboard" };
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
12
frontend/src/app/providers.ts
Normal file
12
frontend/src/app/providers.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { setCsrfTokenProvider } from "@/lib/api/client";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { useThemeStore } from "@/stores/theme";
|
||||
|
||||
export async function bootstrapAppProviders(): Promise<void> {
|
||||
const theme = useThemeStore();
|
||||
theme.initialize();
|
||||
|
||||
const auth = useAuthStore();
|
||||
setCsrfTokenProvider(() => auth.csrfToken);
|
||||
await auth.bootstrapSession();
|
||||
}
|
||||
84
frontend/src/app/router.ts
Normal file
84
frontend/src/app/router.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import { applyRouteGuards } from "@/app/guards";
|
||||
import LoginPage from "@/pages/LoginPage.vue";
|
||||
import DashboardPage from "@/pages/DashboardPage.vue";
|
||||
import ScholarsPage from "@/pages/ScholarsPage.vue";
|
||||
import PublicationsPage from "@/pages/PublicationsPage.vue";
|
||||
import RunsPage from "@/pages/RunsPage.vue";
|
||||
import RunDetailPage from "@/pages/RunDetailPage.vue";
|
||||
import SettingsPage from "@/pages/SettingsPage.vue";
|
||||
import AdminUsersPage from "@/pages/AdminUsersPage.vue";
|
||||
import StyleGuidePage from "@/pages/StyleGuidePage.vue";
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
component: LoginPage,
|
||||
meta: { guestOnly: true },
|
||||
},
|
||||
{
|
||||
path: "/",
|
||||
redirect: "/dashboard",
|
||||
},
|
||||
{
|
||||
path: "/dashboard",
|
||||
name: "dashboard",
|
||||
component: DashboardPage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/scholars",
|
||||
name: "scholars",
|
||||
component: ScholarsPage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/publications",
|
||||
name: "publications",
|
||||
component: PublicationsPage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
name: "settings",
|
||||
component: SettingsPage,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/admin/style-guide",
|
||||
name: "style-guide",
|
||||
component: StyleGuidePage,
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "/admin/runs",
|
||||
name: "runs",
|
||||
component: RunsPage,
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "/admin/runs/:id",
|
||||
name: "run-detail",
|
||||
component: RunDetailPage,
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "/admin/users",
|
||||
name: "admin-users",
|
||||
component: AdminUsersPage,
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
redirect: "/dashboard",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
applyRouteGuards(router);
|
||||
|
||||
export default router;
|
||||
71
frontend/src/components/layout/AppHeader.vue
Normal file
71
frontend/src/components/layout/AppHeader.vue
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import { useThemeStore } from "@/stores/theme";
|
||||
|
||||
const theme = useThemeStore();
|
||||
|
||||
const isDarkTheme = computed(() => theme.active === "dark");
|
||||
const toggleThemeLabel = computed(() =>
|
||||
isDarkTheme.value ? "Switch to light theme" : "Switch to dark theme",
|
||||
);
|
||||
|
||||
function onToggleTheme(): void {
|
||||
theme.setPreference(isDarkTheme.value ? "light" : "dark");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="sticky top-0 z-30 border-b border-zinc-200/70 bg-white/90 backdrop-blur dark:border-zinc-800 dark:bg-zinc-900/90">
|
||||
<div class="flex min-h-[4.5rem] w-full items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<RouterLink
|
||||
to="/dashboard"
|
||||
class="rounded-sm font-display text-lg tracking-tight text-zinc-900 transition hover:text-zinc-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:text-zinc-100 dark:hover:text-zinc-300 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
||||
>
|
||||
scholarr
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end">
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
class="h-10 w-10 rounded-full p-0"
|
||||
:aria-label="toggleThemeLabel"
|
||||
:title="toggleThemeLabel"
|
||||
@click="onToggleTheme"
|
||||
>
|
||||
<span class="sr-only">{{ toggleThemeLabel }}</span>
|
||||
<svg
|
||||
v-if="isDarkTheme"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
class="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path
|
||||
d="M12 2v2.5M12 19.5V22M4.9 4.9l1.8 1.8M17.3 17.3l1.8 1.8M2 12h2.5M19.5 12H22M4.9 19.1l1.8-1.8M17.3 6.7l1.8-1.8"
|
||||
/>
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
class="h-5 w-5"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 14.5A8.5 8.5 0 1 1 9.5 3 7 7 0 0 0 21 14.5z" />
|
||||
</svg>
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
56
frontend/src/components/layout/AppNav.vue
Normal file
56
frontend/src/components/layout/AppNav.vue
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const router = useRouter();
|
||||
|
||||
const links = computed(() => {
|
||||
const base = [
|
||||
{ to: "/dashboard", label: "Dashboard" },
|
||||
{ to: "/scholars", label: "Scholars" },
|
||||
{ to: "/publications", label: "Publications" },
|
||||
{ to: "/settings", label: "Settings" },
|
||||
];
|
||||
|
||||
if (auth.isAdmin) {
|
||||
base.push({ to: "/admin/runs", label: "Runs" });
|
||||
base.push({ to: "/admin/users", label: "Users" });
|
||||
}
|
||||
|
||||
return base;
|
||||
});
|
||||
|
||||
async function onLogout(): Promise<void> {
|
||||
await auth.logout();
|
||||
await router.replace({ name: "login" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
class="min-w-0 border-b border-zinc-200 bg-white/70 px-4 py-4 dark:border-zinc-800 dark:bg-zinc-900/70 lg:h-full lg:min-h-0 lg:overflow-y-auto lg:border-b-0 lg:border-r lg:px-5 lg:py-6"
|
||||
>
|
||||
<div class="flex h-full min-h-0 flex-col gap-4">
|
||||
<nav class="grid grid-cols-2 content-start gap-2 sm:grid-cols-3 lg:grid-cols-1" aria-label="Primary">
|
||||
<RouterLink
|
||||
v-for="link in links"
|
||||
:key="link.to"
|
||||
:to="link.to"
|
||||
class="inline-flex min-h-10 min-w-0 items-center rounded-lg border border-transparent px-3 py-2 text-sm font-medium text-zinc-600 transition hover:border-zinc-300 hover:bg-zinc-50 hover:text-zinc-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:text-zinc-300 dark:hover:border-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-100 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
||||
active-class="border-zinc-900 bg-zinc-900 text-white hover:border-zinc-900 hover:bg-zinc-900 hover:text-white dark:border-brand-400 dark:bg-brand-400 dark:text-zinc-950 dark:hover:border-brand-300 dark:hover:bg-brand-300"
|
||||
>
|
||||
{{ link.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="mt-auto grid gap-2 border-t border-zinc-200 pt-3 dark:border-zinc-800">
|
||||
<p class="truncate text-xs text-zinc-500 dark:text-zinc-400">{{ auth.user?.email }}</p>
|
||||
<AppButton variant="ghost" class="w-full justify-start" @click="onLogout">Logout</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
16
frontend/src/components/layout/AppPage.vue
Normal file
16
frontend/src/components/layout/AppPage.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-stack min-w-0">
|
||||
<header class="space-y-2">
|
||||
<h1 class="page-title">{{ title }}</h1>
|
||||
<p v-if="subtitle" class="page-subtitle">{{ subtitle }}</p>
|
||||
</header>
|
||||
<slot />
|
||||
</section>
|
||||
</template>
|
||||
22
frontend/src/components/patterns/QueueHealthBadge.vue
Normal file
22
frontend/src/components/patterns/QueueHealthBadge.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
queued: number;
|
||||
retrying: number;
|
||||
dropped: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex flex-wrap items-center gap-2 rounded-full border border-zinc-300 bg-zinc-100 px-3 py-1 text-xs text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300"
|
||||
>
|
||||
<span class="font-medium">Queue</span>
|
||||
<span class="rounded-full bg-zinc-200 px-2 py-0.5 dark:bg-zinc-700">{{ props.queued }} queued</span>
|
||||
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-amber-800 dark:bg-amber-950/50 dark:text-amber-300">
|
||||
{{ props.retrying }} retrying
|
||||
</span>
|
||||
<span class="rounded-full bg-rose-100 px-2 py-0.5 text-rose-700 dark:bg-rose-950/50 dark:text-rose-300">
|
||||
{{ props.dropped }} dropped
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
18
frontend/src/components/patterns/RequestErrorPanel.vue
Normal file
18
frontend/src/components/patterns/RequestErrorPanel.vue
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import AppAlert from "@/components/ui/AppAlert.vue";
|
||||
import { useUiStore } from "@/stores/ui";
|
||||
|
||||
const ui = useUiStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="ui.globalError" class="sticky top-[4.5rem] z-20 px-4 pb-2 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto w-full max-w-7xl">
|
||||
<AppAlert tone="danger" :dismissible="true" @dismiss="ui.clearGlobalError()">
|
||||
<template #title>Request failed</template>
|
||||
<p>{{ ui.globalError.message }}</p>
|
||||
<p class="text-secondary">Request ID: {{ ui.globalError.requestId || "n/a" }}</p>
|
||||
</AppAlert>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
34
frontend/src/components/patterns/RunStatusBadge.vue
Normal file
34
frontend/src/components/patterns/RunStatusBadge.vue
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
status: string;
|
||||
}>();
|
||||
|
||||
const label = computed(() => props.status.replaceAll("_", " "));
|
||||
|
||||
const toneClass = computed(() => {
|
||||
if (props.status === "success") {
|
||||
return "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300";
|
||||
}
|
||||
if (props.status === "partial_failure") {
|
||||
return "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300";
|
||||
}
|
||||
if (props.status === "failed") {
|
||||
return "border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300";
|
||||
}
|
||||
if (props.status === "running") {
|
||||
return "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950/50 dark:text-brand-300";
|
||||
}
|
||||
return "border-zinc-300 bg-zinc-100 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-semibold capitalize"
|
||||
:class="toneClass"
|
||||
>
|
||||
{{ label }}
|
||||
</span>
|
||||
</template>
|
||||
49
frontend/src/components/ui/AppAlert.vue
Normal file
49
frontend/src/components/ui/AppAlert.vue
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
tone?: "danger" | "warning" | "info" | "success";
|
||||
dismissible?: boolean;
|
||||
}>(),
|
||||
{
|
||||
tone: "info",
|
||||
dismissible: false,
|
||||
},
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ dismiss: [] }>();
|
||||
|
||||
const toneClass = computed(() => {
|
||||
if (props.tone === "danger") {
|
||||
return "border-rose-300 bg-rose-50 text-rose-900 dark:border-rose-800 dark:bg-rose-950/45 dark:text-rose-200";
|
||||
}
|
||||
if (props.tone === "warning") {
|
||||
return "border-amber-300 bg-amber-50 text-amber-900 dark:border-amber-800 dark:bg-amber-950/45 dark:text-amber-200";
|
||||
}
|
||||
if (props.tone === "success") {
|
||||
return "border-emerald-300 bg-emerald-50 text-emerald-900 dark:border-emerald-800 dark:bg-emerald-950/45 dark:text-emerald-200";
|
||||
}
|
||||
return "border-brand-300 bg-brand-50 text-brand-900 dark:border-brand-800 dark:bg-brand-950/45 dark:text-brand-200";
|
||||
});
|
||||
|
||||
const alertRole = computed(() => (props.tone === "danger" || props.tone === "warning" ? "alert" : "status"));
|
||||
const alertLive = computed(() => (props.tone === "danger" || props.tone === "warning" ? "assertive" : "polite"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex items-start justify-between gap-3 rounded-xl border px-4 py-3 text-sm"
|
||||
:class="toneClass"
|
||||
:role="alertRole"
|
||||
:aria-live="alertLive"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<strong v-if="$slots.title" class="block font-semibold"><slot name="title" /></strong>
|
||||
<slot />
|
||||
</div>
|
||||
<AppButton v-if="props.dismissible" variant="ghost" @click="emit('dismiss')">Dismiss</AppButton>
|
||||
</div>
|
||||
</template>
|
||||
33
frontend/src/components/ui/AppBadge.vue
Normal file
33
frontend/src/components/ui/AppBadge.vue
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = defineProps<{
|
||||
tone?: "neutral" | "success" | "warning" | "danger" | "info";
|
||||
}>();
|
||||
|
||||
const toneClass = computed(() => {
|
||||
if (props.tone === "success") {
|
||||
return "border-emerald-300 bg-emerald-50 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-950/50 dark:text-emerald-300";
|
||||
}
|
||||
|
||||
if (props.tone === "warning") {
|
||||
return "border-amber-300 bg-amber-50 text-amber-800 dark:border-amber-800 dark:bg-amber-950/50 dark:text-amber-300";
|
||||
}
|
||||
|
||||
if (props.tone === "danger") {
|
||||
return "border-rose-300 bg-rose-50 text-rose-700 dark:border-rose-800 dark:bg-rose-950/50 dark:text-rose-300";
|
||||
}
|
||||
|
||||
if (props.tone === "info") {
|
||||
return "border-brand-300 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950/50 dark:text-brand-300";
|
||||
}
|
||||
|
||||
return "border-zinc-300 bg-zinc-100 text-zinc-700 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-300";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="inline-flex items-center rounded-full border px-2 py-0.5 text-xs font-medium" :class="toneClass">
|
||||
<slot />
|
||||
</span>
|
||||
</template>
|
||||
43
frontend/src/components/ui/AppButton.vue
Normal file
43
frontend/src/components/ui/AppButton.vue
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
type?: "button" | "submit" | "reset";
|
||||
disabled?: boolean;
|
||||
}>(),
|
||||
{
|
||||
variant: "primary",
|
||||
type: "button",
|
||||
disabled: false,
|
||||
},
|
||||
);
|
||||
|
||||
const variantClass = computed(() => {
|
||||
if (props.variant === "secondary") {
|
||||
return "border-zinc-300 bg-zinc-100 text-zinc-900 hover:bg-zinc-200 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100 dark:hover:bg-zinc-700";
|
||||
}
|
||||
|
||||
if (props.variant === "ghost") {
|
||||
return "border-zinc-300 bg-transparent text-zinc-700 hover:bg-zinc-100 dark:border-zinc-700 dark:text-zinc-200 dark:hover:bg-zinc-800";
|
||||
}
|
||||
|
||||
if (props.variant === "danger") {
|
||||
return "border-rose-300 bg-rose-100 text-rose-800 hover:bg-rose-200 dark:border-rose-800 dark:bg-rose-950/60 dark:text-rose-200 dark:hover:bg-rose-900/70";
|
||||
}
|
||||
|
||||
return "border-brand-700 bg-brand-700 text-white hover:bg-brand-600 dark:border-brand-400 dark:bg-brand-400 dark:text-zinc-950 dark:hover:bg-brand-300";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="inline-flex min-h-10 items-center justify-center rounded-lg border px-3 py-2 text-sm font-semibold transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 disabled:cursor-not-allowed disabled:opacity-60 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
||||
:class="variantClass"
|
||||
:type="props.type"
|
||||
:disabled="props.disabled"
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
</template>
|
||||
5
frontend/src/components/ui/AppCard.vue
Normal file
5
frontend/src/components/ui/AppCard.vue
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<template>
|
||||
<article class="min-w-0 rounded-2xl border border-zinc-200 bg-white p-5 shadow-panel dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<slot />
|
||||
</article>
|
||||
</template>
|
||||
22
frontend/src/components/ui/AppCheckbox.vue
Normal file
22
frontend/src/components/ui/AppCheckbox.vue
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
const model = defineModel<boolean>({ default: false });
|
||||
|
||||
defineProps<{
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<label class="inline-flex min-h-10 items-center gap-2 text-sm text-zinc-800 dark:text-zinc-200" :for="id">
|
||||
<input
|
||||
:id="id"
|
||||
v-model="model"
|
||||
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"
|
||||
type="checkbox"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
<span><slot>{{ label }}</slot></span>
|
||||
</label>
|
||||
</template>
|
||||
16
frontend/src/components/ui/AppEmptyState.vue
Normal file
16
frontend/src/components/ui/AppEmptyState.vue
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
title: string;
|
||||
body: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="rounded-xl border border-dashed border-zinc-300 bg-zinc-50 p-5 dark:border-zinc-700 dark:bg-zinc-900/60">
|
||||
<h3 class="font-display text-lg font-semibold text-zinc-900 dark:text-zinc-100">{{ title }}</h3>
|
||||
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">{{ body }}</p>
|
||||
<div v-if="$slots.default" class="mt-4">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
21
frontend/src/components/ui/AppInput.vue
Normal file
21
frontend/src/components/ui/AppInput.vue
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<script setup lang="ts">
|
||||
const model = defineModel<string>({ default: "" });
|
||||
|
||||
defineProps<{
|
||||
id?: string;
|
||||
type?: string;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
v-model="model"
|
||||
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"
|
||||
:id="id"
|
||||
:type="type || 'text'"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
/>
|
||||
</template>
|
||||
17
frontend/src/components/ui/AppModal.vue
Normal file
17
frontend/src/components/ui/AppModal.vue
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script setup lang="ts">
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
title: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="props.open" class="fixed inset-0 z-50 grid place-items-center bg-zinc-950/60 p-4" @click.self="emit('close')">
|
||||
<div class="w-full max-w-lg rounded-2xl border border-zinc-200 bg-white p-6 shadow-panel dark:border-zinc-800 dark:bg-zinc-900">
|
||||
<h3 class="mb-4 font-display text-xl font-semibold text-zinc-900 dark:text-zinc-100">{{ props.title }}</h3>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
19
frontend/src/components/ui/AppSelect.vue
Normal file
19
frontend/src/components/ui/AppSelect.vue
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
<script setup lang="ts">
|
||||
const model = defineModel<string>({ default: "" });
|
||||
|
||||
defineProps<{
|
||||
id?: string;
|
||||
disabled?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<select
|
||||
v-model="model"
|
||||
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 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"
|
||||
:id="id"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<slot />
|
||||
</select>
|
||||
</template>
|
||||
15
frontend/src/components/ui/AppSkeleton.vue
Normal file
15
frontend/src/components/ui/AppSkeleton.vue
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
lines?: number;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-2" role="status" aria-live="polite">
|
||||
<span
|
||||
v-for="line in lines || 3"
|
||||
:key="line"
|
||||
class="block h-3 animate-pulse rounded-full bg-zinc-200 dark:bg-zinc-800"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
31
frontend/src/components/ui/AppTable.vue
Normal file
31
frontend/src/components/ui/AppTable.vue
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
label?: string;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="max-w-full overflow-x-auto rounded-xl border border-zinc-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:border-zinc-800 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950"
|
||||
tabindex="0"
|
||||
>
|
||||
<table class="min-w-full border-collapse bg-white text-sm dark:bg-zinc-900" :aria-label="label || 'Data table'">
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(th),
|
||||
:deep(td) {
|
||||
@apply border-b border-zinc-200 px-3 py-2 text-left align-top dark:border-zinc-800;
|
||||
}
|
||||
|
||||
:deep(th) {
|
||||
@apply sticky top-0 z-10 bg-zinc-100 font-semibold text-zinc-800 dark:bg-zinc-900 dark:text-zinc-200;
|
||||
}
|
||||
|
||||
:deep(td) {
|
||||
@apply text-zinc-700 dark:text-zinc-300;
|
||||
}
|
||||
</style>
|
||||
1
frontend/src/env.d.ts
vendored
Normal file
1
frontend/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
49
frontend/src/features/admin_users/index.ts
Normal file
49
frontend/src/features/admin_users/index.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import { apiRequest } from "@/lib/api/client";
|
||||
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
is_admin: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface UsersListData {
|
||||
users: AdminUser[];
|
||||
}
|
||||
|
||||
export interface CreateAdminUserPayload {
|
||||
email: string;
|
||||
password: string;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
export async function listAdminUsers(): Promise<AdminUser[]> {
|
||||
const response = await apiRequest<UsersListData>("/admin/users", { method: "GET" });
|
||||
return response.data.users;
|
||||
}
|
||||
|
||||
export async function createAdminUser(payload: CreateAdminUserPayload): Promise<AdminUser> {
|
||||
const response = await apiRequest<AdminUser>("/admin/users", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function setAdminUserActive(userId: number, isActive: boolean): Promise<AdminUser> {
|
||||
const response = await apiRequest<AdminUser>(`/admin/users/${userId}/active`, {
|
||||
method: "PATCH",
|
||||
body: { is_active: isActive },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function resetAdminUserPassword(userId: number, newPassword: string): Promise<{ message: string }> {
|
||||
const response = await apiRequest<{ message: string }>(`/admin/users/${userId}/reset-password`, {
|
||||
method: "POST",
|
||||
body: { new_password: newPassword },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
9
frontend/src/features/auth/index.ts
Normal file
9
frontend/src/features/auth/index.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
export { fetchCsrfBootstrap, type CsrfBootstrapData } from "@/lib/api/csrf";
|
||||
export {
|
||||
fetchMe,
|
||||
loginSession,
|
||||
logoutSession,
|
||||
type AuthSessionData,
|
||||
type MessageData,
|
||||
type SessionUser,
|
||||
} from "@/lib/auth/session";
|
||||
58
frontend/src/features/dashboard/index.ts
Normal file
58
frontend/src/features/dashboard/index.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import {
|
||||
listPublications,
|
||||
type PublicationItem,
|
||||
type PublicationMode,
|
||||
} from "@/features/publications";
|
||||
import { listQueueItems, listRuns, type RunListItem } from "@/features/runs";
|
||||
|
||||
export interface QueueHealth {
|
||||
queued: number;
|
||||
retrying: number;
|
||||
dropped: number;
|
||||
}
|
||||
|
||||
export interface DashboardSnapshot {
|
||||
newCount: number;
|
||||
totalCount: number;
|
||||
mode: PublicationMode;
|
||||
latestRun: RunListItem | null;
|
||||
recentRuns: RunListItem[];
|
||||
recentPublications: PublicationItem[];
|
||||
queue: QueueHealth;
|
||||
}
|
||||
|
||||
function countQueueStatuses(statuses: string[]): QueueHealth {
|
||||
return statuses.reduce<QueueHealth>(
|
||||
(acc, status) => {
|
||||
if (status === "queued") {
|
||||
acc.queued += 1;
|
||||
} else if (status === "retrying") {
|
||||
acc.retrying += 1;
|
||||
} else if (status === "dropped") {
|
||||
acc.dropped += 1;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ queued: 0, retrying: 0, dropped: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchDashboardSnapshot(): Promise<DashboardSnapshot> {
|
||||
const [publications, runs, queueItems] = await Promise.all([
|
||||
listPublications({ mode: "new", limit: 20 }),
|
||||
listRuns({ limit: 5 }),
|
||||
listQueueItems(200),
|
||||
]);
|
||||
|
||||
const queueHealth = countQueueStatuses(queueItems.map((item) => item.status));
|
||||
|
||||
return {
|
||||
newCount: publications.new_count,
|
||||
totalCount: publications.total_count,
|
||||
mode: publications.mode,
|
||||
latestRun: runs[0] ?? null,
|
||||
recentRuns: runs,
|
||||
recentPublications: publications.publications,
|
||||
queue: queueHealth,
|
||||
};
|
||||
}
|
||||
81
frontend/src/features/publications/index.ts
Normal file
81
frontend/src/features/publications/index.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import { apiRequest } from "@/lib/api/client";
|
||||
|
||||
export type PublicationMode = "all" | "new";
|
||||
|
||||
export interface PublicationItem {
|
||||
publication_id: number;
|
||||
scholar_profile_id: number;
|
||||
scholar_label: string;
|
||||
title: string;
|
||||
year: number | null;
|
||||
citation_count: number;
|
||||
venue_text: string | null;
|
||||
pub_url: string | null;
|
||||
is_read: boolean;
|
||||
first_seen_at: string;
|
||||
is_new_in_latest_run: boolean;
|
||||
}
|
||||
|
||||
export interface PublicationsResult {
|
||||
mode: PublicationMode;
|
||||
selected_scholar_profile_id: number | null;
|
||||
new_count: number;
|
||||
total_count: number;
|
||||
publications: PublicationItem[];
|
||||
}
|
||||
|
||||
export interface PublicationsQuery {
|
||||
mode?: PublicationMode;
|
||||
scholarProfileId?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface PublicationSelection {
|
||||
scholar_profile_id: number;
|
||||
publication_id: number;
|
||||
}
|
||||
|
||||
export async function listPublications(query: PublicationsQuery = {}): Promise<PublicationsResult> {
|
||||
const params = new URLSearchParams();
|
||||
|
||||
if (query.mode) {
|
||||
params.set("mode", query.mode);
|
||||
}
|
||||
if (query.scholarProfileId) {
|
||||
params.set("scholar_profile_id", String(query.scholarProfileId));
|
||||
}
|
||||
if (query.limit) {
|
||||
params.set("limit", String(query.limit));
|
||||
}
|
||||
|
||||
const suffix = params.toString();
|
||||
const response = await apiRequest<PublicationsResult>(
|
||||
`/publications${suffix ? `?${suffix}` : ""}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function markAllRead(): Promise<{ message: string; updated_count: number }> {
|
||||
const response = await apiRequest<{ message: string; updated_count: number }>(
|
||||
"/publications/mark-all-read",
|
||||
{ method: "POST" },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function markSelectedRead(selections: PublicationSelection[]): Promise<{
|
||||
message: string;
|
||||
requested_count: number;
|
||||
updated_count: number;
|
||||
}> {
|
||||
const response = await apiRequest<{
|
||||
message: string;
|
||||
requested_count: number;
|
||||
updated_count: number;
|
||||
}>("/publications/mark-read", {
|
||||
method: "POST",
|
||||
body: { selections },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
171
frontend/src/features/runs/index.ts
Normal file
171
frontend/src/features/runs/index.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { apiRequest } from "@/lib/api/client";
|
||||
|
||||
export interface RunListItem {
|
||||
id: number;
|
||||
trigger_type: string;
|
||||
status: string;
|
||||
start_dt: string;
|
||||
end_dt: string | null;
|
||||
scholar_count: number;
|
||||
new_publication_count: number;
|
||||
failed_count: number;
|
||||
partial_count: number;
|
||||
}
|
||||
|
||||
export interface RunSummary {
|
||||
succeeded_count: number;
|
||||
failed_count: number;
|
||||
partial_count: number;
|
||||
failed_state_counts: Record<string, number>;
|
||||
failed_reason_counts: Record<string, number>;
|
||||
}
|
||||
|
||||
export interface RunScholarResult {
|
||||
scholar_profile_id: number;
|
||||
scholar_id: string;
|
||||
state: string;
|
||||
state_reason: string | null;
|
||||
outcome: string;
|
||||
attempt_count: number;
|
||||
publication_count: number;
|
||||
start_cstart: number;
|
||||
continuation_cstart: number | null;
|
||||
continuation_enqueued: boolean;
|
||||
continuation_cleared: boolean;
|
||||
warnings: string[];
|
||||
error: string | null;
|
||||
debug: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface RunDetail {
|
||||
run: RunListItem;
|
||||
summary: RunSummary;
|
||||
scholar_results: RunScholarResult[];
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
id: number;
|
||||
scholar_profile_id: number;
|
||||
scholar_label: string;
|
||||
status: string;
|
||||
reason: string;
|
||||
dropped_reason: string | null;
|
||||
attempt_count: number;
|
||||
resume_cstart: number;
|
||||
next_attempt_dt: string | null;
|
||||
updated_at: string;
|
||||
last_error: string | null;
|
||||
last_run_id: number | null;
|
||||
}
|
||||
|
||||
interface RunsListData {
|
||||
runs: RunListItem[];
|
||||
}
|
||||
|
||||
interface QueueListData {
|
||||
queue_items: QueueItem[];
|
||||
}
|
||||
|
||||
export interface RunsListQuery {
|
||||
failedOnly?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export async function listRuns(query: RunsListQuery = {}): Promise<RunListItem[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (query.failedOnly) {
|
||||
params.set("failed_only", "true");
|
||||
}
|
||||
if (query.limit) {
|
||||
params.set("limit", String(query.limit));
|
||||
}
|
||||
|
||||
const suffix = params.toString();
|
||||
const response = await apiRequest<RunsListData>(`/runs${suffix ? `?${suffix}` : ""}`, {
|
||||
method: "GET",
|
||||
});
|
||||
return response.data.runs;
|
||||
}
|
||||
|
||||
export async function getRunDetail(runId: number): Promise<RunDetail> {
|
||||
const response = await apiRequest<RunDetail>(`/runs/${runId}`, { method: "GET" });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
function generateIdempotencyKey(): string {
|
||||
const randomUuid = globalThis.crypto?.randomUUID?.();
|
||||
if (randomUuid) {
|
||||
return randomUuid;
|
||||
}
|
||||
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
export async function triggerManualRun(): Promise<{
|
||||
run_id: number;
|
||||
status: string;
|
||||
scholar_count: number;
|
||||
succeeded_count: number;
|
||||
failed_count: number;
|
||||
partial_count: number;
|
||||
new_publication_count: number;
|
||||
reused_existing_run: boolean;
|
||||
idempotency_key: string | null;
|
||||
}> {
|
||||
const headers: Record<string, string> = {
|
||||
"Idempotency-Key": generateIdempotencyKey(),
|
||||
};
|
||||
|
||||
const response = await apiRequest<{
|
||||
run_id: number;
|
||||
status: string;
|
||||
scholar_count: number;
|
||||
succeeded_count: number;
|
||||
failed_count: number;
|
||||
partial_count: number;
|
||||
new_publication_count: number;
|
||||
reused_existing_run: boolean;
|
||||
idempotency_key: string | null;
|
||||
}>("/runs/manual", {
|
||||
method: "POST",
|
||||
headers,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listQueueItems(limit = 200): Promise<QueueItem[]> {
|
||||
const response = await apiRequest<QueueListData>(`/runs/queue/items?limit=${limit}`, {
|
||||
method: "GET",
|
||||
});
|
||||
return response.data.queue_items;
|
||||
}
|
||||
|
||||
export async function retryQueueItem(queueItemId: number): Promise<QueueItem> {
|
||||
const response = await apiRequest<QueueItem>(`/runs/queue/${queueItemId}/retry`, {
|
||||
method: "POST",
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function dropQueueItem(queueItemId: number): Promise<QueueItem> {
|
||||
const response = await apiRequest<QueueItem>(`/runs/queue/${queueItemId}/drop`, {
|
||||
method: "POST",
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function clearQueueItem(queueItemId: number): Promise<{
|
||||
queue_item_id: number;
|
||||
previous_status: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}> {
|
||||
const response = await apiRequest<{
|
||||
queue_item_id: number;
|
||||
previous_status: string;
|
||||
status: string;
|
||||
message: string;
|
||||
}>(`/runs/queue/${queueItemId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
117
frontend/src/features/scholars/index.ts
Normal file
117
frontend/src/features/scholars/index.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { apiRequest } from "@/lib/api/client";
|
||||
|
||||
export interface ScholarProfile {
|
||||
id: number;
|
||||
scholar_id: string;
|
||||
display_name: string | null;
|
||||
profile_image_url: string | null;
|
||||
profile_image_source: "upload" | "override" | "scraped" | "none";
|
||||
is_enabled: boolean;
|
||||
baseline_completed: boolean;
|
||||
last_run_dt: string | null;
|
||||
last_run_status: string | null;
|
||||
}
|
||||
|
||||
export interface ScholarCreatePayload {
|
||||
scholar_id: string;
|
||||
profile_image_url?: string;
|
||||
}
|
||||
|
||||
export interface ScholarSearchCandidate {
|
||||
scholar_id: string;
|
||||
display_name: string;
|
||||
affiliation: string | null;
|
||||
email_domain: string | null;
|
||||
cited_by_count: number | null;
|
||||
interests: string[];
|
||||
profile_url: string;
|
||||
profile_image_url: string | null;
|
||||
}
|
||||
|
||||
export interface ScholarSearchResult {
|
||||
query: string;
|
||||
state: "ok" | "no_results" | "blocked_or_captcha" | "layout_changed" | "network_error";
|
||||
state_reason: string;
|
||||
action_hint: string | null;
|
||||
candidates: ScholarSearchCandidate[];
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface ScholarsListData {
|
||||
scholars: ScholarProfile[];
|
||||
}
|
||||
|
||||
interface ScholarSearchData extends ScholarSearchResult {}
|
||||
|
||||
export async function listScholars(): Promise<ScholarProfile[]> {
|
||||
const response = await apiRequest<ScholarsListData>("/scholars", { method: "GET" });
|
||||
return response.data.scholars;
|
||||
}
|
||||
|
||||
export async function createScholar(payload: ScholarCreatePayload): Promise<ScholarProfile> {
|
||||
const response = await apiRequest<ScholarProfile>("/scholars", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function toggleScholar(scholarProfileId: number): Promise<ScholarProfile> {
|
||||
const response = await apiRequest<ScholarProfile>(`/scholars/${scholarProfileId}/toggle`, {
|
||||
method: "PATCH",
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function deleteScholar(scholarProfileId: number): Promise<void> {
|
||||
await apiRequest<{ message: string }>(`/scholars/${scholarProfileId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchScholarsByName(
|
||||
query: string,
|
||||
limit = 10,
|
||||
): Promise<ScholarSearchResult> {
|
||||
const searchParams = new URLSearchParams({
|
||||
query,
|
||||
limit: String(limit),
|
||||
});
|
||||
const response = await apiRequest<ScholarSearchData>(`/scholars/search?${searchParams.toString()}`, {
|
||||
method: "GET",
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function setScholarImageUrl(
|
||||
scholarProfileId: number,
|
||||
imageUrl: string,
|
||||
): Promise<ScholarProfile> {
|
||||
const response = await apiRequest<ScholarProfile>(`/scholars/${scholarProfileId}/image/url`, {
|
||||
method: "PUT",
|
||||
body: { image_url: imageUrl },
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function uploadScholarImage(
|
||||
scholarProfileId: number,
|
||||
file: File,
|
||||
): Promise<ScholarProfile> {
|
||||
const form = new FormData();
|
||||
form.append("image", file);
|
||||
const response = await apiRequest<ScholarProfile>(`/scholars/${scholarProfileId}/image/upload`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function clearScholarImage(
|
||||
scholarProfileId: number,
|
||||
): Promise<ScholarProfile> {
|
||||
const response = await apiRequest<ScholarProfile>(`/scholars/${scholarProfileId}/image`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
34
frontend/src/features/settings/index.ts
Normal file
34
frontend/src/features/settings/index.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { apiRequest } from "@/lib/api/client";
|
||||
|
||||
export interface UserSettings {
|
||||
auto_run_enabled: boolean;
|
||||
run_interval_minutes: number;
|
||||
request_delay_seconds: number;
|
||||
}
|
||||
|
||||
export interface ChangePasswordPayload {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
confirm_password: string;
|
||||
}
|
||||
|
||||
export async function fetchSettings(): Promise<UserSettings> {
|
||||
const response = await apiRequest<UserSettings>("/settings", { method: "GET" });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function updateSettings(payload: UserSettings): Promise<UserSettings> {
|
||||
const response = await apiRequest<UserSettings>("/settings", {
|
||||
method: "PUT",
|
||||
body: payload,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function changePassword(payload: ChangePasswordPayload): Promise<{ message: string }> {
|
||||
const response = await apiRequest<{ message: string }>("/auth/change-password", {
|
||||
method: "POST",
|
||||
body: payload,
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
102
frontend/src/lib/api/client.ts
Normal file
102
frontend/src/lib/api/client.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import {
|
||||
isErrorEnvelope,
|
||||
isSuccessEnvelope,
|
||||
readRequestId,
|
||||
type ApiSuccessEnvelope,
|
||||
} from "@/lib/api/envelope";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
||||
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
|
||||
export interface ApiRequestOptions {
|
||||
method?: HttpMethod;
|
||||
body?: unknown | FormData;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
const UNSAFE_METHODS = new Set<HttpMethod>(["POST", "PUT", "PATCH", "DELETE"]);
|
||||
const API_BASE = "/api/v1";
|
||||
|
||||
let csrfTokenProvider: (() => string | null) | null = null;
|
||||
|
||||
export function setCsrfTokenProvider(provider: () => string | null): void {
|
||||
csrfTokenProvider = provider;
|
||||
}
|
||||
|
||||
export async function apiRequest<T>(path: string, options: ApiRequestOptions = {}): Promise<ApiSuccessEnvelope<T>> {
|
||||
const method = options.method ?? "GET";
|
||||
const headers = new Headers(options.headers ?? {});
|
||||
headers.set("Accept", "application/json");
|
||||
|
||||
const hasBody = options.body !== undefined;
|
||||
const isFormData = typeof FormData !== "undefined" && options.body instanceof FormData;
|
||||
let requestBody: BodyInit | undefined;
|
||||
|
||||
if (hasBody && !isFormData && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
|
||||
if (hasBody) {
|
||||
requestBody = isFormData ? (options.body as FormData) : JSON.stringify(options.body);
|
||||
}
|
||||
|
||||
if (UNSAFE_METHODS.has(method) && csrfTokenProvider) {
|
||||
const csrfToken = csrfTokenProvider();
|
||||
if (csrfToken) {
|
||||
headers.set("X-CSRF-Token", csrfToken);
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
credentials: "include",
|
||||
body: requestBody,
|
||||
});
|
||||
|
||||
const raw = await parseResponseBody(response);
|
||||
const requestId = readRequestId(raw) ?? response.headers.get("X-Request-ID");
|
||||
|
||||
if (!response.ok) {
|
||||
if (isErrorEnvelope(raw)) {
|
||||
throw new ApiRequestError({
|
||||
status: response.status,
|
||||
code: raw.error.code || "error",
|
||||
message: raw.error.message || "Request failed.",
|
||||
details: raw.error.details,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
throw new ApiRequestError({
|
||||
status: response.status,
|
||||
code: "http_error",
|
||||
message: `Request failed with status ${response.status}.`,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
if (!isSuccessEnvelope<T>(raw)) {
|
||||
throw new ApiRequestError({
|
||||
status: response.status,
|
||||
code: "invalid_envelope",
|
||||
message: "Server returned an unexpected response format.",
|
||||
requestId,
|
||||
details: raw,
|
||||
});
|
||||
}
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function parseResponseBody(response: Response): Promise<unknown> {
|
||||
const contentType = response.headers.get("Content-Type") || "";
|
||||
if (!contentType.includes("application/json")) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return await response.json();
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
10
frontend/src/lib/api/csrf.ts
Normal file
10
frontend/src/lib/api/csrf.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { apiRequest } from "@/lib/api/client";
|
||||
|
||||
export interface CsrfBootstrapData {
|
||||
csrf_token: string;
|
||||
authenticated: boolean;
|
||||
}
|
||||
|
||||
export async function fetchCsrfBootstrap() {
|
||||
return apiRequest<CsrfBootstrapData>("/auth/csrf", { method: "GET" });
|
||||
}
|
||||
43
frontend/src/lib/api/envelope.test.ts
Normal file
43
frontend/src/lib/api/envelope.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
isErrorEnvelope,
|
||||
isSuccessEnvelope,
|
||||
readRequestId,
|
||||
type ApiSuccessEnvelope,
|
||||
} from "@/lib/api/envelope";
|
||||
|
||||
describe("api envelope helpers", () => {
|
||||
it("recognizes a success envelope", () => {
|
||||
const payload: ApiSuccessEnvelope<{ ok: boolean }> = {
|
||||
data: { ok: true },
|
||||
meta: { request_id: "req_123" },
|
||||
};
|
||||
|
||||
expect(isSuccessEnvelope(payload)).toBe(true);
|
||||
expect(isErrorEnvelope(payload)).toBe(false);
|
||||
expect(readRequestId(payload)).toBe("req_123");
|
||||
});
|
||||
|
||||
it("recognizes an error envelope", () => {
|
||||
const payload = {
|
||||
error: {
|
||||
code: "invalid",
|
||||
message: "Invalid request",
|
||||
details: null,
|
||||
},
|
||||
meta: { request_id: "req_456" },
|
||||
};
|
||||
|
||||
expect(isErrorEnvelope(payload)).toBe(true);
|
||||
expect(isSuccessEnvelope(payload)).toBe(false);
|
||||
expect(readRequestId(payload)).toBe("req_456");
|
||||
});
|
||||
|
||||
it("returns null when request id is missing or invalid", () => {
|
||||
expect(readRequestId(null)).toBeNull();
|
||||
expect(readRequestId({})).toBeNull();
|
||||
expect(readRequestId({ meta: {} })).toBeNull();
|
||||
expect(readRequestId({ meta: { request_id: "" } })).toBeNull();
|
||||
});
|
||||
});
|
||||
44
frontend/src/lib/api/envelope.ts
Normal file
44
frontend/src/lib/api/envelope.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import type { ApiErrorPayload } from "@/lib/api/errors";
|
||||
|
||||
export interface ApiMeta {
|
||||
request_id: string | null;
|
||||
}
|
||||
|
||||
export interface ApiSuccessEnvelope<T> {
|
||||
data: T;
|
||||
meta: ApiMeta;
|
||||
}
|
||||
|
||||
export interface ApiErrorEnvelope {
|
||||
error: ApiErrorPayload;
|
||||
meta: ApiMeta;
|
||||
}
|
||||
|
||||
export function isSuccessEnvelope<T>(value: unknown): value is ApiSuccessEnvelope<T> {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
return "data" in value && "meta" in value;
|
||||
}
|
||||
|
||||
export function isErrorEnvelope(value: unknown): value is ApiErrorEnvelope {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return false;
|
||||
}
|
||||
return "error" in value && "meta" in value;
|
||||
}
|
||||
|
||||
export function readRequestId(payload: unknown): string | null {
|
||||
if (typeof payload !== "object" || payload === null) {
|
||||
return null;
|
||||
}
|
||||
const meta = (payload as { meta?: unknown }).meta;
|
||||
if (typeof meta !== "object" || meta === null) {
|
||||
return null;
|
||||
}
|
||||
const requestId = (meta as { request_id?: unknown }).request_id;
|
||||
if (typeof requestId !== "string" || !requestId.trim()) {
|
||||
return null;
|
||||
}
|
||||
return requestId;
|
||||
}
|
||||
22
frontend/src/lib/api/errors.test.ts
Normal file
22
frontend/src/lib/api/errors.test.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
||||
describe("ApiRequestError", () => {
|
||||
it("preserves structured metadata", () => {
|
||||
const error = new ApiRequestError({
|
||||
status: 409,
|
||||
code: "run_in_progress",
|
||||
message: "A run is already in progress",
|
||||
details: { run_id: 42 },
|
||||
requestId: "req_789",
|
||||
});
|
||||
|
||||
expect(error.name).toBe("ApiRequestError");
|
||||
expect(error.status).toBe(409);
|
||||
expect(error.code).toBe("run_in_progress");
|
||||
expect(error.message).toBe("A run is already in progress");
|
||||
expect(error.details).toEqual({ run_id: 42 });
|
||||
expect(error.requestId).toBe("req_789");
|
||||
});
|
||||
});
|
||||
27
frontend/src/lib/api/errors.ts
Normal file
27
frontend/src/lib/api/errors.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export interface ApiErrorPayload {
|
||||
code: string;
|
||||
message: string;
|
||||
details: unknown;
|
||||
}
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly details: unknown;
|
||||
readonly requestId: string | null;
|
||||
|
||||
constructor(params: {
|
||||
status: number;
|
||||
code: string;
|
||||
message: string;
|
||||
details?: unknown;
|
||||
requestId?: string | null;
|
||||
}) {
|
||||
super(params.message);
|
||||
this.name = "ApiRequestError";
|
||||
this.status = params.status;
|
||||
this.code = params.code;
|
||||
this.details = params.details ?? null;
|
||||
this.requestId = params.requestId ?? null;
|
||||
}
|
||||
}
|
||||
35
frontend/src/lib/auth/session.ts
Normal file
35
frontend/src/lib/auth/session.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import { apiRequest } from "@/lib/api/client";
|
||||
|
||||
export interface SessionUser {
|
||||
id: number;
|
||||
email: string;
|
||||
is_admin: boolean;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface AuthSessionData {
|
||||
authenticated: boolean;
|
||||
csrf_token: string;
|
||||
user: SessionUser;
|
||||
}
|
||||
|
||||
export interface MessageData {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export async function fetchMe() {
|
||||
return apiRequest<AuthSessionData>("/auth/me", { method: "GET" });
|
||||
}
|
||||
|
||||
export async function loginSession(params: { email: string; password: string }) {
|
||||
return apiRequest<AuthSessionData>("/auth/login", {
|
||||
method: "POST",
|
||||
body: params,
|
||||
});
|
||||
}
|
||||
|
||||
export async function logoutSession() {
|
||||
return apiRequest<MessageData>("/auth/logout", {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
8
frontend/src/lib/utils/request_id.ts
Normal file
8
frontend/src/lib/utils/request_id.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
||||
export function toRequestId(value: unknown): string | null {
|
||||
if (value instanceof ApiRequestError) {
|
||||
return value.requestId;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
22
frontend/src/main.ts
Normal file
22
frontend/src/main.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
import AppShell from "@/app/AppShell.vue";
|
||||
import router from "@/app/router";
|
||||
import { bootstrapAppProviders } from "@/app/providers";
|
||||
import "@/styles.css";
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const app = createApp(AppShell);
|
||||
const pinia = createPinia();
|
||||
|
||||
app.use(pinia);
|
||||
await bootstrapAppProviders();
|
||||
|
||||
app.use(router);
|
||||
await router.isReady();
|
||||
|
||||
app.mount("#app");
|
||||
}
|
||||
|
||||
void main();
|
||||
199
frontend/src/pages/AdminUsersPage.vue
Normal file
199
frontend/src/pages/AdminUsersPage.vue
Normal 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>
|
||||
187
frontend/src/pages/DashboardPage.vue
Normal file
187
frontend/src/pages/DashboardPage.vue
Normal 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>
|
||||
129
frontend/src/pages/LoginPage.vue
Normal file
129
frontend/src/pages/LoginPage.vue
Normal 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>
|
||||
449
frontend/src/pages/PublicationsPage.vue
Normal file
449
frontend/src/pages/PublicationsPage.vue
Normal 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>
|
||||
134
frontend/src/pages/RunDetailPage.vue
Normal file
134
frontend/src/pages/RunDetailPage.vue
Normal 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>
|
||||
266
frontend/src/pages/RunsPage.vue
Normal file
266
frontend/src/pages/RunsPage.vue
Normal 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>
|
||||
921
frontend/src/pages/ScholarsPage.vue
Normal file
921
frontend/src/pages/ScholarsPage.vue
Normal 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>
|
||||
255
frontend/src/pages/SettingsPage.vue
Normal file
255
frontend/src/pages/SettingsPage.vue
Normal 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>
|
||||
138
frontend/src/pages/StyleGuidePage.vue
Normal file
138
frontend/src/pages/StyleGuidePage.vue
Normal 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>
|
||||
71
frontend/src/stores/auth.ts
Normal file
71
frontend/src/stores/auth.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { defineStore } from "pinia";
|
||||
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
import { fetchCsrfBootstrap } from "@/lib/api/csrf";
|
||||
import { fetchMe, loginSession, logoutSession, type SessionUser } from "@/lib/auth/session";
|
||||
import { useUiStore } from "@/stores/ui";
|
||||
|
||||
export type AuthState = "unknown" | "authenticated" | "anonymous";
|
||||
|
||||
export const useAuthStore = defineStore("auth", {
|
||||
state: () => ({
|
||||
state: "unknown" as AuthState,
|
||||
csrfToken: null as string | null,
|
||||
user: null as SessionUser | null,
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (state) => state.state === "authenticated",
|
||||
isAdmin: (state) => Boolean(state.user?.is_admin),
|
||||
},
|
||||
actions: {
|
||||
async bootstrapSession(): Promise<void> {
|
||||
const ui = useUiStore();
|
||||
ui.clearGlobalError();
|
||||
|
||||
try {
|
||||
const csrf = await fetchCsrfBootstrap();
|
||||
this.csrfToken = csrf.data.csrf_token;
|
||||
|
||||
if (!csrf.data.authenticated) {
|
||||
this.state = "anonymous";
|
||||
this.user = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const me = await fetchMe();
|
||||
this.state = "authenticated";
|
||||
this.user = me.data.user;
|
||||
this.csrfToken = me.data.csrf_token;
|
||||
} catch (error) {
|
||||
this.state = "anonymous";
|
||||
this.user = null;
|
||||
|
||||
if (error instanceof ApiRequestError) {
|
||||
ui.setGlobalError({
|
||||
message: error.message,
|
||||
requestId: error.requestId,
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
async login(email: string, password: string): Promise<void> {
|
||||
const response = await loginSession({ email, password });
|
||||
this.state = "authenticated";
|
||||
this.user = response.data.user;
|
||||
this.csrfToken = response.data.csrf_token;
|
||||
},
|
||||
async logout(): Promise<void> {
|
||||
await logoutSession();
|
||||
this.state = "anonymous";
|
||||
this.user = null;
|
||||
this.csrfToken = null;
|
||||
|
||||
try {
|
||||
const csrf = await fetchCsrfBootstrap();
|
||||
this.csrfToken = csrf.data.csrf_token;
|
||||
} catch (_err) {
|
||||
// No-op: app can still function by refreshing.
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
51
frontend/src/stores/theme.ts
Normal file
51
frontend/src/stores/theme.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { defineStore } from "pinia";
|
||||
|
||||
export type ThemePreference = "system" | "light" | "dark";
|
||||
export type ThemeValue = "light" | "dark";
|
||||
|
||||
const STORAGE_KEY = "scholarr-theme-preference";
|
||||
|
||||
function systemTheme(): ThemeValue {
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme: ThemeValue, preference: ThemePreference): void {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle("dark", theme === "dark");
|
||||
root.setAttribute("data-theme-preference", preference);
|
||||
}
|
||||
|
||||
export const useThemeStore = defineStore("theme", {
|
||||
state: () => ({
|
||||
preference: "system" as ThemePreference,
|
||||
active: "light" as ThemeValue,
|
||||
}),
|
||||
actions: {
|
||||
initialize(): void {
|
||||
let stored: string | null = null;
|
||||
try {
|
||||
stored = localStorage.getItem(STORAGE_KEY);
|
||||
} catch (_err) {
|
||||
stored = null;
|
||||
}
|
||||
|
||||
if (stored === "light" || stored === "dark" || stored === "system") {
|
||||
this.preference = stored;
|
||||
}
|
||||
|
||||
this.active = this.preference === "system" ? systemTheme() : this.preference;
|
||||
applyTheme(this.active, this.preference);
|
||||
},
|
||||
setPreference(preference: ThemePreference): void {
|
||||
this.preference = preference;
|
||||
this.active = preference === "system" ? systemTheme() : preference;
|
||||
applyTheme(this.active, this.preference);
|
||||
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, preference);
|
||||
} catch (_err) {
|
||||
// Ignore storage failures in private contexts.
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
20
frontend/src/stores/ui.ts
Normal file
20
frontend/src/stores/ui.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { defineStore } from "pinia";
|
||||
|
||||
export interface GlobalErrorState {
|
||||
message: string;
|
||||
requestId: string | null;
|
||||
}
|
||||
|
||||
export const useUiStore = defineStore("ui", {
|
||||
state: () => ({
|
||||
globalError: null as GlobalErrorState | null,
|
||||
}),
|
||||
actions: {
|
||||
setGlobalError(error: GlobalErrorState): void {
|
||||
this.globalError = error;
|
||||
},
|
||||
clearGlobalError(): void {
|
||||
this.globalError = null;
|
||||
},
|
||||
},
|
||||
});
|
||||
69
frontend/src/styles.css
Normal file
69
frontend/src/styles.css
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
@import url("https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Space+Grotesk:wght@500;600;700&display=swap");
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
@apply min-h-full overflow-x-clip;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-zinc-100 text-zinc-900 antialiased transition-colors duration-300 dark:bg-zinc-950 dark:text-zinc-100;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
@apply font-display;
|
||||
}
|
||||
|
||||
a {
|
||||
@apply text-inherit no-underline;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea,
|
||||
button {
|
||||
font-family: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.skip-link {
|
||||
@apply sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-3 focus:z-50 focus:rounded-md focus:bg-zinc-900 focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:text-white focus:outline-none dark:focus:bg-brand-400 dark:focus:text-zinc-950;
|
||||
}
|
||||
|
||||
.page-stack {
|
||||
@apply space-y-6;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
@apply font-display text-3xl font-semibold tracking-tight text-zinc-900 sm:text-4xl dark:text-zinc-100;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
@apply text-sm leading-relaxed text-zinc-600 dark:text-zinc-400;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
@apply text-zinc-600 dark:text-zinc-400;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
@apply text-zinc-500 dark:text-zinc-500;
|
||||
}
|
||||
|
||||
.row {
|
||||
@apply flex items-center gap-3;
|
||||
}
|
||||
|
||||
.link-inline {
|
||||
@apply rounded-sm text-brand-700 underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:text-brand-300 dark:focus-visible:ring-brand-400 dark:focus-visible:ring-offset-zinc-950;
|
||||
}
|
||||
}
|
||||
32
frontend/tailwind.config.js
Normal file
32
frontend/tailwind.config.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
darkMode: "class",
|
||||
content: ["./index.html", "./src/**/*.{vue,ts,tsx,js,jsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Manrope", "ui-sans-serif", "system-ui", "sans-serif"],
|
||||
display: ["Space Grotesk", "Manrope", "ui-sans-serif", "system-ui", "sans-serif"],
|
||||
},
|
||||
boxShadow: {
|
||||
panel: "0 10px 30px -20px rgba(8, 15, 30, 0.45)",
|
||||
},
|
||||
colors: {
|
||||
brand: {
|
||||
50: "#ecfeff",
|
||||
100: "#cffafe",
|
||||
200: "#a5f3fc",
|
||||
300: "#67e8f9",
|
||||
400: "#22d3ee",
|
||||
500: "#06b6d4",
|
||||
600: "#0891b2",
|
||||
700: "#0e7490",
|
||||
800: "#155e75",
|
||||
900: "#164e63",
|
||||
950: "#083344",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
19
frontend/tsconfig.json
Normal file
19
frontend/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"jsx": "preserve",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"esModuleInterop": true,
|
||||
"lib": ["ES2022", "ESNext", "DOM", "DOM.Iterable"],
|
||||
"types": ["vite/client", "node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"]
|
||||
}
|
||||
26
frontend/vite.config.ts
Normal file
26
frontend/vite.config.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import { defineConfig } from "vite";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import path from "node:path";
|
||||
|
||||
const devApiProxyTarget = process.env.VITE_DEV_API_PROXY_TARGET ?? "http://localhost:8000";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
server: {
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: devApiProxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/healthz": {
|
||||
target: devApiProxyTarget,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
});
|
||||
16
frontend/vitest.config.ts
Normal file
16
frontend/vitest.config.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import path from "node:path";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "src"),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["src/**/*.test.ts"],
|
||||
},
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue