Big changes
This commit is contained in:
parent
4240ad38e2
commit
e3f0d63fec
99 changed files with 8804 additions and 1731 deletions
|
|
@ -8,7 +8,6 @@ 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({
|
||||
|
|
@ -66,12 +65,6 @@ export const router = createRouter({
|
|||
component: RunDetailPage,
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "/admin/users",
|
||||
name: "admin-users",
|
||||
component: AdminUsersPage,
|
||||
meta: { requiresAuth: true, requiresAdmin: true },
|
||||
},
|
||||
{
|
||||
path: "/:pathMatch(.*)*",
|
||||
redirect: "/dashboard",
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@
|
|||
import { computed } from "vue";
|
||||
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||
import { useThemeStore } from "@/stores/theme";
|
||||
import type { ThemePresetId } from "@/theme/presets";
|
||||
import AppBrandMark from "@/components/ui/AppBrandMark.vue";
|
||||
import AppThemePicker from "@/components/ui/AppThemePicker.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
|
|
@ -20,25 +19,9 @@ const emit = defineEmits<{
|
|||
(e: "toggle-menu"): void;
|
||||
}>();
|
||||
|
||||
const theme = useThemeStore();
|
||||
|
||||
const isDarkTheme = computed(() => theme.active === "dark");
|
||||
const toggleThemeLabel = computed(() =>
|
||||
isDarkTheme.value ? "Switch to light theme" : "Switch to dark theme",
|
||||
);
|
||||
const menuButtonLabel = computed(() =>
|
||||
props.menuOpen ? "Close navigation menu" : "Open navigation menu",
|
||||
);
|
||||
const selectedPreset = computed<ThemePresetId>({
|
||||
get: () => theme.preset,
|
||||
set: (value) => theme.setPreset(value),
|
||||
});
|
||||
const presetOptions = computed(() => theme.availablePresets);
|
||||
const themePresetLabel = computed(() => theme.presetLabel);
|
||||
|
||||
function onToggleTheme(): void {
|
||||
theme.setPreference(isDarkTheme.value ? "light" : "dark");
|
||||
}
|
||||
|
||||
function onToggleMenu(): void {
|
||||
emit("toggle-menu");
|
||||
|
|
@ -75,64 +58,14 @@ function onToggleMenu(): void {
|
|||
</AppButton>
|
||||
<RouterLink
|
||||
to="/dashboard"
|
||||
class="rounded-sm font-display text-lg tracking-tight text-ink-primary transition hover:text-ink-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||
class="inline-flex items-center gap-2.5 rounded-sm font-display text-xl tracking-tight text-ink-primary transition hover:text-ink-secondary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||
>
|
||||
scholarr
|
||||
<AppBrandMark size="lg" />
|
||||
<span>scholarr</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<div class="w-36 sm:w-44">
|
||||
<label for="theme-preset-select" class="sr-only">Theme preset</label>
|
||||
<AppSelect
|
||||
id="theme-preset-select"
|
||||
v-model="selectedPreset"
|
||||
:disabled="presetOptions.length <= 1"
|
||||
:title="`Theme preset: ${themePresetLabel}`"
|
||||
class="py-1.5"
|
||||
>
|
||||
<option v-for="preset in presetOptions" :key="preset.id" :value="preset.id">
|
||||
{{ preset.label }}
|
||||
</option>
|
||||
</AppSelect>
|
||||
</div>
|
||||
<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>
|
||||
<AppThemePicker id-prefix="header-theme" />
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -19,22 +19,12 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const links = computed(() => {
|
||||
const base = [
|
||||
{ id: "dashboard", to: "/dashboard", label: "Dashboard", adminOnly: false },
|
||||
{ id: "scholars", to: "/scholars", label: "Scholars", adminOnly: false },
|
||||
{ id: "publications", to: "/publications", label: "Publications", adminOnly: false },
|
||||
{ id: "settings", to: "/settings", label: "Settings", adminOnly: false },
|
||||
{ id: "style-guide", to: "/admin/style-guide", label: "Style Guide", adminOnly: true },
|
||||
{ id: "runs", to: "/admin/runs", label: "Runs", adminOnly: true },
|
||||
{ id: "users", to: "/admin/users", label: "Users", adminOnly: true },
|
||||
return [
|
||||
{ id: "dashboard", to: "/dashboard", label: "Dashboard" },
|
||||
{ id: "scholars", to: "/scholars", label: "Scholars" },
|
||||
{ id: "publications", to: "/publications", label: "Publications" },
|
||||
{ id: "settings", to: "/settings", label: "Settings" },
|
||||
];
|
||||
|
||||
return base.filter((item) => {
|
||||
if (item.adminOnly && !auth.isAdmin) {
|
||||
return false;
|
||||
}
|
||||
return userSettings.isPageVisible(item.id);
|
||||
});
|
||||
});
|
||||
const navSafetyText = computed(() => {
|
||||
if (!userSettings.manualRunAllowed) {
|
||||
|
|
|
|||
55
frontend/src/components/ui/AppBrandMark.vue
Normal file
55
frontend/src/components/ui/AppBrandMark.vue
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
import brandLogo from "@/logo.png";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
size?: "sm" | "md" | "lg" | "xl";
|
||||
}>(),
|
||||
{
|
||||
size: "md",
|
||||
},
|
||||
);
|
||||
|
||||
const sizeClass = computed(() => {
|
||||
if (props.size === "sm") {
|
||||
return "h-5 w-5";
|
||||
}
|
||||
if (props.size === "lg") {
|
||||
return "h-8 w-8";
|
||||
}
|
||||
if (props.size === "xl") {
|
||||
return "h-12 w-12";
|
||||
}
|
||||
return "h-6 w-6";
|
||||
});
|
||||
|
||||
const logoMaskStyle: Record<string, string> = {
|
||||
"--brand-logo-mask": `url(${brandLogo})`,
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
:class="['brand-logo-mark', sizeClass]"
|
||||
:style="logoMaskStyle"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.brand-logo-mark {
|
||||
display: inline-block;
|
||||
flex-shrink: 0;
|
||||
background-color: rgb(var(--color-brand-700));
|
||||
-webkit-mask-image: var(--brand-logo-mask);
|
||||
mask-image: var(--brand-logo-mask);
|
||||
-webkit-mask-position: center;
|
||||
mask-position: center;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-size: contain;
|
||||
mask-size: contain;
|
||||
}
|
||||
</style>
|
||||
61
frontend/src/components/ui/AppRefreshButton.vue
Normal file
61
frontend/src/components/ui/AppRefreshButton.vue
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
title?: string;
|
||||
loadingTitle?: string;
|
||||
variant?: "primary" | "secondary" | "ghost" | "danger";
|
||||
size?: "md" | "sm";
|
||||
}>(),
|
||||
{
|
||||
disabled: false,
|
||||
loading: false,
|
||||
title: "Refresh",
|
||||
loadingTitle: "Refreshing",
|
||||
variant: "secondary",
|
||||
size: "md",
|
||||
},
|
||||
);
|
||||
|
||||
const resolvedTitle = computed(() => (props.loading ? props.loadingTitle : props.title));
|
||||
const isDisabled = computed(() => props.disabled || props.loading);
|
||||
const buttonSizeClass = computed(() => {
|
||||
if (props.size === "sm") {
|
||||
return "min-h-8 h-8 w-8";
|
||||
}
|
||||
return "min-h-10 h-10 w-10";
|
||||
});
|
||||
const iconSizeClass = computed(() => (props.size === "sm" ? "h-3.5 w-3.5" : "h-4 w-4"));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppButton
|
||||
:variant="props.variant"
|
||||
:disabled="isDisabled"
|
||||
class="rounded-full p-0"
|
||||
:class="buttonSizeClass"
|
||||
:title="resolvedTitle"
|
||||
:aria-label="resolvedTitle"
|
||||
>
|
||||
<span class="sr-only">{{ resolvedTitle }}</span>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.8"
|
||||
:class="[iconSizeClass, props.loading ? 'animate-spin' : '']"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20 4v6h-6" />
|
||||
<path d="M4 20v-6h6" />
|
||||
<path d="M6.5 9A7.5 7.5 0 0 1 19 7.5L20 10" />
|
||||
<path d="M17.5 15A7.5 7.5 0 0 1 5 16.5L4 14" />
|
||||
</svg>
|
||||
</AppButton>
|
||||
</template>
|
||||
51
frontend/src/components/ui/AppTabs.vue
Normal file
51
frontend/src/components/ui/AppTabs.vue
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
<script setup lang="ts">
|
||||
export interface AppTabItem {
|
||||
id: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string;
|
||||
items: AppTabItem[];
|
||||
ariaLabel?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "update:modelValue", value: string): void;
|
||||
}>();
|
||||
|
||||
function onSelect(tabId: string, disabled: boolean | undefined): void {
|
||||
if (disabled || tabId === props.modelValue) {
|
||||
return;
|
||||
}
|
||||
emit("update:modelValue", tabId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex flex-wrap gap-2 rounded-lg border border-stroke-default bg-surface-card-muted p-2"
|
||||
role="tablist"
|
||||
:aria-label="props.ariaLabel || 'Tabs'"
|
||||
>
|
||||
<button
|
||||
v-for="item in props.items"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
role="tab"
|
||||
:aria-selected="item.id === props.modelValue"
|
||||
:tabindex="item.id === props.modelValue ? 0 : -1"
|
||||
:disabled="item.disabled"
|
||||
class="inline-flex min-h-9 items-center rounded-md border px-3 py-1.5 text-sm font-medium transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
|
||||
:class="
|
||||
item.id === props.modelValue
|
||||
? 'border-stroke-interactive bg-surface-nav-active text-ink-primary'
|
||||
: 'border-stroke-default bg-surface-card text-ink-secondary hover:border-stroke-interactive hover:text-ink-primary'
|
||||
"
|
||||
@click="onSelect(item.id, item.disabled)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
95
frontend/src/components/ui/AppThemePicker.vue
Normal file
95
frontend/src/components/ui/AppThemePicker.vue
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||
import { useThemeStore } from "@/stores/theme";
|
||||
import type { ThemePresetId } from "@/theme/presets";
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
compact?: boolean;
|
||||
idPrefix?: string;
|
||||
}>(),
|
||||
{
|
||||
compact: false,
|
||||
idPrefix: "theme",
|
||||
},
|
||||
);
|
||||
|
||||
const theme = useThemeStore();
|
||||
const isDarkTheme = computed(() => theme.active === "dark");
|
||||
const toggleThemeLabel = computed(() =>
|
||||
isDarkTheme.value ? "Switch to light theme" : "Switch to dark theme",
|
||||
);
|
||||
const selectedPreset = computed<ThemePresetId>({
|
||||
get: () => theme.preset,
|
||||
set: (value) => theme.setPreset(value),
|
||||
});
|
||||
const presetOptions = computed(() => theme.availablePresets);
|
||||
const themePresetLabel = computed(() => theme.presetLabel);
|
||||
const selectId = computed(() => `${props.idPrefix}-preset-select`);
|
||||
const selectWidthClass = computed(() => (props.compact ? "w-32 sm:w-36" : "w-36 sm:w-44"));
|
||||
const toggleButtonClass = computed(() =>
|
||||
props.compact ? "h-9 w-9 rounded-full p-0" : "h-10 w-10 rounded-full p-0",
|
||||
);
|
||||
|
||||
function onToggleTheme(): void {
|
||||
theme.setPreference(isDarkTheme.value ? "light" : "dark");
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<div :class="selectWidthClass">
|
||||
<label :for="selectId" class="sr-only">Theme preset</label>
|
||||
<AppSelect
|
||||
:id="selectId"
|
||||
v-model="selectedPreset"
|
||||
:disabled="presetOptions.length <= 1"
|
||||
:title="`Theme preset: ${themePresetLabel}`"
|
||||
class="py-1.5"
|
||||
>
|
||||
<option v-for="preset in presetOptions" :key="preset.id" :value="preset.id">
|
||||
{{ preset.label }}
|
||||
</option>
|
||||
</AppSelect>
|
||||
</div>
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
:class="toggleButtonClass"
|
||||
: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>
|
||||
</template>
|
||||
153
frontend/src/features/admin_dbops/index.ts
Normal file
153
frontend/src/features/admin_dbops/index.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { apiRequest } from "@/lib/api/client";
|
||||
|
||||
export interface AdminDbIntegrityCheck {
|
||||
name: string;
|
||||
count: number;
|
||||
severity: "warning" | "failure" | "metric";
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AdminDbIntegrityReport {
|
||||
status: "ok" | "warning" | "failed";
|
||||
checked_at: string;
|
||||
failures: string[];
|
||||
warnings: string[];
|
||||
checks: AdminDbIntegrityCheck[];
|
||||
}
|
||||
|
||||
export interface AdminDbRepairJob {
|
||||
id: number;
|
||||
job_name: string;
|
||||
requested_by: string | null;
|
||||
dry_run: boolean;
|
||||
status: string;
|
||||
scope: Record<string, unknown>;
|
||||
summary: Record<string, unknown>;
|
||||
error_text: string | null;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AdminPdfQueueItem {
|
||||
publication_id: number;
|
||||
title: string;
|
||||
doi: string | null;
|
||||
pdf_url: string | null;
|
||||
status: string;
|
||||
attempt_count: number;
|
||||
last_failure_reason: string | null;
|
||||
last_failure_detail: string | null;
|
||||
last_source: string | null;
|
||||
requested_by_user_id: number | null;
|
||||
requested_by_email: string | null;
|
||||
queued_at: string | null;
|
||||
last_attempt_at: string | null;
|
||||
resolved_at: string | null;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AdminPdfQueuePage {
|
||||
items: AdminPdfQueueItem[];
|
||||
total_count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
}
|
||||
|
||||
export interface AdminPdfQueueRequeueResult {
|
||||
publication_id: number;
|
||||
queued: boolean;
|
||||
status: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface AdminPdfQueueBulkEnqueueResult {
|
||||
requested_count: number;
|
||||
queued_count: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TriggerPublicationLinkRepairPayload {
|
||||
scope_mode?: "single_user" | "all_users";
|
||||
user_id?: number;
|
||||
scholar_profile_ids?: number[];
|
||||
dry_run?: boolean;
|
||||
gc_orphan_publications?: boolean;
|
||||
requested_by?: string;
|
||||
confirmation_text?: string;
|
||||
}
|
||||
|
||||
export interface TriggerPublicationLinkRepairResult {
|
||||
job_id: number;
|
||||
status: string;
|
||||
scope: Record<string, unknown>;
|
||||
summary: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export async function getAdminDbIntegrityReport(): Promise<AdminDbIntegrityReport> {
|
||||
const response = await apiRequest<AdminDbIntegrityReport>("/admin/db/integrity", { method: "GET" });
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listAdminDbRepairJobs(limit = 30): Promise<AdminDbRepairJob[]> {
|
||||
const parsedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(200, Math.trunc(limit))) : 30;
|
||||
const response = await apiRequest<{ jobs: AdminDbRepairJob[] }>(
|
||||
`/admin/db/repair-jobs?limit=${parsedLimit}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
return response.data.jobs;
|
||||
}
|
||||
|
||||
export async function triggerPublicationLinkRepair(
|
||||
payload: TriggerPublicationLinkRepairPayload,
|
||||
): Promise<TriggerPublicationLinkRepairResult> {
|
||||
const response = await apiRequest<TriggerPublicationLinkRepairResult>(
|
||||
"/admin/db/repairs/publication-links",
|
||||
{
|
||||
method: "POST",
|
||||
body: payload,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function listAdminPdfQueue(
|
||||
page = 1,
|
||||
pageSize = 100,
|
||||
status: string | null = null,
|
||||
): Promise<AdminPdfQueuePage> {
|
||||
const parsedPage = Number.isFinite(page) ? Math.max(1, Math.trunc(page)) : 1;
|
||||
const parsedPageSize = Number.isFinite(pageSize) ? Math.max(1, Math.min(500, Math.trunc(pageSize))) : 100;
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", String(parsedPage));
|
||||
params.set("page_size", String(parsedPageSize));
|
||||
if (status && status.trim().length > 0) {
|
||||
params.set("status", status.trim().toLowerCase());
|
||||
}
|
||||
const response = await apiRequest<AdminPdfQueuePage>(
|
||||
`/admin/db/pdf-queue?${params.toString()}`,
|
||||
{ method: "GET" },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function requeueAdminPdfLookup(publicationId: number): Promise<AdminPdfQueueRequeueResult> {
|
||||
const id = Number.isFinite(publicationId) ? Math.trunc(publicationId) : 0;
|
||||
const response = await apiRequest<AdminPdfQueueRequeueResult>(
|
||||
`/admin/db/pdf-queue/${Math.max(1, id)}/requeue`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function requeueAllAdminPdfLookups(limit = 1000): Promise<AdminPdfQueueBulkEnqueueResult> {
|
||||
const parsedLimit = Number.isFinite(limit) ? Math.max(1, Math.min(5000, Math.trunc(limit))) : 1000;
|
||||
const response = await apiRequest<AdminPdfQueueBulkEnqueueResult>(
|
||||
`/admin/db/pdf-queue/requeue-all?limit=${parsedLimit}`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
|
@ -41,7 +41,7 @@ function countQueueStatuses(statuses: string[]): QueueHealth {
|
|||
|
||||
export async function fetchDashboardSnapshot(): Promise<DashboardSnapshot> {
|
||||
const [publications, runsPayload, queueItems] = await Promise.all([
|
||||
listPublications({ mode: "latest", limit: 20 }),
|
||||
listPublications({ mode: "latest", page: 1, pageSize: 20 }),
|
||||
listRuns({ limit: 20 }),
|
||||
listQueueItems(200),
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -13,26 +13,39 @@ export interface PublicationItem {
|
|||
pub_url: string | null;
|
||||
doi: string | null;
|
||||
pdf_url: string | null;
|
||||
pdf_status: "untracked" | "queued" | "running" | "resolved" | "failed";
|
||||
pdf_attempt_count: number;
|
||||
pdf_failure_reason: string | null;
|
||||
pdf_failure_detail: string | null;
|
||||
is_read: boolean;
|
||||
is_favorite: boolean;
|
||||
first_seen_at: string;
|
||||
is_new_in_latest_run: boolean;
|
||||
}
|
||||
|
||||
export interface PublicationsResult {
|
||||
mode: PublicationMode;
|
||||
favorite_only: boolean;
|
||||
selected_scholar_profile_id: number | null;
|
||||
unread_count: number;
|
||||
favorites_count: number;
|
||||
latest_count: number;
|
||||
// Compatibility alias for latest_count; retained while API clients migrate.
|
||||
new_count: number;
|
||||
total_count: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
has_next: boolean;
|
||||
has_prev: boolean;
|
||||
publications: PublicationItem[];
|
||||
}
|
||||
|
||||
export interface PublicationsQuery {
|
||||
mode?: PublicationMode;
|
||||
favoriteOnly?: boolean;
|
||||
scholarProfileId?: number;
|
||||
limit?: number;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface PublicationSelection {
|
||||
|
|
@ -46,12 +59,18 @@ export async function listPublications(query: PublicationsQuery = {}): Promise<P
|
|||
if (query.mode) {
|
||||
params.set("mode", query.mode);
|
||||
}
|
||||
if (query.favoriteOnly) {
|
||||
params.set("favorite_only", "true");
|
||||
}
|
||||
if (query.scholarProfileId) {
|
||||
params.set("scholar_profile_id", String(query.scholarProfileId));
|
||||
}
|
||||
if (query.limit) {
|
||||
params.set("limit", String(query.limit));
|
||||
}
|
||||
const parsedPage = Number.isFinite(query.page) ? Math.max(1, Math.trunc(Number(query.page))) : 1;
|
||||
const parsedPageSize = Number.isFinite(query.pageSize)
|
||||
? Math.max(1, Math.min(500, Math.trunc(Number(query.pageSize))))
|
||||
: 100;
|
||||
params.set("page", String(parsedPage));
|
||||
params.set("page_size", String(parsedPageSize));
|
||||
|
||||
const suffix = params.toString();
|
||||
const response = await apiRequest<PublicationsResult>(
|
||||
|
|
@ -87,10 +106,16 @@ export async function markSelectedRead(selections: PublicationSelection[]): Prom
|
|||
|
||||
export interface RetryPublicationPdfResult {
|
||||
message: string;
|
||||
queued: boolean;
|
||||
resolved_pdf: boolean;
|
||||
publication: PublicationItem;
|
||||
}
|
||||
|
||||
export interface TogglePublicationFavoriteResult {
|
||||
message: string;
|
||||
publication: PublicationItem;
|
||||
}
|
||||
|
||||
export async function retryPublicationPdf(
|
||||
publicationId: number,
|
||||
scholarProfileId: number,
|
||||
|
|
@ -104,3 +129,21 @@ export async function retryPublicationPdf(
|
|||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export async function togglePublicationFavorite(
|
||||
publicationId: number,
|
||||
scholarProfileId: number,
|
||||
isFavorite: boolean,
|
||||
): Promise<TogglePublicationFavoriteResult> {
|
||||
const response = await apiRequest<TogglePublicationFavoriteResult>(
|
||||
`/publications/${publicationId}/favorite`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
scholar_profile_id: scholarProfileId,
|
||||
is_favorite: isFavorite,
|
||||
},
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
|
|
|||
826
frontend/src/features/settings/SettingsAdminPanel.vue
Normal file
826
frontend/src/features/settings/SettingsAdminPanel.vue
Normal file
|
|
@ -0,0 +1,826 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
|
||||
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.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 AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import AppModal from "@/components/ui/AppModal.vue";
|
||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||
import AppTable from "@/components/ui/AppTable.vue";
|
||||
import {
|
||||
getAdminDbIntegrityReport,
|
||||
listAdminDbRepairJobs,
|
||||
listAdminPdfQueue,
|
||||
requeueAdminPdfLookup,
|
||||
requeueAllAdminPdfLookups,
|
||||
triggerPublicationLinkRepair,
|
||||
type AdminDbIntegrityCheck,
|
||||
type AdminDbIntegrityReport,
|
||||
type AdminDbRepairJob,
|
||||
type AdminPdfQueueItem,
|
||||
type TriggerPublicationLinkRepairResult,
|
||||
} from "@/features/admin_dbops";
|
||||
import {
|
||||
createAdminUser,
|
||||
listAdminUsers,
|
||||
resetAdminUserPassword,
|
||||
setAdminUserActive,
|
||||
type AdminUser,
|
||||
} from "@/features/admin_users";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
||||
const SECTION_USERS = "users";
|
||||
const SECTION_INTEGRITY = "integrity";
|
||||
const SECTION_REPAIRS = "repairs";
|
||||
const SECTION_PDF = "pdf";
|
||||
const SCOPE_SINGLE_USER = "single_user";
|
||||
const SCOPE_ALL_USERS = "all_users";
|
||||
const APPLY_ALL_USERS_CONFIRM_TEXT = "REPAIR ALL USERS";
|
||||
|
||||
type RepairScopeMode = typeof SCOPE_SINGLE_USER | typeof SCOPE_ALL_USERS;
|
||||
|
||||
const props = defineProps<{
|
||||
section: "users" | "integrity" | "repairs" | "pdf";
|
||||
}>();
|
||||
|
||||
const loading = ref(true);
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const errorRequestId = ref<string | null>(null);
|
||||
const successMessage = ref<string | null>(null);
|
||||
|
||||
const refreshingUsers = ref(false);
|
||||
const creating = ref(false);
|
||||
const togglingUserId = ref<number | null>(null);
|
||||
const resettingPassword = ref(false);
|
||||
|
||||
const users = ref<AdminUser[]>([]);
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const createIsAdmin = ref(false);
|
||||
const activeUserId = ref<number | null>(null);
|
||||
const resetPassword = ref("");
|
||||
|
||||
const refreshingIntegrity = ref(false);
|
||||
const integrityReport = ref<AdminDbIntegrityReport | null>(null);
|
||||
|
||||
const refreshingJobs = ref(false);
|
||||
const runningRepair = ref(false);
|
||||
const repairJobs = ref<AdminDbRepairJob[]>([]);
|
||||
const lastRepairResult = ref<TriggerPublicationLinkRepairResult | null>(null);
|
||||
const repairScopeMode = ref<RepairScopeMode>(SCOPE_SINGLE_USER);
|
||||
const repairUserId = ref("");
|
||||
const repairScholarIds = ref("");
|
||||
const repairRequestedBy = ref("");
|
||||
const repairDryRun = ref(true);
|
||||
const repairGcOrphans = ref(false);
|
||||
const repairConfirmationText = ref("");
|
||||
|
||||
const refreshingPdfQueue = ref(false);
|
||||
const requeueingPublicationId = ref<number | null>(null);
|
||||
const requeueingAllPdfs = ref(false);
|
||||
const pdfQueueItems = ref<AdminPdfQueueItem[]>([]);
|
||||
const pdfQueueStatusFilter = ref("");
|
||||
const pdfQueuePage = ref(1);
|
||||
const pdfQueuePageSize = ref("50");
|
||||
const pdfQueueTotalCount = ref(0);
|
||||
const pdfQueueHasNext = ref(false);
|
||||
const pdfQueueHasPrev = ref(false);
|
||||
|
||||
const activeUser = computed(() => users.value.find((user) => user.id === activeUserId.value) ?? null);
|
||||
const typedConfirmationRequired = computed(
|
||||
() => repairScopeMode.value === SCOPE_ALL_USERS && !repairDryRun.value,
|
||||
);
|
||||
const pdfQueuePageSizeValue = computed(() => {
|
||||
const parsed = Number(pdfQueuePageSize.value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return 50;
|
||||
}
|
||||
return Math.max(1, Math.min(500, Math.trunc(parsed)));
|
||||
});
|
||||
const pdfQueueTotalPages = computed(() =>
|
||||
Math.max(1, Math.ceil(pdfQueueTotalCount.value / Math.max(pdfQueuePageSizeValue.value, 1))),
|
||||
);
|
||||
const pdfQueueSummary = computed(() =>
|
||||
`${pdfQueueTotalCount.value} item${pdfQueueTotalCount.value === 1 ? "" : "s"} total`,
|
||||
);
|
||||
|
||||
function clearAlerts(): void {
|
||||
errorMessage.value = null;
|
||||
errorRequestId.value = null;
|
||||
successMessage.value = null;
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null): string {
|
||||
if (!value) {
|
||||
return "n/a";
|
||||
}
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? value : parsed.toLocaleString();
|
||||
}
|
||||
|
||||
function statusTone(status: string): "success" | "warning" | "danger" | "info" | "neutral" {
|
||||
if (status === "ok" || status === "completed" || status === "resolved") {
|
||||
return "success";
|
||||
}
|
||||
if (status === "warning" || status === "running" || status === "queued") {
|
||||
return "warning";
|
||||
}
|
||||
if (status === "failed") {
|
||||
return "danger";
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
function checkTone(check: AdminDbIntegrityCheck): "warning" | "danger" | "neutral" | "info" {
|
||||
if (check.severity === "metric") {
|
||||
return "info";
|
||||
}
|
||||
if (check.count <= 0) {
|
||||
return "neutral";
|
||||
}
|
||||
return check.severity === "failure" ? "danger" : "warning";
|
||||
}
|
||||
|
||||
function userRoleLabel(user: AdminUser): string {
|
||||
return user.is_admin ? "Admin" : "User";
|
||||
}
|
||||
|
||||
function canRequeuePdf(item: AdminPdfQueueItem): boolean {
|
||||
return item.status !== "queued" && item.status !== "running";
|
||||
}
|
||||
|
||||
function statusDotClass(user: AdminUser): string {
|
||||
return user.is_active ? "bg-success-500 ring-success-200" : "bg-ink-muted/70 ring-stroke-default";
|
||||
}
|
||||
|
||||
function assignError(error: unknown, fallback: string): void {
|
||||
if (error instanceof ApiRequestError) {
|
||||
errorMessage.value = error.message;
|
||||
errorRequestId.value = error.requestId;
|
||||
return;
|
||||
}
|
||||
if (error instanceof Error && error.message) {
|
||||
errorMessage.value = error.message;
|
||||
return;
|
||||
}
|
||||
errorMessage.value = fallback;
|
||||
}
|
||||
|
||||
function parseScholarIds(raw: string): number[] {
|
||||
const tokens = raw
|
||||
.split(/[,\s]+/)
|
||||
.map((token) => token.trim())
|
||||
.filter((token) => token.length > 0);
|
||||
const deduped = new Set<number>();
|
||||
for (const token of tokens) {
|
||||
const parsed = Number(token);
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error("Scholar profile IDs must be positive integers.");
|
||||
}
|
||||
deduped.add(parsed);
|
||||
}
|
||||
return [...deduped];
|
||||
}
|
||||
|
||||
function parseRepairUserIdOrThrow(raw: string): number {
|
||||
const parsed = Number((raw || "").trim());
|
||||
if (!Number.isInteger(parsed) || parsed < 1) {
|
||||
throw new Error("Select a valid target user.");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function validateTypedConfirmation(): string {
|
||||
const normalized = repairConfirmationText.value.trim();
|
||||
if (typedConfirmationRequired.value && normalized !== APPLY_ALL_USERS_CONFIRM_TEXT) {
|
||||
throw new Error(`Type '${APPLY_ALL_USERS_CONFIRM_TEXT}' to apply repair for all users.`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function summaryCount(job: AdminDbRepairJob, key: string): string {
|
||||
const value = job.summary[key];
|
||||
return typeof value === "number" ? String(value) : "n/a";
|
||||
}
|
||||
|
||||
function openUserModal(user: AdminUser): void {
|
||||
activeUserId.value = user.id;
|
||||
resetPassword.value = "";
|
||||
}
|
||||
|
||||
function closeUserModal(): void {
|
||||
activeUserId.value = null;
|
||||
resetPassword.value = "";
|
||||
}
|
||||
|
||||
function ensureRepairUserSelected(): void {
|
||||
if (repairScopeMode.value !== SCOPE_SINGLE_USER || repairUserId.value || users.value.length === 0) {
|
||||
return;
|
||||
}
|
||||
repairUserId.value = String(users.value[0].id);
|
||||
}
|
||||
|
||||
async function refreshUsers(): Promise<void> {
|
||||
refreshingUsers.value = true;
|
||||
try {
|
||||
users.value = await listAdminUsers();
|
||||
ensureRepairUserSelected();
|
||||
if (activeUserId.value !== null && !users.value.some((item) => item.id === activeUserId.value)) {
|
||||
closeUserModal();
|
||||
}
|
||||
} finally {
|
||||
refreshingUsers.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshIntegrity(): Promise<void> {
|
||||
refreshingIntegrity.value = true;
|
||||
try {
|
||||
integrityReport.value = await getAdminDbIntegrityReport();
|
||||
} finally {
|
||||
refreshingIntegrity.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRepairJobs(): Promise<void> {
|
||||
refreshingJobs.value = true;
|
||||
try {
|
||||
repairJobs.value = await listAdminDbRepairJobs(30);
|
||||
} finally {
|
||||
refreshingJobs.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshPdfQueue(): Promise<void> {
|
||||
refreshingPdfQueue.value = true;
|
||||
try {
|
||||
const status = pdfQueueStatusFilter.value.trim() || null;
|
||||
const page = await listAdminPdfQueue(pdfQueuePage.value, pdfQueuePageSizeValue.value, status);
|
||||
pdfQueueItems.value = page.items;
|
||||
pdfQueueTotalCount.value = page.total_count;
|
||||
pdfQueueHasNext.value = page.has_next;
|
||||
pdfQueueHasPrev.value = page.has_prev;
|
||||
pdfQueuePage.value = page.page;
|
||||
pdfQueuePageSize.value = String(page.page_size);
|
||||
} finally {
|
||||
refreshingPdfQueue.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshForSection(): Promise<void> {
|
||||
if (props.section === SECTION_USERS) {
|
||||
await refreshUsers();
|
||||
return;
|
||||
}
|
||||
if (props.section === SECTION_INTEGRITY) {
|
||||
await refreshIntegrity();
|
||||
return;
|
||||
}
|
||||
if (props.section === SECTION_REPAIRS) {
|
||||
await Promise.all([refreshUsers(), refreshRepairJobs()]);
|
||||
return;
|
||||
}
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
async function onCreateUser(): Promise<void> {
|
||||
creating.value = true;
|
||||
clearAlerts();
|
||||
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 refreshUsers();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to create user.");
|
||||
} finally {
|
||||
creating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onToggleUser(user: AdminUser): Promise<void> {
|
||||
togglingUserId.value = user.id;
|
||||
clearAlerts();
|
||||
try {
|
||||
const updated = await setAdminUserActive(user.id, !user.is_active);
|
||||
successMessage.value = `${updated.email} is now ${updated.is_active ? "active" : "inactive"}.`;
|
||||
await refreshUsers();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to update user.");
|
||||
} finally {
|
||||
togglingUserId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onResetPassword(): Promise<void> {
|
||||
const user = activeUser.value;
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
resettingPassword.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const candidate = resetPassword.value.trim();
|
||||
if (candidate.length < 12) {
|
||||
throw new Error("New password must be at least 12 characters.");
|
||||
}
|
||||
const result = await resetAdminUserPassword(user.id, candidate);
|
||||
resetPassword.value = "";
|
||||
successMessage.value = result.message || `Password reset for ${user.email}.`;
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to reset password.");
|
||||
} finally {
|
||||
resettingPassword.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRunRepair(): Promise<void> {
|
||||
runningRepair.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const scopeMode = repairScopeMode.value;
|
||||
const payload = {
|
||||
scope_mode: scopeMode,
|
||||
scholar_profile_ids: parseScholarIds(repairScholarIds.value),
|
||||
dry_run: repairDryRun.value,
|
||||
gc_orphan_publications: repairGcOrphans.value,
|
||||
requested_by: repairRequestedBy.value.trim() || undefined,
|
||||
confirmation_text: validateTypedConfirmation() || undefined,
|
||||
user_id: scopeMode === SCOPE_SINGLE_USER ? parseRepairUserIdOrThrow(repairUserId.value) : undefined,
|
||||
};
|
||||
const result = await triggerPublicationLinkRepair(payload);
|
||||
lastRepairResult.value = result;
|
||||
successMessage.value = `Repair job #${result.job_id} completed (${result.status}).`;
|
||||
repairConfirmationText.value = "";
|
||||
await Promise.all([refreshIntegrity(), refreshRepairJobs(), refreshPdfQueue()]);
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to run publication link repair.");
|
||||
} finally {
|
||||
runningRepair.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onScopeModeChange(): void {
|
||||
ensureRepairUserSelected();
|
||||
}
|
||||
|
||||
async function onPdfQueueFilterChanged(): Promise<void> {
|
||||
pdfQueuePage.value = 1;
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
async function onPdfQueuePageSizeChanged(): Promise<void> {
|
||||
pdfQueuePage.value = 1;
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
async function onPdfQueuePrevPage(): Promise<void> {
|
||||
if (!pdfQueueHasPrev.value || pdfQueuePage.value <= 1) {
|
||||
return;
|
||||
}
|
||||
pdfQueuePage.value = Math.max(pdfQueuePage.value - 1, 1);
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
async function onPdfQueueNextPage(): Promise<void> {
|
||||
if (!pdfQueueHasNext.value) {
|
||||
return;
|
||||
}
|
||||
pdfQueuePage.value += 1;
|
||||
await refreshPdfQueue();
|
||||
}
|
||||
|
||||
async function onRequeuePdf(item: AdminPdfQueueItem): Promise<void> {
|
||||
requeueingPublicationId.value = item.publication_id;
|
||||
clearAlerts();
|
||||
try {
|
||||
const result = await requeueAdminPdfLookup(item.publication_id);
|
||||
successMessage.value = result.message;
|
||||
await refreshPdfQueue();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to requeue PDF lookup.");
|
||||
} finally {
|
||||
requeueingPublicationId.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function onRequeueAllPdfs(): Promise<void> {
|
||||
requeueingAllPdfs.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
const result = await requeueAllAdminPdfLookups(5000);
|
||||
successMessage.value = result.message;
|
||||
await refreshPdfQueue();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to queue missing PDF lookups.");
|
||||
} finally {
|
||||
requeueingAllPdfs.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loading.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
await refreshForSection();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to load admin data.");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.section,
|
||||
async () => {
|
||||
loading.value = true;
|
||||
clearAlerts();
|
||||
try {
|
||||
await refreshForSection();
|
||||
} catch (error) {
|
||||
assignError(error, "Unable to load admin data.");
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="grid gap-4">
|
||||
<RequestStateAlerts
|
||||
:success-message="successMessage"
|
||||
:error-message="errorMessage"
|
||||
:error-request-id="errorRequestId"
|
||||
success-title="Operation complete"
|
||||
error-title="Operation failed"
|
||||
@dismiss-success="successMessage = null"
|
||||
/>
|
||||
|
||||
<AsyncStateGate :loading="loading" :loading-lines="10" :show-empty="false">
|
||||
<AppCard v-if="props.section === SECTION_USERS" class="space-y-4">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Users</h2>
|
||||
<AppHelpHint text="Create accounts, toggle active status, and reset passwords." />
|
||||
</div>
|
||||
<AppRefreshButton
|
||||
variant="secondary"
|
||||
:loading="refreshingUsers"
|
||||
title="Refresh users"
|
||||
loading-title="Refreshing users"
|
||||
@click="refreshUsers"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form class="grid gap-3 md:grid-cols-3" @submit.prevent="onCreateUser">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Email</span>
|
||||
<AppInput v-model="email" type="email" autocomplete="off" />
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Password</span>
|
||||
<AppInput v-model="password" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<div class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
<span>Role</span>
|
||||
<div class="flex items-center gap-3">
|
||||
<AppCheckbox id="admin-create-user-is-admin" v-model="createIsAdmin" label="Grant admin" />
|
||||
<AppButton type="submit" :disabled="creating">
|
||||
{{ creating ? "Creating..." : "Create user" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<AppTable label="Users table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Email</th>
|
||||
<th scope="col">Role</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td class="align-middle">
|
||||
<button
|
||||
type="button"
|
||||
class="group inline-flex items-center gap-2 rounded-md px-1 py-0.5 text-left text-ink-primary transition hover:bg-surface-card-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||
@click="openUserModal(user)"
|
||||
>
|
||||
<span
|
||||
:class="statusDotClass(user)"
|
||||
class="h-2.5 w-2.5 rounded-full ring-2"
|
||||
:aria-label="user.is_active ? 'Active user' : 'Inactive user'"
|
||||
/>
|
||||
<span class="underline-offset-2 group-hover:underline">{{ user.email }}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ userRoleLabel(user) }}</td>
|
||||
<td>{{ user.is_active ? "active" : "inactive" }}</td>
|
||||
<td class="flex flex-wrap items-center gap-2">
|
||||
<AppButton variant="secondary" :disabled="togglingUserId === user.id" @click="onToggleUser(user)">
|
||||
{{ togglingUserId === user.id ? "Updating..." : user.is_active ? "Deactivate" : "Activate" }}
|
||||
</AppButton>
|
||||
<AppButton variant="ghost" @click="openUserModal(user)">Manage</AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
</AppCard>
|
||||
|
||||
<AppCard v-if="props.section === SECTION_INTEGRITY" class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Integrity Report</h2>
|
||||
<AppHelpHint text="Read-only checks for known corruption patterns and data drift." />
|
||||
</div>
|
||||
<AppRefreshButton
|
||||
variant="secondary"
|
||||
:loading="refreshingIntegrity"
|
||||
title="Refresh integrity report"
|
||||
loading-title="Refreshing integrity report"
|
||||
@click="refreshIntegrity"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="integrityReport" class="flex flex-wrap items-center gap-2 text-xs">
|
||||
<AppBadge :tone="statusTone(integrityReport.status)">Status: {{ integrityReport.status }}</AppBadge>
|
||||
<AppBadge tone="warning">Warnings: {{ integrityReport.warnings.length }}</AppBadge>
|
||||
<AppBadge tone="danger">Failures: {{ integrityReport.failures.length }}</AppBadge>
|
||||
<span class="text-secondary">Checked: {{ formatTimestamp(integrityReport.checked_at) }}</span>
|
||||
</div>
|
||||
|
||||
<AppTable v-if="integrityReport" label="Integrity checks">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Check</th>
|
||||
<th scope="col">Count</th>
|
||||
<th scope="col">Severity</th>
|
||||
<th scope="col">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="check in integrityReport.checks" :key="check.name">
|
||||
<td>{{ check.name }}</td>
|
||||
<td>{{ check.count }}</td>
|
||||
<td><AppBadge :tone="checkTone(check)">{{ check.severity }}</AppBadge></td>
|
||||
<td>{{ check.message }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
</AppCard>
|
||||
|
||||
<section v-if="props.section === SECTION_REPAIRS" class="grid gap-4">
|
||||
<AppCard class="space-y-3">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Publication Link Repair</h2>
|
||||
<AppHelpHint text="Dry-run first. For all-users apply mode, typed confirmation is required." />
|
||||
</div>
|
||||
|
||||
<form class="grid gap-3 md:grid-cols-2" @submit.prevent="onRunRepair">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Scope</span>
|
||||
<AppSelect v-model="repairScopeMode" @change="onScopeModeChange">
|
||||
<option :value="SCOPE_SINGLE_USER">Single user</option>
|
||||
<option :value="SCOPE_ALL_USERS">All users</option>
|
||||
</AppSelect>
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Target user</span>
|
||||
<AppSelect v-model="repairUserId" :disabled="repairScopeMode === SCOPE_ALL_USERS || users.length === 0">
|
||||
<option value="" disabled>Select user</option>
|
||||
<option v-for="user in users" :key="user.id" :value="String(user.id)">
|
||||
{{ user.email }} (ID {{ user.id }})
|
||||
</option>
|
||||
</AppSelect>
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
|
||||
<span>Scholar profile IDs (optional)</span>
|
||||
<AppInput v-model="repairScholarIds" placeholder="e.g. 12,13,14" />
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
|
||||
<span>Requested by (optional)</span>
|
||||
<AppInput v-model="repairRequestedBy" placeholder="email/name/ticket id" />
|
||||
</label>
|
||||
<div class="flex flex-wrap items-center gap-4 md:col-span-2">
|
||||
<AppCheckbox id="repair-dry-run" v-model="repairDryRun" label="Dry-run (no writes)" />
|
||||
<AppCheckbox id="repair-gc-orphans" v-model="repairGcOrphans" label="Delete orphan publications" />
|
||||
</div>
|
||||
<label v-if="typedConfirmationRequired" class="grid gap-1 text-sm font-medium text-ink-secondary md:col-span-2">
|
||||
<span>Type '{{ APPLY_ALL_USERS_CONFIRM_TEXT }}' to confirm</span>
|
||||
<AppInput v-model="repairConfirmationText" :placeholder="APPLY_ALL_USERS_CONFIRM_TEXT" />
|
||||
</label>
|
||||
<p v-if="repairScopeMode === SCOPE_ALL_USERS" class="text-xs text-secondary md:col-span-2">
|
||||
All-users scope includes every scholar profile across all accounts.
|
||||
</p>
|
||||
<div class="md:col-span-2">
|
||||
<AppButton type="submit" :disabled="runningRepair">
|
||||
{{ runningRepair ? "Running..." : "Run repair job" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div v-if="lastRepairResult" class="rounded-lg border border-stroke-default bg-surface-card-muted p-3 text-xs">
|
||||
<div class="mb-2 flex flex-wrap items-center gap-2">
|
||||
<AppBadge :tone="statusTone(lastRepairResult.status)">Job #{{ lastRepairResult.job_id }}</AppBadge>
|
||||
<span class="text-secondary">Status: {{ lastRepairResult.status }}</span>
|
||||
</div>
|
||||
<pre class="overflow-x-auto text-secondary">{{ JSON.stringify(lastRepairResult.summary, null, 2) }}</pre>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
<AppCard class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Recent Repair Jobs</h2>
|
||||
<AppHelpHint text="Audit history and summary counters for each repair job." />
|
||||
</div>
|
||||
<AppRefreshButton
|
||||
variant="secondary"
|
||||
:loading="refreshingJobs"
|
||||
title="Refresh repair jobs"
|
||||
loading-title="Refreshing repair jobs"
|
||||
@click="refreshRepairJobs"
|
||||
/>
|
||||
</div>
|
||||
<AppTable label="Repair jobs table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Job</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Mode</th>
|
||||
<th scope="col">Requested by</th>
|
||||
<th scope="col">Created</th>
|
||||
<th scope="col">Links in scope</th>
|
||||
<th scope="col">Links deleted</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="job in repairJobs" :key="job.id">
|
||||
<td>#{{ job.id }} · {{ job.job_name }}</td>
|
||||
<td><AppBadge :tone="statusTone(job.status)">{{ job.status }}</AppBadge></td>
|
||||
<td>{{ job.dry_run ? "dry-run" : "apply" }}</td>
|
||||
<td>{{ job.requested_by || "n/a" }}</td>
|
||||
<td>{{ formatTimestamp(job.created_at) }}</td>
|
||||
<td>{{ summaryCount(job, "links_in_scope") }}</td>
|
||||
<td>{{ summaryCount(job, "links_deleted") }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
</AppCard>
|
||||
</section>
|
||||
|
||||
<AppCard v-if="props.section === SECTION_PDF" class="space-y-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">PDF Gathering Queue</h2>
|
||||
<AppHelpHint text="Live queue and outcome history for PDF acquisition across all publications." />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<AppSelect v-model="pdfQueueStatusFilter" class="min-w-[12rem] !py-1.5 !text-sm" @change="onPdfQueueFilterChanged">
|
||||
<option value="">All statuses</option>
|
||||
<option value="untracked">untracked</option>
|
||||
<option value="queued">queued</option>
|
||||
<option value="running">running</option>
|
||||
<option value="failed">failed</option>
|
||||
<option value="resolved">resolved</option>
|
||||
</AppSelect>
|
||||
<AppSelect v-model="pdfQueuePageSize" class="min-w-[10rem] !py-1.5 !text-sm" @change="onPdfQueuePageSizeChanged">
|
||||
<option value="25">25 / page</option>
|
||||
<option value="50">50 / page</option>
|
||||
<option value="100">100 / page</option>
|
||||
</AppSelect>
|
||||
<AppButton
|
||||
variant="secondary"
|
||||
class="!min-h-8 whitespace-nowrap !px-2.5 !py-1 !text-xs"
|
||||
:disabled="requeueingAllPdfs"
|
||||
title="Queue all missing PDFs"
|
||||
@click="onRequeueAllPdfs"
|
||||
>
|
||||
{{ requeueingAllPdfs ? "Queueing..." : "Queue all" }}
|
||||
</AppButton>
|
||||
<AppRefreshButton
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
:loading="refreshingPdfQueue"
|
||||
title="Refresh PDF queue"
|
||||
loading-title="Refreshing PDF queue"
|
||||
@click="refreshPdfQueue"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppTable label="PDF queue table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Publication</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Attempts</th>
|
||||
<th scope="col">Failure reason</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Requested by</th>
|
||||
<th scope="col">Queued</th>
|
||||
<th scope="col">Last attempt</th>
|
||||
<th scope="col">Resolved</th>
|
||||
<th scope="col">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in pdfQueueItems" :key="item.publication_id">
|
||||
<td>
|
||||
<div class="grid gap-1">
|
||||
<span class="font-medium text-ink-primary">{{ item.title }}</span>
|
||||
<a v-if="item.doi" :href="`https://doi.org/${item.doi}`" target="_blank" rel="noreferrer" class="link-inline text-xs">
|
||||
DOI: {{ item.doi }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td><AppBadge :tone="statusTone(item.status)">{{ item.status }}</AppBadge></td>
|
||||
<td>{{ item.attempt_count }}</td>
|
||||
<td>{{ item.last_failure_reason || "n/a" }}</td>
|
||||
<td>{{ item.last_source || "n/a" }}</td>
|
||||
<td>{{ item.requested_by_email || "n/a" }}</td>
|
||||
<td>{{ formatTimestamp(item.queued_at) }}</td>
|
||||
<td>{{ formatTimestamp(item.last_attempt_at) }}</td>
|
||||
<td>{{ formatTimestamp(item.resolved_at) }}</td>
|
||||
<td>
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
:disabled="requeueingPublicationId === item.publication_id || !canRequeuePdf(item)"
|
||||
@click="onRequeuePdf(item)"
|
||||
>
|
||||
{{ requeueingPublicationId === item.publication_id ? "Requeueing..." : "Requeue" }}
|
||||
</AppButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 text-xs text-secondary">
|
||||
<span>{{ pdfQueueSummary }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span>Page {{ pdfQueuePage }} / {{ pdfQueueTotalPages }}</span>
|
||||
<AppButton variant="ghost" :disabled="!pdfQueueHasPrev" @click="onPdfQueuePrevPage">Prev</AppButton>
|
||||
<AppButton variant="ghost" :disabled="!pdfQueueHasNext" @click="onPdfQueueNextPage">Next</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppCard>
|
||||
</AsyncStateGate>
|
||||
|
||||
<AppModal :open="activeUser !== null" title="User settings" @close="closeUserModal">
|
||||
<div v-if="activeUser" class="grid gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
:class="statusDotClass(activeUser)"
|
||||
class="h-2.5 w-2.5 rounded-full ring-2"
|
||||
:aria-label="activeUser.is_active ? 'Active user' : 'Inactive user'"
|
||||
/>
|
||||
<p class="truncate text-sm font-semibold text-ink-primary">{{ activeUser.email }}</p>
|
||||
</div>
|
||||
<p class="text-sm text-secondary">Role: {{ userRoleLabel(activeUser) }}</p>
|
||||
<p class="text-xs text-secondary">Last updated: {{ formatTimestamp(activeUser.updated_at) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>New password</span>
|
||||
<AppInput
|
||||
id="admin-reset-password"
|
||||
v-model="resetPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="At least 12 characters"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<AppButton :disabled="resettingPassword" @click="onResetPassword">
|
||||
{{ resettingPassword ? "Resetting..." : "Reset password" }}
|
||||
</AppButton>
|
||||
<AppButton variant="secondary" :disabled="togglingUserId === activeUser.id" @click="onToggleUser(activeUser)">
|
||||
{{ togglingUserId === activeUser.id ? "Updating..." : activeUser.is_active ? "Deactivate user" : "Activate user" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
</section>
|
||||
</template>
|
||||
BIN
frontend/src/logo.png
Normal file
BIN
frontend/src/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
|
|
@ -1,316 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
|
||||
import AppPage from "@/components/layout/AppPage.vue";
|
||||
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import AppModal from "@/components/ui/AppModal.vue";
|
||||
import AppTable from "@/components/ui/AppTable.vue";
|
||||
import {
|
||||
createAdminUser,
|
||||
listAdminUsers,
|
||||
resetAdminUserPassword,
|
||||
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 resettingPassword = ref(false);
|
||||
const activeUserId = ref<number | null>(null);
|
||||
const users = ref<AdminUser[]>([]);
|
||||
|
||||
const email = ref("");
|
||||
const password = ref("");
|
||||
const createIsAdmin = ref(false);
|
||||
const resetPassword = ref("");
|
||||
|
||||
const errorMessage = ref<string | null>(null);
|
||||
const errorRequestId = ref<string | null>(null);
|
||||
const successMessage = ref<string | null>(null);
|
||||
|
||||
const activeUser = computed(() => users.value.find((item) => item.id === activeUserId.value) ?? null);
|
||||
|
||||
function formatDate(value: string): string {
|
||||
const asDate = new Date(value);
|
||||
if (Number.isNaN(asDate.getTime())) {
|
||||
return value;
|
||||
}
|
||||
return asDate.toLocaleString();
|
||||
}
|
||||
|
||||
function userRoleLabel(user: AdminUser): string {
|
||||
return user.is_admin ? "Admin" : "User";
|
||||
}
|
||||
|
||||
function statusDotClass(user: AdminUser): string {
|
||||
return user.is_active
|
||||
? "bg-success-500 ring-success-200"
|
||||
: "bg-ink-muted/70 ring-stroke-default";
|
||||
}
|
||||
|
||||
function openUserModal(user: AdminUser): void {
|
||||
activeUserId.value = user.id;
|
||||
resetPassword.value = "";
|
||||
}
|
||||
|
||||
function closeUserModal(): void {
|
||||
activeUserId.value = null;
|
||||
resetPassword.value = "";
|
||||
}
|
||||
|
||||
async function loadUsers(): Promise<void> {
|
||||
loading.value = true;
|
||||
errorMessage.value = null;
|
||||
errorRequestId.value = null;
|
||||
|
||||
try {
|
||||
users.value = await listAdminUsers();
|
||||
if (activeUserId.value !== null && !users.value.some((item) => item.id === activeUserId.value)) {
|
||||
closeUserModal();
|
||||
}
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
async function onResetPassword(): Promise<void> {
|
||||
const user = activeUser.value;
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
resettingPassword.value = true;
|
||||
successMessage.value = null;
|
||||
errorMessage.value = null;
|
||||
errorRequestId.value = null;
|
||||
|
||||
try {
|
||||
const candidate = resetPassword.value.trim();
|
||||
if (candidate.length < 12) {
|
||||
throw new Error("New password must be at least 12 characters.");
|
||||
}
|
||||
|
||||
const result = await resetAdminUserPassword(user.id, candidate);
|
||||
successMessage.value = result.message || `Password reset for ${user.email}.`;
|
||||
resetPassword.value = "";
|
||||
} 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 reset password.";
|
||||
}
|
||||
} finally {
|
||||
resettingPassword.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPage title="Admin Users" subtitle="Create and manage user access for this instance.">
|
||||
<RequestStateAlerts
|
||||
:success-message="successMessage"
|
||||
:error-message="errorMessage"
|
||||
:error-request-id="errorRequestId"
|
||||
error-title="User management request failed"
|
||||
@dismiss-success="successMessage = null"
|
||||
/>
|
||||
|
||||
<section class="grid gap-4 lg:grid-cols-2">
|
||||
<AppCard class="space-y-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Create User</h2>
|
||||
<AppHelpHint text="Create local accounts for this Scholarr instance and optionally grant admin rights." />
|
||||
</div>
|
||||
<form class="grid gap-3" @submit.prevent="onCreateUser">
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
<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-ink-secondary">
|
||||
<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">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Users</h2>
|
||||
<AppHelpHint text="Review account roles and active status. Inactive users cannot sign in." />
|
||||
</div>
|
||||
<AsyncStateGate
|
||||
:loading="loading"
|
||||
:loading-lines="5"
|
||||
:empty="users.length === 0"
|
||||
:show-empty="!errorMessage"
|
||||
empty-title="No users available"
|
||||
empty-body="Create an account to begin assigning access."
|
||||
>
|
||||
<AppTable label="Admin users table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Email</th>
|
||||
<th scope="col">Role</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id">
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
class="group inline-flex items-center gap-2 rounded-md px-1 py-0.5 text-left text-ink-primary transition hover:bg-surface-card-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||
@click="openUserModal(user)"
|
||||
>
|
||||
<span
|
||||
:class="statusDotClass(user)"
|
||||
class="h-2.5 w-2.5 rounded-full ring-2"
|
||||
:aria-label="user.is_active ? 'Active user' : 'Inactive user'"
|
||||
/>
|
||||
<span class="underline-offset-2 group-hover:underline">{{ user.email }}</span>
|
||||
</button>
|
||||
</td>
|
||||
<td>{{ userRoleLabel(user) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</AppTable>
|
||||
<p class="text-xs text-secondary">Status dot: green is active, gray is inactive. Click an email to manage.</p>
|
||||
</AsyncStateGate>
|
||||
</AppCard>
|
||||
</section>
|
||||
|
||||
<AppModal :open="activeUser !== null" title="User settings" @close="closeUserModal">
|
||||
<div v-if="activeUser" class="grid gap-4">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
:class="statusDotClass(activeUser)"
|
||||
class="h-2.5 w-2.5 rounded-full ring-2"
|
||||
:aria-label="activeUser.is_active ? 'Active user' : 'Inactive user'"
|
||||
/>
|
||||
<p class="truncate text-sm font-semibold text-ink-primary">{{ activeUser.email }}</p>
|
||||
</div>
|
||||
<p class="text-sm text-secondary">Role: {{ userRoleLabel(activeUser) }}</p>
|
||||
<p class="text-xs text-secondary">Last updated: {{ formatDate(activeUser.updated_at) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2 border-t border-stroke-default pt-3">
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary" for="admin-user-reset-password">
|
||||
<span>Reset password</span>
|
||||
<AppInput
|
||||
id="admin-user-reset-password"
|
||||
v-model="resetPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
placeholder="Minimum 12 characters"
|
||||
:disabled="resettingPassword"
|
||||
/>
|
||||
</label>
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<AppButton variant="secondary" :disabled="resettingPassword" @click="onResetPassword">
|
||||
{{ resettingPassword ? "Resetting..." : "Reset password" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2 border-t border-stroke-default pt-3">
|
||||
<AppButton
|
||||
variant="secondary"
|
||||
:disabled="togglingUserId === activeUser.id"
|
||||
@click="onToggleUser(activeUser)"
|
||||
>
|
||||
{{ activeUser.is_active ? "Deactivate user" : "Activate user" }}
|
||||
</AppButton>
|
||||
<AppButton variant="ghost" :disabled="togglingUserId === activeUser.id || resettingPassword" @click="closeUserModal">
|
||||
Close
|
||||
</AppButton>
|
||||
</div>
|
||||
</div>
|
||||
</AppModal>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { fetchDashboardSnapshot, type DashboardSnapshot } from "@/features/dashboard";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
|
@ -14,7 +14,7 @@ import AppCard from "@/components/ui/AppCard.vue";
|
|||
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
import { RUN_STATUS_POLL_INTERVAL_MS, useRunStatusStore } from "@/stores/run_status";
|
||||
import { useRunStatusStore } from "@/stores/run_status";
|
||||
import { useUserSettingsStore } from "@/stores/user_settings";
|
||||
|
||||
const loading = ref(true);
|
||||
|
|
@ -26,7 +26,6 @@ const refreshingAfterCompletion = ref(false);
|
|||
const auth = useAuthStore();
|
||||
const runStatus = useRunStatusStore();
|
||||
const userSettings = useUserSettingsStore();
|
||||
let latestRunSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const isStartBlocked = computed(
|
||||
() =>
|
||||
|
|
@ -135,23 +134,6 @@ function shouldRefreshAfterRunChange(
|
|||
return previousRun.status === "running";
|
||||
}
|
||||
|
||||
function startLatestRunSyncLoop(): void {
|
||||
if (latestRunSyncTimer !== null) {
|
||||
return;
|
||||
}
|
||||
latestRunSyncTimer = setInterval(() => {
|
||||
void runStatus.syncLatest();
|
||||
}, RUN_STATUS_POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
function stopLatestRunSyncLoop(): void {
|
||||
if (latestRunSyncTimer === null) {
|
||||
return;
|
||||
}
|
||||
clearInterval(latestRunSyncTimer);
|
||||
latestRunSyncTimer = null;
|
||||
}
|
||||
|
||||
async function loadSnapshot(): Promise<void> {
|
||||
loading.value = true;
|
||||
errorMessage.value = null;
|
||||
|
|
@ -207,11 +189,6 @@ async function onTriggerRun(): Promise<void> {
|
|||
onMounted(() => {
|
||||
void loadSnapshot();
|
||||
void runStatus.syncLatest();
|
||||
startLatestRunSyncLoop();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
stopLatestRunSyncLoop();
|
||||
});
|
||||
|
||||
watch(
|
||||
|
|
@ -266,7 +243,12 @@ watch(
|
|||
</div>
|
||||
<p class="text-sm text-secondary">Newest papers found while checking your tracked scholar profiles.</p>
|
||||
</div>
|
||||
<RouterLink to="/publications" class="link-inline text-sm">Open full publications view</RouterLink>
|
||||
<RouterLink
|
||||
to="/publications"
|
||||
class="inline-flex min-h-10 items-center justify-center rounded-lg border border-action-secondary-border bg-action-secondary-bg px-3 py-2 text-sm font-semibold text-action-secondary-text shadow-sm transition hover:border-action-secondary-hover-border hover:bg-action-secondary-hover-bg hover:text-action-secondary-hover-text focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||
>
|
||||
view all
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<AppEmptyState
|
||||
|
|
|
|||
|
|
@ -3,9 +3,11 @@ import { computed, ref } from "vue";
|
|||
import { useRouter } from "vue-router";
|
||||
|
||||
import AppAlert from "@/components/ui/AppAlert.vue";
|
||||
import AppBrandMark from "@/components/ui/AppBrandMark.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import AppThemePicker from "@/components/ui/AppThemePicker.vue";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
import { useAuthStore } from "@/stores/auth";
|
||||
|
||||
|
|
@ -54,46 +56,22 @@ async function onSubmit(): Promise<void> {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative h-[100dvh] max-h-[100dvh] overflow-y-auto overflow-x-hidden bg-surface-app">
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
<div class="absolute -top-20 left-[-8rem] h-72 w-72 rounded-full bg-brand-300/30 blur-3xl" />
|
||||
<div class="absolute bottom-[-7rem] right-[-6rem] h-80 w-80 rounded-full bg-success-300/25 blur-3xl" />
|
||||
<div class="absolute left-1/3 top-1/3 h-52 w-52 rounded-full bg-warning-300/20 blur-3xl" />
|
||||
<div class="relative h-[100dvh] max-h-[100dvh] overflow-hidden bg-surface-app">
|
||||
<div class="absolute right-4 top-4 z-10 sm:right-6 sm:top-6">
|
||||
<AppThemePicker compact id-prefix="login-theme" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="relative mx-auto grid min-h-full w-full max-w-6xl items-center gap-8 px-4 py-6 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-stroke-strong bg-surface-card/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.12em] text-ink-muted">
|
||||
Scholarr Control Center
|
||||
</p>
|
||||
<h1 class="max-w-xl font-display text-4xl font-semibold tracking-tight text-ink-primary">
|
||||
Scholar tracking with reliable operational controls.
|
||||
</h1>
|
||||
<p class="max-w-xl text-base leading-relaxed text-ink-muted">
|
||||
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-ink-muted">
|
||||
<li class="rounded-lg border border-stroke-default bg-surface-card/60 px-3 py-2">
|
||||
Session-based authentication with CSRF enforcement.
|
||||
</li>
|
||||
<li class="rounded-lg border border-stroke-default bg-surface-card/60 px-3 py-2">
|
||||
Run and queue diagnostics are available for support workflows.
|
||||
</li>
|
||||
<li class="rounded-lg border border-stroke-default bg-surface-card/60 px-3 py-2">
|
||||
Theme-aware interface with light and dark mode support.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
<div class="absolute -top-28 right-[-8rem] h-72 w-72 rounded-full bg-brand-300/25 blur-3xl" />
|
||||
<div class="absolute bottom-[-8rem] left-[-7rem] h-80 w-80 rounded-full bg-info-300/20 blur-3xl" />
|
||||
</div>
|
||||
|
||||
<AppCard class="w-full max-w-md justify-self-end space-y-6 border-stroke-default/80 bg-surface-card/90 backdrop-blur">
|
||||
<div class="space-y-2">
|
||||
<h1 class="font-display text-3xl font-semibold tracking-tight text-ink-primary">Sign In</h1>
|
||||
<p class="text-sm text-secondary">
|
||||
Sign in to access scholar tracking, publication updates, and run diagnostics.
|
||||
</p>
|
||||
<div class="relative mx-auto grid h-full w-full max-w-md items-center px-4 py-8 sm:px-6">
|
||||
<AppCard class="space-y-6 border-stroke-default/80 bg-surface-card/90 p-6 backdrop-blur sm:p-7">
|
||||
<div class="grid justify-items-center gap-2 text-center">
|
||||
<AppBrandMark size="xl" />
|
||||
<p class="font-display text-2xl font-semibold tracking-tight text-ink-primary">scholarr</p>
|
||||
<h1 class="text-sm font-medium uppercase tracking-[0.12em] text-ink-secondary">Sign in</h1>
|
||||
</div>
|
||||
|
||||
<AppAlert v-if="errorMessage" tone="danger">
|
||||
|
|
@ -106,7 +84,7 @@ async function onSubmit(): Promise<void> {
|
|||
<form class="grid gap-4" @submit.prevent="onSubmit">
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
<span>Email</span>
|
||||
<AppInput id="login-email" v-model="email" type="email" autocomplete="email" />
|
||||
<AppInput id="login-email" v-model="email" type="email" autocomplete="email" autofocus />
|
||||
</label>
|
||||
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
|
|
@ -123,6 +101,10 @@ async function onSubmit(): Promise<void> {
|
|||
{{ pending ? "Signing in..." : "Sign in" }}
|
||||
</AppButton>
|
||||
</form>
|
||||
|
||||
<p class="text-center text-xs text-secondary">
|
||||
Use your assigned account credentials.
|
||||
</p>
|
||||
</AppCard>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -6,10 +6,10 @@ import AppPage from "@/components/layout/AppPage.vue";
|
|||
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||
import RunStatusBadge from "@/components/patterns/RunStatusBadge.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||
import AppTable from "@/components/ui/AppTable.vue";
|
||||
import { getRunDetail, type RunDetail } from "@/features/runs";
|
||||
import { ApiRequestError } from "@/lib/api/errors";
|
||||
|
|
@ -108,9 +108,13 @@ onMounted(() => {
|
|||
</div>
|
||||
<p class="text-sm text-secondary">Refresh this run detail view after queue/run updates.</p>
|
||||
</div>
|
||||
<AppButton variant="secondary" @click="loadDetail" :disabled="loading">
|
||||
{{ loading ? "Refreshing..." : "Refresh" }}
|
||||
</AppButton>
|
||||
<AppRefreshButton
|
||||
variant="secondary"
|
||||
:loading="loading"
|
||||
title="Refresh run detail"
|
||||
loading-title="Refreshing run detail"
|
||||
@click="loadDetail"
|
||||
/>
|
||||
</div>
|
||||
</AppCard>
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import AppButton from "@/components/ui/AppButton.vue";
|
|||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||
import AppTable from "@/components/ui/AppTable.vue";
|
||||
import {
|
||||
clearQueueItem,
|
||||
|
|
@ -210,9 +211,13 @@ onMounted(() => {
|
|||
<AppButton :disabled="isStartBlocked" :title="startCheckDisabledReason || undefined" @click="onTriggerManualRun">
|
||||
{{ runButtonLabel }}
|
||||
</AppButton>
|
||||
<AppButton variant="secondary" :disabled="loading" @click="loadData">
|
||||
{{ loading ? "Refreshing..." : "Refresh" }}
|
||||
</AppButton>
|
||||
<AppRefreshButton
|
||||
variant="secondary"
|
||||
:loading="loading"
|
||||
title="Refresh runs and queue"
|
||||
loading-title="Refreshing runs and queue"
|
||||
@click="loadData"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="runStatus.isLikelyRunning"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import AppEmptyState from "@/components/ui/AppEmptyState.vue";
|
|||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import AppModal from "@/components/ui/AppModal.vue";
|
||||
import AppRefreshButton from "@/components/ui/AppRefreshButton.vue";
|
||||
import AppSelect from "@/components/ui/AppSelect.vue";
|
||||
import AppTable from "@/components/ui/AppTable.vue";
|
||||
import {
|
||||
|
|
@ -812,9 +813,14 @@ onMounted(() => {
|
|||
<AppButton variant="secondary" :disabled="loading || importingData" @click="onOpenImportPicker">
|
||||
{{ importingData ? "Importing..." : "Import" }}
|
||||
</AppButton>
|
||||
<AppButton variant="secondary" :disabled="loading || saving" @click="loadScholars">
|
||||
{{ loading ? "Refreshing..." : "Refresh" }}
|
||||
</AppButton>
|
||||
<AppRefreshButton
|
||||
variant="secondary"
|
||||
:disabled="saving"
|
||||
:loading="loading"
|
||||
title="Refresh scholars"
|
||||
loading-title="Refreshing scholars"
|
||||
@click="loadScholars"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
|
||||
import AppPage from "@/components/layout/AppPage.vue";
|
||||
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||
|
|
@ -9,7 +10,8 @@ import AppCard from "@/components/ui/AppCard.vue";
|
|||
import AppCheckbox from "@/components/ui/AppCheckbox.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import AppModal from "@/components/ui/AppModal.vue";
|
||||
import AppTabs, { type AppTabItem } from "@/components/ui/AppTabs.vue";
|
||||
import SettingsAdminPanel from "@/features/settings/SettingsAdminPanel.vue";
|
||||
import {
|
||||
changePassword,
|
||||
fetchSettings,
|
||||
|
|
@ -22,65 +24,18 @@ import { useAuthStore } from "@/stores/auth";
|
|||
import { useRunStatusStore } from "@/stores/run_status";
|
||||
import { normalizeUserNavVisiblePages, useUserSettingsStore } from "@/stores/user_settings";
|
||||
|
||||
interface NavPageOption {
|
||||
id: string;
|
||||
label: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
adminOnly?: boolean;
|
||||
}
|
||||
|
||||
const NAV_PAGE_OPTIONS: NavPageOption[] = [
|
||||
{
|
||||
id: "dashboard",
|
||||
label: "Dashboard",
|
||||
description: "Overview and latest publication updates.",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "scholars",
|
||||
label: "Scholars",
|
||||
description: "Tracked scholar profiles and profile management.",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "publications",
|
||||
label: "Publications",
|
||||
description: "Review and search discovered publication records.",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
description: "Configuration and account controls.",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
id: "style-guide",
|
||||
label: "Style Guide",
|
||||
description: "Admin-only visual reference for theme and component tokens.",
|
||||
required: false,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "runs",
|
||||
label: "Runs",
|
||||
description: "Admin-only diagnostics and queue operations.",
|
||||
required: false,
|
||||
adminOnly: true,
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
label: "Users",
|
||||
description: "Admin-only user management.",
|
||||
required: false,
|
||||
adminOnly: true,
|
||||
},
|
||||
];
|
||||
const TAB_CHECKING = "checking";
|
||||
const TAB_ACCOUNT = "account";
|
||||
const TAB_ADMIN_USERS = "admin-users";
|
||||
const TAB_ADMIN_INTEGRITY = "admin-integrity";
|
||||
const TAB_ADMIN_REPAIRS = "admin-repairs";
|
||||
const TAB_ADMIN_PDF = "admin-pdf";
|
||||
|
||||
const auth = useAuthStore();
|
||||
const userSettings = useUserSettingsStore();
|
||||
const runStatus = useRunStatusStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(true);
|
||||
const saving = ref(false);
|
||||
|
|
@ -98,55 +53,82 @@ 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);
|
||||
const showNavigationModal = ref(false);
|
||||
|
||||
const minCheckIntervalMinutes = ref(15);
|
||||
const minRequestDelaySeconds = ref(2);
|
||||
const automationAllowed = ref(true);
|
||||
const manualRunAllowed = ref(true);
|
||||
const blockedFailureThreshold = ref(1);
|
||||
const networkFailureThreshold = ref(2);
|
||||
const cooldownBlockedSeconds = ref(1800);
|
||||
const cooldownNetworkSeconds = ref(900);
|
||||
|
||||
const visibleNavOptions = computed(() =>
|
||||
NAV_PAGE_OPTIONS.filter((option) => !option.adminOnly || auth.isAdmin),
|
||||
);
|
||||
const visibleNavLabels = computed(() =>
|
||||
visibleNavOptions.value
|
||||
.filter((option) => navVisiblePages.value.includes(option.id))
|
||||
.map((option) => option.label),
|
||||
);
|
||||
const activeTab = ref(TAB_CHECKING);
|
||||
|
||||
const tabItems = computed<AppTabItem[]>(() => {
|
||||
const tabs: AppTabItem[] = [
|
||||
{ id: TAB_CHECKING, label: "Checking" },
|
||||
{ id: TAB_ACCOUNT, label: "Account" },
|
||||
];
|
||||
if (auth.isAdmin) {
|
||||
tabs.push(
|
||||
{ id: TAB_ADMIN_USERS, label: "Users" },
|
||||
{ id: TAB_ADMIN_INTEGRITY, label: "Integrity" },
|
||||
{ id: TAB_ADMIN_REPAIRS, label: "Repairs" },
|
||||
{ id: TAB_ADMIN_PDF, label: "PDF Queue" },
|
||||
);
|
||||
}
|
||||
return tabs;
|
||||
});
|
||||
|
||||
const activeAdminSection = computed<"users" | "integrity" | "repairs" | "pdf" | null>(() => {
|
||||
if (activeTab.value === TAB_ADMIN_USERS) {
|
||||
return "users";
|
||||
}
|
||||
if (activeTab.value === TAB_ADMIN_INTEGRITY) {
|
||||
return "integrity";
|
||||
}
|
||||
if (activeTab.value === TAB_ADMIN_REPAIRS) {
|
||||
return "repairs";
|
||||
}
|
||||
if (activeTab.value === TAB_ADMIN_PDF) {
|
||||
return "pdf";
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
function isKnownTab(tabId: string): boolean {
|
||||
return tabItems.value.some((item) => item.id === tabId);
|
||||
}
|
||||
|
||||
async function syncRouteTab(candidate: string): Promise<void> {
|
||||
const fallback = tabItems.value[0]?.id || TAB_CHECKING;
|
||||
const nextTab = isKnownTab(candidate) ? candidate : fallback;
|
||||
activeTab.value = nextTab;
|
||||
|
||||
const currentQueryTab = typeof route.query.tab === "string" ? route.query.tab : "";
|
||||
if (currentQueryTab === nextTab) {
|
||||
return;
|
||||
}
|
||||
|
||||
await router.replace({ query: { ...route.query, tab: nextTab } });
|
||||
}
|
||||
|
||||
function hydrateSettings(settings: UserSettings): void {
|
||||
const parsedMinRunInterval = Number(settings.policy?.min_run_interval_minutes);
|
||||
minCheckIntervalMinutes.value = Number.isFinite(parsedMinRunInterval)
|
||||
? Math.max(15, parsedMinRunInterval)
|
||||
: 15;
|
||||
|
||||
const parsedMinRequestDelay = Number(settings.policy?.min_request_delay_seconds);
|
||||
minRequestDelaySeconds.value = Number.isFinite(parsedMinRequestDelay)
|
||||
? Math.max(2, parsedMinRequestDelay)
|
||||
: 2;
|
||||
|
||||
automationAllowed.value = Boolean(settings.policy?.automation_allowed ?? true);
|
||||
manualRunAllowed.value = Boolean(settings.policy?.manual_run_allowed ?? true);
|
||||
blockedFailureThreshold.value = Number.isFinite(settings.policy?.blocked_failure_threshold)
|
||||
? Math.max(1, settings.policy.blocked_failure_threshold)
|
||||
: 1;
|
||||
networkFailureThreshold.value = Number.isFinite(settings.policy?.network_failure_threshold)
|
||||
? Math.max(1, settings.policy.network_failure_threshold)
|
||||
: 2;
|
||||
cooldownBlockedSeconds.value = Number.isFinite(settings.policy?.cooldown_blocked_seconds)
|
||||
? Math.max(60, settings.policy.cooldown_blocked_seconds)
|
||||
: 1800;
|
||||
cooldownNetworkSeconds.value = Number.isFinite(settings.policy?.cooldown_network_seconds)
|
||||
? Math.max(60, settings.policy.cooldown_network_seconds)
|
||||
: 900;
|
||||
|
||||
autoRunEnabled.value = Boolean(settings.auto_run_enabled) && automationAllowed.value;
|
||||
runIntervalMinutes.value = String(settings.run_interval_minutes);
|
||||
requestDelaySeconds.value = String(settings.request_delay_seconds);
|
||||
navVisiblePages.value = normalizeUserNavVisiblePages(settings.nav_visible_pages);
|
||||
|
||||
userSettings.applySettings(settings);
|
||||
runStatus.setSafetyState(settings.safety_state);
|
||||
}
|
||||
|
|
@ -162,31 +144,10 @@ function parseBoundedInteger(value: string, label: string, minimum: number): num
|
|||
return parsed;
|
||||
}
|
||||
|
||||
function isNavPageVisible(pageId: string): boolean {
|
||||
return navVisiblePages.value.includes(pageId);
|
||||
}
|
||||
|
||||
function onToggleNavPage(page: NavPageOption, event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
if (page.required && !input.checked) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.checked) {
|
||||
navVisiblePages.value = normalizeUserNavVisiblePages([...navVisiblePages.value, page.id]);
|
||||
return;
|
||||
}
|
||||
|
||||
navVisiblePages.value = normalizeUserNavVisiblePages(
|
||||
navVisiblePages.value.filter((pageId) => pageId !== page.id),
|
||||
);
|
||||
}
|
||||
|
||||
async function loadSettings(): Promise<void> {
|
||||
loading.value = true;
|
||||
errorMessage.value = null;
|
||||
errorRequestId.value = null;
|
||||
|
||||
try {
|
||||
const settings = await fetchSettings();
|
||||
hydrateSettings(settings);
|
||||
|
|
@ -227,8 +188,6 @@ async function onSaveSettings(): Promise<void> {
|
|||
const saved = await updateSettings(payload);
|
||||
hydrateSettings(saved);
|
||||
successMessage.value = "Settings updated.";
|
||||
showIngestionModal.value = false;
|
||||
showNavigationModal.value = false;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiRequestError) {
|
||||
errorMessage.value = error.message;
|
||||
|
|
@ -264,7 +223,6 @@ async function onChangePassword(): Promise<void> {
|
|||
newPassword.value = "";
|
||||
confirmPassword.value = "";
|
||||
successMessage.value = response.message;
|
||||
showPasswordModal.value = false;
|
||||
} catch (error) {
|
||||
if (error instanceof ApiRequestError) {
|
||||
errorMessage.value = error.message;
|
||||
|
|
@ -279,16 +237,35 @@ async function onChangePassword(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadSettings();
|
||||
async function onSelectTab(tabId: string): Promise<void> {
|
||||
await syncRouteTab(tabId);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.tab,
|
||||
async (value) => {
|
||||
const requestedTab = typeof value === "string" ? value : TAB_CHECKING;
|
||||
await syncRouteTab(requestedTab);
|
||||
},
|
||||
);
|
||||
|
||||
watch(
|
||||
() => auth.isAdmin,
|
||||
async () => {
|
||||
const requestedTab = typeof route.query.tab === "string" ? route.query.tab : TAB_CHECKING;
|
||||
await syncRouteTab(requestedTab);
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await loadSettings();
|
||||
const requestedTab = typeof route.query.tab === "string" ? route.query.tab : TAB_CHECKING;
|
||||
await syncRouteTab(requestedTab);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppPage
|
||||
title="Settings"
|
||||
subtitle="Control how often Scholarr checks profiles and how cautiously it sends requests."
|
||||
>
|
||||
<AppPage title="Settings" subtitle="Configuration and account controls in one place.">
|
||||
<RequestStateAlerts
|
||||
:success-message="successMessage"
|
||||
success-title="Saved"
|
||||
|
|
@ -298,189 +275,91 @@ onMounted(() => {
|
|||
@dismiss-success="successMessage = null"
|
||||
/>
|
||||
|
||||
<AsyncStateGate :loading="loading" :loading-lines="7" :show-empty="false">
|
||||
<section class="grid gap-4 xl:grid-cols-3">
|
||||
<AppCard class="flex h-full flex-col gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Automatic Checking</h2>
|
||||
<AppHelpHint text="Controls when Scholarr runs automatic profile checks and how cautiously it scrapes." />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
Configure the background checker that looks for new publications on your tracked profiles.
|
||||
</p>
|
||||
<AppButton variant="secondary" class="mt-auto self-start" @click="showIngestionModal = true">
|
||||
Edit checking rules
|
||||
</AppButton>
|
||||
</AppCard>
|
||||
<AsyncStateGate :loading="loading" :loading-lines="8" :show-empty="false">
|
||||
<AppCard class="grid gap-4">
|
||||
<AppTabs :model-value="activeTab" :items="tabItems" aria-label="Settings sections" @update:model-value="onSelectTab" />
|
||||
|
||||
<AppCard class="flex h-full flex-col gap-4">
|
||||
<section v-if="activeTab === TAB_CHECKING" class="grid gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Automatic Checking</h2>
|
||||
<AppHelpHint text="Controls when Scholarr runs automatic profile checks and how cautiously it scrapes." />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">Configure background checks and request pacing for ingestion.</p>
|
||||
|
||||
<div class="grid gap-3 md:grid-cols-2 md:items-start">
|
||||
<div class="grid gap-2 rounded-lg border border-stroke-default bg-surface-card-muted p-3 md:col-span-2">
|
||||
<AppCheckbox
|
||||
id="auto-run-enabled"
|
||||
v-model="autoRunEnabled"
|
||||
:disabled="!automationAllowed"
|
||||
label="Enable automatic background checks"
|
||||
/>
|
||||
<p class="text-xs text-secondary">
|
||||
Automatic checks are {{ automationAllowed ? "enabled by policy." : "disabled by server safety policy." }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Check interval (minutes)
|
||||
<AppHelpHint text="Minimum is controlled by server policy." />
|
||||
</span>
|
||||
<AppInput v-model="runIntervalMinutes" inputmode="numeric" />
|
||||
<span class="text-xs text-secondary">Minimum: {{ minCheckIntervalMinutes }}</span>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Delay between requests (seconds)
|
||||
<AppHelpHint text="Minimum is controlled by safety policy to reduce blocks." />
|
||||
</span>
|
||||
<AppInput v-model="requestDelaySeconds" inputmode="numeric" />
|
||||
<span class="text-xs text-secondary">Minimum: {{ minRequestDelaySeconds }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
|
||||
<span>Automation allowed: {{ automationAllowed ? "yes" : "no" }}</span>
|
||||
<span>Manual runs allowed: {{ manualRunAllowed ? "yes" : "no" }}</span>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<AppButton :disabled="saving" @click="onSaveSettings">
|
||||
{{ saving ? "Saving..." : "Save checking settings" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="activeTab === TAB_ACCOUNT" class="grid gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Account Access</h2>
|
||||
<AppHelpHint text="Manage credentials for your current signed-in account on this instance." />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
Change your sign-in password from a focused view. This does not affect other users.
|
||||
</p>
|
||||
<AppButton variant="secondary" class="mt-auto self-start" @click="showPasswordModal = true">
|
||||
Change password
|
||||
</AppButton>
|
||||
</AppCard>
|
||||
<p class="text-sm text-secondary">Change your sign-in password directly from this tab.</p>
|
||||
<form class="grid gap-3 rounded-lg border border-stroke-default bg-surface-card-muted p-3" @submit.prevent="onChangePassword">
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Current password</span>
|
||||
<AppInput v-model="currentPassword" type="password" autocomplete="current-password" />
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>New password</span>
|
||||
<AppInput v-model="newPassword" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<label class="grid gap-1 text-sm font-medium text-ink-secondary">
|
||||
<span>Confirm new password</span>
|
||||
<AppInput v-model="confirmPassword" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<AppButton type="submit" :disabled="updatingPassword">
|
||||
{{ updatingPassword ? "Updating..." : "Update password" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<AppCard class="flex h-full flex-col gap-4">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Navigation</h2>
|
||||
<AppHelpHint text="Choose which pages appear in the left sidebar. Dashboard, Scholars, and Settings are always visible." />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
Visible now: {{ visibleNavLabels.length > 0 ? visibleNavLabels.join(", ") : "none" }}.
|
||||
</p>
|
||||
<AppButton variant="secondary" class="mt-auto self-start" @click="showNavigationModal = true">
|
||||
Customize sidebar pages
|
||||
</AppButton>
|
||||
</AppCard>
|
||||
</section>
|
||||
<SettingsAdminPanel v-if="activeAdminSection" :section="activeAdminSection" />
|
||||
</AppCard>
|
||||
</AsyncStateGate>
|
||||
|
||||
<AppModal
|
||||
:open="showIngestionModal"
|
||||
title="Automatic Checking Settings"
|
||||
@close="showIngestionModal = false"
|
||||
>
|
||||
<form class="grid gap-3" @submit.prevent="onSaveSettings">
|
||||
<AppCheckbox
|
||||
id="auto-run-enabled"
|
||||
v-model="autoRunEnabled"
|
||||
:disabled="!automationAllowed"
|
||||
label="Enable automatic background checks"
|
||||
/>
|
||||
<p v-if="!automationAllowed" class="text-xs text-secondary">
|
||||
Automatic checks are disabled by server safety policy.
|
||||
</p>
|
||||
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Check interval (minutes)
|
||||
<AppHelpHint text="How often Scholarr starts a background update check." />
|
||||
</span>
|
||||
<AppInput id="run-interval" v-model="runIntervalMinutes" type="number" :min="minCheckIntervalMinutes" />
|
||||
<span class="text-xs text-secondary">Minimum: {{ minCheckIntervalMinutes }} minutes.</span>
|
||||
</label>
|
||||
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
<span class="inline-flex items-center gap-1">
|
||||
Delay between requests (seconds)
|
||||
<AppHelpHint text="Pause between profile requests during a check. Higher values are slower but safer." />
|
||||
</span>
|
||||
<AppInput
|
||||
id="request-delay"
|
||||
v-model="requestDelaySeconds"
|
||||
type="number"
|
||||
:min="minRequestDelaySeconds"
|
||||
/>
|
||||
<span class="text-xs text-secondary">Minimum: {{ minRequestDelaySeconds }} seconds.</span>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-1 rounded-lg border border-stroke-default bg-surface-card-muted px-3 py-2 text-xs text-secondary">
|
||||
<p class="font-medium text-ink-primary">Server-enforced scrape safety policy</p>
|
||||
<p>Blocked failures trigger cooldown at {{ blockedFailureThreshold }} failures.</p>
|
||||
<p>Network failures trigger cooldown at {{ networkFailureThreshold }} failures.</p>
|
||||
<p>Blocked cooldown: {{ cooldownBlockedSeconds }}s. Network cooldown: {{ cooldownNetworkSeconds }}s.</p>
|
||||
</div>
|
||||
|
||||
<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 Sign-in Password" @close="showPasswordModal = false">
|
||||
<form class="grid gap-3" @submit.prevent="onChangePassword">
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
<span>Current password</span>
|
||||
<AppInput v-model="currentPassword" type="password" autocomplete="current-password" />
|
||||
</label>
|
||||
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
<span>New password</span>
|
||||
<AppInput v-model="newPassword" type="password" autocomplete="new-password" />
|
||||
</label>
|
||||
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary">
|
||||
<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>
|
||||
|
||||
<AppModal :open="showNavigationModal" title="Sidebar Page Visibility" @close="showNavigationModal = false">
|
||||
<form class="grid gap-3" @submit.prevent="onSaveSettings">
|
||||
<p class="text-sm text-secondary">
|
||||
Turn optional pages on or off in the sidebar for your account.
|
||||
</p>
|
||||
|
||||
<ul class="grid gap-2">
|
||||
<li
|
||||
v-for="option in visibleNavOptions"
|
||||
:key="option.id"
|
||||
class="rounded-lg border border-stroke-default bg-surface-card-muted/70 px-3 py-2"
|
||||
>
|
||||
<label class="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="mt-0.5 h-4 w-4 rounded border-stroke-interactive bg-surface-input text-brand-600 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset"
|
||||
:checked="isNavPageVisible(option.id)"
|
||||
:disabled="saving || option.required"
|
||||
@change="onToggleNavPage(option, $event)"
|
||||
/>
|
||||
<span class="grid gap-0.5">
|
||||
<span class="text-sm font-medium text-ink-primary">
|
||||
{{ option.label }}
|
||||
<span v-if="option.required" class="ml-1 text-xs text-ink-muted">(required)</span>
|
||||
</span>
|
||||
<span class="text-xs text-secondary">{{ option.description }}</span>
|
||||
</span>
|
||||
</label>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-2 flex flex-wrap justify-end gap-2">
|
||||
<AppButton
|
||||
variant="ghost"
|
||||
type="button"
|
||||
:disabled="saving"
|
||||
@click="showNavigationModal = false"
|
||||
>
|
||||
Cancel
|
||||
</AppButton>
|
||||
<AppButton type="submit" :disabled="saving">
|
||||
{{ saving ? "Saving..." : "Save sidebar settings" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
</AppModal>
|
||||
</AppPage>
|
||||
</template>
|
||||
|
|
|
|||
|
|
@ -1,89 +0,0 @@
|
|||
# Theme Inventory (Phase 0)
|
||||
|
||||
This file captures the semantic color system baseline for the theme refactor.
|
||||
|
||||
## Token Domains
|
||||
|
||||
- `scale`:
|
||||
- `brand` (`50..950`)
|
||||
- `info` (`50..950`)
|
||||
- `success` (`50..950`)
|
||||
- `warning` (`50..950`)
|
||||
- `danger` (`50..950`)
|
||||
- `surface`:
|
||||
- `app`
|
||||
- `nav`
|
||||
- `nav_active`
|
||||
- `card`
|
||||
- `card_muted`
|
||||
- `table`
|
||||
- `table_header`
|
||||
- `input`
|
||||
- `overlay`
|
||||
- `text`:
|
||||
- `primary`
|
||||
- `secondary`
|
||||
- `muted`
|
||||
- `inverse`
|
||||
- `link`
|
||||
- `border`:
|
||||
- `default`
|
||||
- `strong`
|
||||
- `subtle`
|
||||
- `interactive`
|
||||
- `focus`:
|
||||
- `ring`
|
||||
- `ring_offset`
|
||||
- `action` variants (`primary`, `secondary`, `ghost`, `danger`):
|
||||
- `bg`
|
||||
- `border`
|
||||
- `text`
|
||||
- `hover_bg`
|
||||
- `hover_border`
|
||||
- `hover_text`
|
||||
- `state` variants (`info`, `success`, `warning`, `danger`):
|
||||
- `bg`
|
||||
- `border`
|
||||
- `text`
|
||||
|
||||
## Theme Sources
|
||||
|
||||
Theme presets are dynamically loaded from `frontend/src/theme/presets/*.{json,js}`.
|
||||
|
||||
Current preset files:
|
||||
|
||||
- `frontend/src/theme/presets/parchment.js`
|
||||
- `frontend/src/theme/presets/lilac.js`
|
||||
- `frontend/src/theme/presets/dune.js`
|
||||
- `frontend/src/theme/presets/oatmeal.js`
|
||||
- `frontend/src/theme/presets/scholarly.json`
|
||||
- `frontend/src/theme/presets/graphite.json`
|
||||
- `frontend/src/theme/presets/tide.json`
|
||||
|
||||
Each preset contains both `light` and `dark` mode token definitions.
|
||||
|
||||
## Adoption Status (Phase 0-3 complete baseline)
|
||||
|
||||
Tokenized foundation components:
|
||||
|
||||
- `AppButton`
|
||||
- `AppCard`
|
||||
- `AppCheckbox`
|
||||
- `AppEmptyState`
|
||||
- `AppHelpHint`
|
||||
- `AppInput`
|
||||
- `AppSelect`
|
||||
- `AppTable`
|
||||
- `AppModal`
|
||||
- `AppHeader`
|
||||
- `AppNav`
|
||||
- `AppAlert`
|
||||
- `AppBadge`
|
||||
- `RunStatusBadge`
|
||||
- `QueueHealthBadge`
|
||||
|
||||
Hardening in place:
|
||||
|
||||
- Frontend token policy check script: `frontend/scripts/check_theme_tokens.mjs`
|
||||
- CI enforcement step in `frontend-quality` workflow
|
||||
- Theme preset integrity tests in `frontend/src/theme/presets.test.ts`
|
||||
313
frontend/src/theme/presets/canopy.js
Normal file
313
frontend/src/theme/presets/canopy.js
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
export default {
|
||||
"id": "canopy",
|
||||
"label": "Canopy",
|
||||
"description": "Vibrant forest green palette with crisp mint contrast.",
|
||||
"modes": {
|
||||
"light": {
|
||||
"scale": {
|
||||
"brand": {
|
||||
"50": "#ecfdf5",
|
||||
"100": "#d1fae5",
|
||||
"200": "#a7f3d0",
|
||||
"300": "#6ee7b7",
|
||||
"400": "#34d399",
|
||||
"500": "#10b981",
|
||||
"600": "#059669",
|
||||
"700": "#047857",
|
||||
"800": "#065f46",
|
||||
"900": "#064e3b",
|
||||
"950": "#022c22"
|
||||
},
|
||||
"info": {
|
||||
"50": "#ecfeff",
|
||||
"100": "#cffafe",
|
||||
"200": "#a5f3fc",
|
||||
"300": "#67e8f9",
|
||||
"400": "#22d3ee",
|
||||
"500": "#06b6d4",
|
||||
"600": "#0891b2",
|
||||
"700": "#0e7490",
|
||||
"800": "#155e75",
|
||||
"900": "#164e63",
|
||||
"950": "#083344"
|
||||
},
|
||||
"success": {
|
||||
"50": "#f0fdf4",
|
||||
"100": "#dcfce7",
|
||||
"200": "#bbf7d0",
|
||||
"300": "#86efac",
|
||||
"400": "#4ade80",
|
||||
"500": "#22c55e",
|
||||
"600": "#16a34a",
|
||||
"700": "#15803d",
|
||||
"800": "#166534",
|
||||
"900": "#14532d",
|
||||
"950": "#052e16"
|
||||
},
|
||||
"warning": {
|
||||
"50": "#fffbeb",
|
||||
"100": "#fef3c7",
|
||||
"200": "#fde68a",
|
||||
"300": "#fcd34d",
|
||||
"400": "#fbbf24",
|
||||
"500": "#f59e0b",
|
||||
"600": "#d97706",
|
||||
"700": "#b45309",
|
||||
"800": "#92400e",
|
||||
"900": "#78350f",
|
||||
"950": "#451a03"
|
||||
},
|
||||
"danger": {
|
||||
"50": "#fff1f2",
|
||||
"100": "#ffe4e6",
|
||||
"200": "#fecdd3",
|
||||
"300": "#fda4af",
|
||||
"400": "#fb7185",
|
||||
"500": "#f43f5e",
|
||||
"600": "#e11d48",
|
||||
"700": "#be123c",
|
||||
"800": "#9f1239",
|
||||
"900": "#881337",
|
||||
"950": "#4c0519"
|
||||
}
|
||||
},
|
||||
"surface": {
|
||||
"app": "#eefbf4",
|
||||
"nav": "#ffffff",
|
||||
"nav_active": "#d1fae5",
|
||||
"card": "#ffffff",
|
||||
"card_muted": "#e8f7ef",
|
||||
"table": "#ffffff",
|
||||
"table_header": "#eefbf4",
|
||||
"input": "#ffffff",
|
||||
"overlay": "#0f172a"
|
||||
},
|
||||
"text": {
|
||||
"primary": "#123524",
|
||||
"secondary": "#166534",
|
||||
"muted": "#4b7c67",
|
||||
"inverse": "#ffffff",
|
||||
"link": "#047857"
|
||||
},
|
||||
"border": {
|
||||
"default": "#ccefe0",
|
||||
"strong": "#9dd8bf",
|
||||
"subtle": "#eaf8f1",
|
||||
"interactive": "#34d399"
|
||||
},
|
||||
"focus": {
|
||||
"ring": "#10b981",
|
||||
"ring_offset": "#eefbf4"
|
||||
},
|
||||
"action": {
|
||||
"primary": {
|
||||
"bg": "#10b981",
|
||||
"border": "#10b981",
|
||||
"text": "#ffffff",
|
||||
"hover_bg": "#059669",
|
||||
"hover_border": "#059669",
|
||||
"hover_text": "#ffffff"
|
||||
},
|
||||
"secondary": {
|
||||
"bg": "#e8f7ef",
|
||||
"border": "#ccefe0",
|
||||
"text": "#123524",
|
||||
"hover_bg": "#d1fae5",
|
||||
"hover_border": "#9dd8bf",
|
||||
"hover_text": "#052e16"
|
||||
},
|
||||
"ghost": {
|
||||
"bg": "#eefbf4",
|
||||
"border": "#ccefe0",
|
||||
"text": "#047857",
|
||||
"hover_bg": "#e2f4ea",
|
||||
"hover_border": "#9dd8bf",
|
||||
"hover_text": "#0b3f2b"
|
||||
},
|
||||
"danger": {
|
||||
"bg": "#ffe4e6",
|
||||
"border": "#fecdd3",
|
||||
"text": "#be123c",
|
||||
"hover_bg": "#fecdd3",
|
||||
"hover_border": "#fda4af",
|
||||
"hover_text": "#9f1239"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"info": {
|
||||
"bg": "#cffafe",
|
||||
"border": "#a5f3fc",
|
||||
"text": "#155e75"
|
||||
},
|
||||
"success": {
|
||||
"bg": "#dcfce7",
|
||||
"border": "#bbf7d0",
|
||||
"text": "#166534"
|
||||
},
|
||||
"warning": {
|
||||
"bg": "#fef3c7",
|
||||
"border": "#fde68a",
|
||||
"text": "#92400e"
|
||||
},
|
||||
"danger": {
|
||||
"bg": "#ffe4e6",
|
||||
"border": "#fecdd3",
|
||||
"text": "#be123c"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dark": {
|
||||
"scale": {
|
||||
"brand": {
|
||||
"50": "#ecfdf5",
|
||||
"100": "#d1fae5",
|
||||
"200": "#a7f3d0",
|
||||
"300": "#6ee7b7",
|
||||
"400": "#34d399",
|
||||
"500": "#10b981",
|
||||
"600": "#059669",
|
||||
"700": "#047857",
|
||||
"800": "#065f46",
|
||||
"900": "#064e3b",
|
||||
"950": "#022c22"
|
||||
},
|
||||
"info": {
|
||||
"50": "#ecfeff",
|
||||
"100": "#cffafe",
|
||||
"200": "#a5f3fc",
|
||||
"300": "#67e8f9",
|
||||
"400": "#22d3ee",
|
||||
"500": "#06b6d4",
|
||||
"600": "#0891b2",
|
||||
"700": "#0e7490",
|
||||
"800": "#155e75",
|
||||
"900": "#164e63",
|
||||
"950": "#083344"
|
||||
},
|
||||
"success": {
|
||||
"50": "#f0fdf4",
|
||||
"100": "#dcfce7",
|
||||
"200": "#bbf7d0",
|
||||
"300": "#86efac",
|
||||
"400": "#4ade80",
|
||||
"500": "#22c55e",
|
||||
"600": "#16a34a",
|
||||
"700": "#15803d",
|
||||
"800": "#166534",
|
||||
"900": "#14532d",
|
||||
"950": "#052e16"
|
||||
},
|
||||
"warning": {
|
||||
"50": "#fffbeb",
|
||||
"100": "#fef3c7",
|
||||
"200": "#fde68a",
|
||||
"300": "#fcd34d",
|
||||
"400": "#fbbf24",
|
||||
"500": "#f59e0b",
|
||||
"600": "#d97706",
|
||||
"700": "#b45309",
|
||||
"800": "#92400e",
|
||||
"900": "#78350f",
|
||||
"950": "#451a03"
|
||||
},
|
||||
"danger": {
|
||||
"50": "#fff1f2",
|
||||
"100": "#ffe4e6",
|
||||
"200": "#fecdd3",
|
||||
"300": "#fda4af",
|
||||
"400": "#fb7185",
|
||||
"500": "#f43f5e",
|
||||
"600": "#e11d48",
|
||||
"700": "#be123c",
|
||||
"800": "#9f1239",
|
||||
"900": "#881337",
|
||||
"950": "#4c0519"
|
||||
}
|
||||
},
|
||||
"surface": {
|
||||
"app": "#0b1f17",
|
||||
"nav": "#10271d",
|
||||
"nav_active": "#047857",
|
||||
"card": "#10271d",
|
||||
"card_muted": "#163427",
|
||||
"table": "#10271d",
|
||||
"table_header": "#163427",
|
||||
"input": "#0a1a14",
|
||||
"overlay": "#030712"
|
||||
},
|
||||
"text": {
|
||||
"primary": "#d9f9e8",
|
||||
"secondary": "#9ad8b8",
|
||||
"muted": "#6ea58a",
|
||||
"inverse": "#0b1f17",
|
||||
"link": "#6ee7b7"
|
||||
},
|
||||
"border": {
|
||||
"default": "#1f4635",
|
||||
"strong": "#2a5b45",
|
||||
"subtle": "#163427",
|
||||
"interactive": "#10b981"
|
||||
},
|
||||
"focus": {
|
||||
"ring": "#34d399",
|
||||
"ring_offset": "#0b1f17"
|
||||
},
|
||||
"action": {
|
||||
"primary": {
|
||||
"bg": "#10b981",
|
||||
"border": "#10b981",
|
||||
"text": "#052e16",
|
||||
"hover_bg": "#34d399",
|
||||
"hover_border": "#34d399",
|
||||
"hover_text": "#052e16"
|
||||
},
|
||||
"secondary": {
|
||||
"bg": "#163427",
|
||||
"border": "#2a5b45",
|
||||
"text": "#d9f9e8",
|
||||
"hover_bg": "#214c38",
|
||||
"hover_border": "#2f654d",
|
||||
"hover_text": "#ffffff"
|
||||
},
|
||||
"ghost": {
|
||||
"bg": "#0b1f17",
|
||||
"border": "#1f4635",
|
||||
"text": "#9ad8b8",
|
||||
"hover_bg": "#163427",
|
||||
"hover_border": "#2a5b45",
|
||||
"hover_text": "#d9f9e8"
|
||||
},
|
||||
"danger": {
|
||||
"bg": "#4c0519",
|
||||
"border": "#9f1239",
|
||||
"text": "#fecdd3",
|
||||
"hover_bg": "#5b0b24",
|
||||
"hover_border": "#be123c",
|
||||
"hover_text": "#ffe4e6"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"info": {
|
||||
"bg": "#164e63",
|
||||
"border": "#0e7490",
|
||||
"text": "#cffafe"
|
||||
},
|
||||
"success": {
|
||||
"bg": "#14532d",
|
||||
"border": "#15803d",
|
||||
"text": "#bbf7d0"
|
||||
},
|
||||
"warning": {
|
||||
"bg": "#78350f",
|
||||
"border": "#b45309",
|
||||
"text": "#fde68a"
|
||||
},
|
||||
"danger": {
|
||||
"bg": "#4c0519",
|
||||
"border": "#be123c",
|
||||
"text": "#fecdd3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -1,22 +1,22 @@
|
|||
export default {
|
||||
"id": "dune",
|
||||
"label": "Dune",
|
||||
"description": "Sandy beige environments featuring soft mauve highlights.",
|
||||
"description": "Sandy clay surfaces with warm terracotta accents.",
|
||||
"modes": {
|
||||
"light": {
|
||||
"scale": {
|
||||
"brand": {
|
||||
"50": "#f7f5f6",
|
||||
"100": "#ece7e9",
|
||||
"200": "#d7cccd",
|
||||
"300": "#baabb2",
|
||||
"400": "#9e8893",
|
||||
"500": "#856b77",
|
||||
"600": "#6c5460",
|
||||
"700": "#58424d",
|
||||
"800": "#4a3841",
|
||||
"900": "#3d3036",
|
||||
"950": "#21181d"
|
||||
"50": "#fff5ef",
|
||||
"100": "#fee8da",
|
||||
"200": "#fbcfb2",
|
||||
"300": "#f6ae81",
|
||||
"400": "#ef8753",
|
||||
"500": "#e2672f",
|
||||
"600": "#bf4f25",
|
||||
"700": "#9a3f21",
|
||||
"800": "#7d3420",
|
||||
"900": "#662d1d",
|
||||
"950": "#38140e"
|
||||
},
|
||||
"info": {
|
||||
"50": "#f4f5f8",
|
||||
|
|
@ -74,7 +74,7 @@ export default {
|
|||
"surface": {
|
||||
"app": "#efece5",
|
||||
"nav": "#f7f5f0",
|
||||
"nav_active": "#d7cccd",
|
||||
"nav_active": "#fbcfb2",
|
||||
"card": "#f7f5f0",
|
||||
"card_muted": "#e6e3dd",
|
||||
"table": "#f7f5f0",
|
||||
|
|
@ -87,25 +87,25 @@ export default {
|
|||
"secondary": "#757072",
|
||||
"muted": "#9c9699",
|
||||
"inverse": "#f7f5f0",
|
||||
"link": "#6c5460"
|
||||
"link": "#bf4f25"
|
||||
},
|
||||
"border": {
|
||||
"default": "#e6e3dd",
|
||||
"strong": "#d1cdc6",
|
||||
"subtle": "#f2f0eb",
|
||||
"interactive": "#baabb2"
|
||||
"interactive": "#f6ae81"
|
||||
},
|
||||
"focus": {
|
||||
"ring": "#9e8893",
|
||||
"ring": "#ef8753",
|
||||
"ring_offset": "#efece5"
|
||||
},
|
||||
"action": {
|
||||
"primary": {
|
||||
"bg": "#9e8893",
|
||||
"border": "#9e8893",
|
||||
"bg": "#ef8753",
|
||||
"border": "#ef8753",
|
||||
"text": "#f7f5f0",
|
||||
"hover_bg": "#856b77",
|
||||
"hover_border": "#856b77",
|
||||
"hover_bg": "#e2672f",
|
||||
"hover_border": "#e2672f",
|
||||
"hover_text": "#ffffff"
|
||||
},
|
||||
"secondary": {
|
||||
|
|
@ -119,7 +119,7 @@ export default {
|
|||
"ghost": {
|
||||
"bg": "#efece5",
|
||||
"border": "#e6e3dd",
|
||||
"text": "#6c5460",
|
||||
"text": "#bf4f25",
|
||||
"hover_bg": "#e6e3dd",
|
||||
"hover_border": "#d1cdc6",
|
||||
"hover_text": "#3d3a3b"
|
||||
|
|
@ -159,17 +159,17 @@ export default {
|
|||
"dark": {
|
||||
"scale": {
|
||||
"brand": {
|
||||
"50": "#f7f5f6",
|
||||
"100": "#ece7e9",
|
||||
"200": "#d7cccd",
|
||||
"300": "#baabb2",
|
||||
"400": "#9e8893",
|
||||
"500": "#856b77",
|
||||
"600": "#6c5460",
|
||||
"700": "#58424d",
|
||||
"800": "#4a3841",
|
||||
"900": "#3d3036",
|
||||
"950": "#21181d"
|
||||
"50": "#fff5ef",
|
||||
"100": "#fee8da",
|
||||
"200": "#fbcfb2",
|
||||
"300": "#f6ae81",
|
||||
"400": "#ef8753",
|
||||
"500": "#e2672f",
|
||||
"600": "#bf4f25",
|
||||
"700": "#9a3f21",
|
||||
"800": "#7d3420",
|
||||
"900": "#662d1d",
|
||||
"950": "#38140e"
|
||||
},
|
||||
"info": {
|
||||
"50": "#f4f5f8",
|
||||
|
|
@ -227,7 +227,7 @@ export default {
|
|||
"surface": {
|
||||
"app": "#3d3a3b",
|
||||
"nav": "#4a4748",
|
||||
"nav_active": "#6c5460",
|
||||
"nav_active": "#bf4f25",
|
||||
"card": "#4a4748",
|
||||
"card_muted": "#575354",
|
||||
"table": "#4a4748",
|
||||
|
|
@ -240,25 +240,25 @@ export default {
|
|||
"secondary": "#b8b4b6",
|
||||
"muted": "#918e8f",
|
||||
"inverse": "#3d3a3b",
|
||||
"link": "#baabb2"
|
||||
"link": "#f6ae81"
|
||||
},
|
||||
"border": {
|
||||
"default": "#575354",
|
||||
"strong": "#696466",
|
||||
"subtle": "#474345",
|
||||
"interactive": "#9e8893"
|
||||
"interactive": "#ef8753"
|
||||
},
|
||||
"focus": {
|
||||
"ring": "#9e8893",
|
||||
"ring": "#ef8753",
|
||||
"ring_offset": "#3d3a3b"
|
||||
},
|
||||
"action": {
|
||||
"primary": {
|
||||
"bg": "#9e8893",
|
||||
"border": "#9e8893",
|
||||
"bg": "#ef8753",
|
||||
"border": "#ef8753",
|
||||
"text": "#1f1d1e",
|
||||
"hover_bg": "#baabb2",
|
||||
"hover_border": "#baabb2",
|
||||
"hover_bg": "#f6ae81",
|
||||
"hover_border": "#f6ae81",
|
||||
"hover_text": "#1f1d1e"
|
||||
},
|
||||
"secondary": {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
export default {
|
||||
"id": "oatmeal",
|
||||
"label": "Oatmeal",
|
||||
"description": "Warm beige foundation with muted sage green interactions.",
|
||||
"description": "Cream backdrop with vivid olive-green accents.",
|
||||
"modes": {
|
||||
"light": {
|
||||
"scale": {
|
||||
"brand": {
|
||||
"50": "#f4f7f5",
|
||||
"100": "#e5ece7",
|
||||
"200": "#ccd9cf",
|
||||
"300": "#a8bdae",
|
||||
"400": "#8ba390",
|
||||
"500": "#6c8471",
|
||||
"600": "#546958",
|
||||
"700": "#435446",
|
||||
"800": "#364338",
|
||||
"900": "#2d372f",
|
||||
"950": "#161d18"
|
||||
"50": "#effbf4",
|
||||
"100": "#d8f4e5",
|
||||
"200": "#b4e8cd",
|
||||
"300": "#7fd8ab",
|
||||
"400": "#4ec788",
|
||||
"500": "#2faa6e",
|
||||
"600": "#248a58",
|
||||
"700": "#1f6f48",
|
||||
"800": "#1b593c",
|
||||
"900": "#174a32",
|
||||
"950": "#0c2a1d"
|
||||
},
|
||||
"info": {
|
||||
"50": "#f0f5f6",
|
||||
|
|
@ -74,7 +74,7 @@ export default {
|
|||
"surface": {
|
||||
"app": "#f5f2eb",
|
||||
"nav": "#fcfaf5",
|
||||
"nav_active": "#e5e1d8",
|
||||
"nav_active": "#d8f4e5",
|
||||
"card": "#fcfaf5",
|
||||
"card_muted": "#ece9e1",
|
||||
"table": "#fcfaf5",
|
||||
|
|
@ -87,25 +87,25 @@ export default {
|
|||
"secondary": "#716d68",
|
||||
"muted": "#96918a",
|
||||
"inverse": "#fcfaf5",
|
||||
"link": "#546958"
|
||||
"link": "#248a58"
|
||||
},
|
||||
"border": {
|
||||
"default": "#e5e1d8",
|
||||
"strong": "#cecabf",
|
||||
"subtle": "#f0ede6",
|
||||
"interactive": "#a8bdae"
|
||||
"interactive": "#7fd8ab"
|
||||
},
|
||||
"focus": {
|
||||
"ring": "#8ba390",
|
||||
"ring": "#4ec788",
|
||||
"ring_offset": "#f5f2eb"
|
||||
},
|
||||
"action": {
|
||||
"primary": {
|
||||
"bg": "#8ba390",
|
||||
"border": "#8ba390",
|
||||
"bg": "#4ec788",
|
||||
"border": "#4ec788",
|
||||
"text": "#fcfaf5",
|
||||
"hover_bg": "#6c8471",
|
||||
"hover_border": "#6c8471",
|
||||
"hover_bg": "#2faa6e",
|
||||
"hover_border": "#2faa6e",
|
||||
"hover_text": "#ffffff"
|
||||
},
|
||||
"secondary": {
|
||||
|
|
@ -119,7 +119,7 @@ export default {
|
|||
"ghost": {
|
||||
"bg": "#f5f2eb",
|
||||
"border": "#e5e1d8",
|
||||
"text": "#546958",
|
||||
"text": "#248a58",
|
||||
"hover_bg": "#ece9e1",
|
||||
"hover_border": "#cecabf",
|
||||
"hover_text": "#3d3b38"
|
||||
|
|
@ -159,17 +159,17 @@ export default {
|
|||
"dark": {
|
||||
"scale": {
|
||||
"brand": {
|
||||
"50": "#f4f7f5",
|
||||
"100": "#e5ece7",
|
||||
"200": "#ccd9cf",
|
||||
"300": "#a8bdae",
|
||||
"400": "#8ba390",
|
||||
"500": "#6c8471",
|
||||
"600": "#546958",
|
||||
"700": "#435446",
|
||||
"800": "#364338",
|
||||
"900": "#2d372f",
|
||||
"950": "#161d18"
|
||||
"50": "#effbf4",
|
||||
"100": "#d8f4e5",
|
||||
"200": "#b4e8cd",
|
||||
"300": "#7fd8ab",
|
||||
"400": "#4ec788",
|
||||
"500": "#2faa6e",
|
||||
"600": "#248a58",
|
||||
"700": "#1f6f48",
|
||||
"800": "#1b593c",
|
||||
"900": "#174a32",
|
||||
"950": "#0c2a1d"
|
||||
},
|
||||
"info": {
|
||||
"50": "#f0f5f6",
|
||||
|
|
@ -227,7 +227,7 @@ export default {
|
|||
"surface": {
|
||||
"app": "#3a3937",
|
||||
"nav": "#454442",
|
||||
"nav_active": "#546958",
|
||||
"nav_active": "#248a58",
|
||||
"card": "#454442",
|
||||
"card_muted": "#504e4c",
|
||||
"table": "#454442",
|
||||
|
|
@ -240,25 +240,25 @@ export default {
|
|||
"secondary": "#bebcb8",
|
||||
"muted": "#969490",
|
||||
"inverse": "#3a3937",
|
||||
"link": "#a8bdae"
|
||||
"link": "#7fd8ab"
|
||||
},
|
||||
"border": {
|
||||
"default": "#504e4c",
|
||||
"strong": "#62605d",
|
||||
"subtle": "#403f3d",
|
||||
"interactive": "#8ba390"
|
||||
"interactive": "#4ec788"
|
||||
},
|
||||
"focus": {
|
||||
"ring": "#8ba390",
|
||||
"ring": "#4ec788",
|
||||
"ring_offset": "#3a3937"
|
||||
},
|
||||
"action": {
|
||||
"primary": {
|
||||
"bg": "#8ba390",
|
||||
"border": "#8ba390",
|
||||
"bg": "#4ec788",
|
||||
"border": "#4ec788",
|
||||
"text": "#1a1918",
|
||||
"hover_bg": "#a8bdae",
|
||||
"hover_border": "#a8bdae",
|
||||
"hover_bg": "#7fd8ab",
|
||||
"hover_border": "#7fd8ab",
|
||||
"hover_text": "#1a1918"
|
||||
},
|
||||
"secondary": {
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
export default {
|
||||
"id": "parchment",
|
||||
"label": "Parchment",
|
||||
"description": "Dusty beige canvas paired with slate blue accents.",
|
||||
"description": "Cool parchment base with bold indigo-blue accents.",
|
||||
"modes": {
|
||||
"light": {
|
||||
"scale": {
|
||||
"brand": {
|
||||
"50": "#f5f7f9",
|
||||
"100": "#eaf0f4",
|
||||
"200": "#d5e0e9",
|
||||
"300": "#b3c9d7",
|
||||
"400": "#8daabc",
|
||||
"500": "#7d93a4",
|
||||
"600": "#5c7487",
|
||||
"700": "#495e6f",
|
||||
"800": "#3f4d5b",
|
||||
"900": "#36414e",
|
||||
"950": "#232b35"
|
||||
"50": "#f2f6ff",
|
||||
"100": "#e3edff",
|
||||
"200": "#c6d8ff",
|
||||
"300": "#9db9ff",
|
||||
"400": "#7596f4",
|
||||
"500": "#5878da",
|
||||
"600": "#4362bc",
|
||||
"700": "#384f95",
|
||||
"800": "#31427b",
|
||||
"900": "#2b3965",
|
||||
"950": "#1b223f"
|
||||
},
|
||||
"info": {
|
||||
"50": "#f2f6fa",
|
||||
|
|
@ -74,7 +74,7 @@ export default {
|
|||
"surface": {
|
||||
"app": "#f2eee9",
|
||||
"nav": "#faf8f5",
|
||||
"nav_active": "#d5e0e9",
|
||||
"nav_active": "#c6d8ff",
|
||||
"card": "#faf8f5",
|
||||
"card_muted": "#eae5df",
|
||||
"table": "#faf8f5",
|
||||
|
|
@ -87,25 +87,25 @@ export default {
|
|||
"secondary": "#6b737c",
|
||||
"muted": "#929ba5",
|
||||
"inverse": "#faf8f5",
|
||||
"link": "#5c7487"
|
||||
"link": "#4362bc"
|
||||
},
|
||||
"border": {
|
||||
"default": "#eae5df",
|
||||
"strong": "#d4cec6",
|
||||
"subtle": "#f6f3ef",
|
||||
"interactive": "#b3c9d7"
|
||||
"interactive": "#9db9ff"
|
||||
},
|
||||
"focus": {
|
||||
"ring": "#7d93a4",
|
||||
"ring": "#5878da",
|
||||
"ring_offset": "#f2eee9"
|
||||
},
|
||||
"action": {
|
||||
"primary": {
|
||||
"bg": "#7d93a4",
|
||||
"border": "#7d93a4",
|
||||
"bg": "#5878da",
|
||||
"border": "#5878da",
|
||||
"text": "#faf8f5",
|
||||
"hover_bg": "#5c7487",
|
||||
"hover_border": "#5c7487",
|
||||
"hover_bg": "#4362bc",
|
||||
"hover_border": "#4362bc",
|
||||
"hover_text": "#ffffff"
|
||||
},
|
||||
"secondary": {
|
||||
|
|
@ -119,7 +119,7 @@ export default {
|
|||
"ghost": {
|
||||
"bg": "#f2eee9",
|
||||
"border": "#eae5df",
|
||||
"text": "#5c7487",
|
||||
"text": "#4362bc",
|
||||
"hover_bg": "#eae5df",
|
||||
"hover_border": "#d4cec6",
|
||||
"hover_text": "#333638"
|
||||
|
|
@ -159,17 +159,17 @@ export default {
|
|||
"dark": {
|
||||
"scale": {
|
||||
"brand": {
|
||||
"50": "#f5f7f9",
|
||||
"100": "#eaf0f4",
|
||||
"200": "#d5e0e9",
|
||||
"300": "#b3c9d7",
|
||||
"400": "#8daabc",
|
||||
"500": "#7d93a4",
|
||||
"600": "#5c7487",
|
||||
"700": "#495e6f",
|
||||
"800": "#3f4d5b",
|
||||
"900": "#36414e",
|
||||
"950": "#232b35"
|
||||
"50": "#f2f6ff",
|
||||
"100": "#e3edff",
|
||||
"200": "#c6d8ff",
|
||||
"300": "#9db9ff",
|
||||
"400": "#7596f4",
|
||||
"500": "#5878da",
|
||||
"600": "#4362bc",
|
||||
"700": "#384f95",
|
||||
"800": "#31427b",
|
||||
"900": "#2b3965",
|
||||
"950": "#1b223f"
|
||||
},
|
||||
"info": {
|
||||
"50": "#f2f6fa",
|
||||
|
|
@ -227,7 +227,7 @@ export default {
|
|||
"surface": {
|
||||
"app": "#35373a",
|
||||
"nav": "#424549",
|
||||
"nav_active": "#5c7487",
|
||||
"nav_active": "#4362bc",
|
||||
"card": "#424549",
|
||||
"card_muted": "#4d5156",
|
||||
"table": "#424549",
|
||||
|
|
@ -240,25 +240,25 @@ export default {
|
|||
"secondary": "#aeb5bc",
|
||||
"muted": "#889098",
|
||||
"inverse": "#35373a",
|
||||
"link": "#b3c9d7"
|
||||
"link": "#9db9ff"
|
||||
},
|
||||
"border": {
|
||||
"default": "#4d5156",
|
||||
"strong": "#5d6268",
|
||||
"subtle": "#3e4145",
|
||||
"interactive": "#7d93a4"
|
||||
"interactive": "#5878da"
|
||||
},
|
||||
"focus": {
|
||||
"ring": "#7d93a4",
|
||||
"ring": "#5878da",
|
||||
"ring_offset": "#35373a"
|
||||
},
|
||||
"action": {
|
||||
"primary": {
|
||||
"bg": "#7d93a4",
|
||||
"border": "#7d93a4",
|
||||
"bg": "#5878da",
|
||||
"border": "#5878da",
|
||||
"text": "#1a1b1d",
|
||||
"hover_bg": "#b3c9d7",
|
||||
"hover_border": "#b3c9d7",
|
||||
"hover_bg": "#9db9ff",
|
||||
"hover_border": "#9db9ff",
|
||||
"hover_text": "#1a1b1d"
|
||||
},
|
||||
"secondary": {
|
||||
|
|
|
|||
313
frontend/src/theme/presets/sunset.js
Normal file
313
frontend/src/theme/presets/sunset.js
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
export default {
|
||||
"id": "sunset",
|
||||
"label": "Sunset",
|
||||
"description": "High-contrast amber and ember tones for a warmer visual rhythm.",
|
||||
"modes": {
|
||||
"light": {
|
||||
"scale": {
|
||||
"brand": {
|
||||
"50": "#fff7ed",
|
||||
"100": "#ffedd5",
|
||||
"200": "#fed7aa",
|
||||
"300": "#fdba74",
|
||||
"400": "#fb923c",
|
||||
"500": "#f97316",
|
||||
"600": "#ea580c",
|
||||
"700": "#c2410c",
|
||||
"800": "#9a3412",
|
||||
"900": "#7c2d12",
|
||||
"950": "#431407"
|
||||
},
|
||||
"info": {
|
||||
"50": "#eef2ff",
|
||||
"100": "#e0e7ff",
|
||||
"200": "#c7d2fe",
|
||||
"300": "#a5b4fc",
|
||||
"400": "#818cf8",
|
||||
"500": "#6366f1",
|
||||
"600": "#4f46e5",
|
||||
"700": "#4338ca",
|
||||
"800": "#3730a3",
|
||||
"900": "#312e81",
|
||||
"950": "#1e1b4b"
|
||||
},
|
||||
"success": {
|
||||
"50": "#f0fdf4",
|
||||
"100": "#dcfce7",
|
||||
"200": "#bbf7d0",
|
||||
"300": "#86efac",
|
||||
"400": "#4ade80",
|
||||
"500": "#22c55e",
|
||||
"600": "#16a34a",
|
||||
"700": "#15803d",
|
||||
"800": "#166534",
|
||||
"900": "#14532d",
|
||||
"950": "#052e16"
|
||||
},
|
||||
"warning": {
|
||||
"50": "#fffbeb",
|
||||
"100": "#fef3c7",
|
||||
"200": "#fde68a",
|
||||
"300": "#fcd34d",
|
||||
"400": "#fbbf24",
|
||||
"500": "#f59e0b",
|
||||
"600": "#d97706",
|
||||
"700": "#b45309",
|
||||
"800": "#92400e",
|
||||
"900": "#78350f",
|
||||
"950": "#451a03"
|
||||
},
|
||||
"danger": {
|
||||
"50": "#fff1f2",
|
||||
"100": "#ffe4e6",
|
||||
"200": "#fecdd3",
|
||||
"300": "#fda4af",
|
||||
"400": "#fb7185",
|
||||
"500": "#f43f5e",
|
||||
"600": "#e11d48",
|
||||
"700": "#be123c",
|
||||
"800": "#9f1239",
|
||||
"900": "#881337",
|
||||
"950": "#4c0519"
|
||||
}
|
||||
},
|
||||
"surface": {
|
||||
"app": "#fff5ef",
|
||||
"nav": "#ffffff",
|
||||
"nav_active": "#fed7aa",
|
||||
"card": "#ffffff",
|
||||
"card_muted": "#fff0e5",
|
||||
"table": "#ffffff",
|
||||
"table_header": "#fff5ef",
|
||||
"input": "#ffffff",
|
||||
"overlay": "#2b1a12"
|
||||
},
|
||||
"text": {
|
||||
"primary": "#3b2217",
|
||||
"secondary": "#7c3f1d",
|
||||
"muted": "#a05d37",
|
||||
"inverse": "#ffffff",
|
||||
"link": "#c2410c"
|
||||
},
|
||||
"border": {
|
||||
"default": "#f4d8c6",
|
||||
"strong": "#e9bc9f",
|
||||
"subtle": "#fff0e5",
|
||||
"interactive": "#fb923c"
|
||||
},
|
||||
"focus": {
|
||||
"ring": "#f97316",
|
||||
"ring_offset": "#fff5ef"
|
||||
},
|
||||
"action": {
|
||||
"primary": {
|
||||
"bg": "#f97316",
|
||||
"border": "#f97316",
|
||||
"text": "#ffffff",
|
||||
"hover_bg": "#ea580c",
|
||||
"hover_border": "#ea580c",
|
||||
"hover_text": "#ffffff"
|
||||
},
|
||||
"secondary": {
|
||||
"bg": "#fff0e5",
|
||||
"border": "#f4d8c6",
|
||||
"text": "#3b2217",
|
||||
"hover_bg": "#fee2cf",
|
||||
"hover_border": "#e9bc9f",
|
||||
"hover_text": "#2a170f"
|
||||
},
|
||||
"ghost": {
|
||||
"bg": "#fff5ef",
|
||||
"border": "#f4d8c6",
|
||||
"text": "#c2410c",
|
||||
"hover_bg": "#fff0e5",
|
||||
"hover_border": "#e9bc9f",
|
||||
"hover_text": "#7c2d12"
|
||||
},
|
||||
"danger": {
|
||||
"bg": "#ffe4e6",
|
||||
"border": "#fecdd3",
|
||||
"text": "#be123c",
|
||||
"hover_bg": "#fecdd3",
|
||||
"hover_border": "#fda4af",
|
||||
"hover_text": "#9f1239"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"info": {
|
||||
"bg": "#e0e7ff",
|
||||
"border": "#c7d2fe",
|
||||
"text": "#4338ca"
|
||||
},
|
||||
"success": {
|
||||
"bg": "#dcfce7",
|
||||
"border": "#bbf7d0",
|
||||
"text": "#166534"
|
||||
},
|
||||
"warning": {
|
||||
"bg": "#ffedd5",
|
||||
"border": "#fed7aa",
|
||||
"text": "#9a3412"
|
||||
},
|
||||
"danger": {
|
||||
"bg": "#ffe4e6",
|
||||
"border": "#fecdd3",
|
||||
"text": "#be123c"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dark": {
|
||||
"scale": {
|
||||
"brand": {
|
||||
"50": "#fff7ed",
|
||||
"100": "#ffedd5",
|
||||
"200": "#fed7aa",
|
||||
"300": "#fdba74",
|
||||
"400": "#fb923c",
|
||||
"500": "#f97316",
|
||||
"600": "#ea580c",
|
||||
"700": "#c2410c",
|
||||
"800": "#9a3412",
|
||||
"900": "#7c2d12",
|
||||
"950": "#431407"
|
||||
},
|
||||
"info": {
|
||||
"50": "#eef2ff",
|
||||
"100": "#e0e7ff",
|
||||
"200": "#c7d2fe",
|
||||
"300": "#a5b4fc",
|
||||
"400": "#818cf8",
|
||||
"500": "#6366f1",
|
||||
"600": "#4f46e5",
|
||||
"700": "#4338ca",
|
||||
"800": "#3730a3",
|
||||
"900": "#312e81",
|
||||
"950": "#1e1b4b"
|
||||
},
|
||||
"success": {
|
||||
"50": "#f0fdf4",
|
||||
"100": "#dcfce7",
|
||||
"200": "#bbf7d0",
|
||||
"300": "#86efac",
|
||||
"400": "#4ade80",
|
||||
"500": "#22c55e",
|
||||
"600": "#16a34a",
|
||||
"700": "#15803d",
|
||||
"800": "#166534",
|
||||
"900": "#14532d",
|
||||
"950": "#052e16"
|
||||
},
|
||||
"warning": {
|
||||
"50": "#fffbeb",
|
||||
"100": "#fef3c7",
|
||||
"200": "#fde68a",
|
||||
"300": "#fcd34d",
|
||||
"400": "#fbbf24",
|
||||
"500": "#f59e0b",
|
||||
"600": "#d97706",
|
||||
"700": "#b45309",
|
||||
"800": "#92400e",
|
||||
"900": "#78350f",
|
||||
"950": "#451a03"
|
||||
},
|
||||
"danger": {
|
||||
"50": "#fff1f2",
|
||||
"100": "#ffe4e6",
|
||||
"200": "#fecdd3",
|
||||
"300": "#fda4af",
|
||||
"400": "#fb7185",
|
||||
"500": "#f43f5e",
|
||||
"600": "#e11d48",
|
||||
"700": "#be123c",
|
||||
"800": "#9f1239",
|
||||
"900": "#881337",
|
||||
"950": "#4c0519"
|
||||
}
|
||||
},
|
||||
"surface": {
|
||||
"app": "#24140d",
|
||||
"nav": "#2f1b12",
|
||||
"nav_active": "#c2410c",
|
||||
"card": "#2f1b12",
|
||||
"card_muted": "#3a2419",
|
||||
"table": "#2f1b12",
|
||||
"table_header": "#3a2419",
|
||||
"input": "#1c100a",
|
||||
"overlay": "#09090b"
|
||||
},
|
||||
"text": {
|
||||
"primary": "#ffe8d7",
|
||||
"secondary": "#efb58f",
|
||||
"muted": "#cb8f6b",
|
||||
"inverse": "#24140d",
|
||||
"link": "#fdba74"
|
||||
},
|
||||
"border": {
|
||||
"default": "#4a2f20",
|
||||
"strong": "#5d3b2a",
|
||||
"subtle": "#3a2419",
|
||||
"interactive": "#f97316"
|
||||
},
|
||||
"focus": {
|
||||
"ring": "#fb923c",
|
||||
"ring_offset": "#24140d"
|
||||
},
|
||||
"action": {
|
||||
"primary": {
|
||||
"bg": "#f97316",
|
||||
"border": "#f97316",
|
||||
"text": "#1c100a",
|
||||
"hover_bg": "#fb923c",
|
||||
"hover_border": "#fb923c",
|
||||
"hover_text": "#1c100a"
|
||||
},
|
||||
"secondary": {
|
||||
"bg": "#3a2419",
|
||||
"border": "#5d3b2a",
|
||||
"text": "#ffe8d7",
|
||||
"hover_bg": "#4a2f20",
|
||||
"hover_border": "#6f4834",
|
||||
"hover_text": "#ffffff"
|
||||
},
|
||||
"ghost": {
|
||||
"bg": "#24140d",
|
||||
"border": "#4a2f20",
|
||||
"text": "#efb58f",
|
||||
"hover_bg": "#3a2419",
|
||||
"hover_border": "#5d3b2a",
|
||||
"hover_text": "#ffe8d7"
|
||||
},
|
||||
"danger": {
|
||||
"bg": "#4c0519",
|
||||
"border": "#9f1239",
|
||||
"text": "#fecdd3",
|
||||
"hover_bg": "#5d0b26",
|
||||
"hover_border": "#be123c",
|
||||
"hover_text": "#ffe4e6"
|
||||
}
|
||||
},
|
||||
"state": {
|
||||
"info": {
|
||||
"bg": "#312e81",
|
||||
"border": "#4338ca",
|
||||
"text": "#c7d2fe"
|
||||
},
|
||||
"success": {
|
||||
"bg": "#14532d",
|
||||
"border": "#15803d",
|
||||
"text": "#bbf7d0"
|
||||
},
|
||||
"warning": {
|
||||
"bg": "#7c2d12",
|
||||
"border": "#c2410c",
|
||||
"text": "#fed7aa"
|
||||
},
|
||||
"danger": {
|
||||
"bg": "#4c0519",
|
||||
"border": "#be123c",
|
||||
"text": "#fecdd3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue