- Extract scholar ID parsing to dedicated module with robust URL handling (trailing slashes, fragments, extra query params, encoded characters) and per-token error feedback showing why each invalid entry was skipped - Wrap delete_scholar DB commit in IntegrityError handler so cascade failures return a proper API error instead of 500 - Add ScholarStatusBadges component showing Pending/Failed/Partial/OK badges on scholar rows based on baseline_completed and last_run_status - Show Pending badge in ScholarSettingsModal for unscraped scholars - Surface failed_count and partial_count in dashboard latest run summary and recent run list items - Extend backend validator tests with edge cases (empty, whitespace, unicode, URL-as-ID) - Add integration tests for DELETE 404, DELETE with cascade, and POST with corrupted scholar_id - Add comprehensive frontend tests for parsing logic and component behavior
120 lines
5 KiB
Vue
120 lines
5 KiB
Vue
<script setup lang="ts">
|
|
import AppButton from "@/components/ui/AppButton.vue";
|
|
import AppInput from "@/components/ui/AppInput.vue";
|
|
import AppModal from "@/components/ui/AppModal.vue";
|
|
import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue";
|
|
import type { ScholarProfile } from "@/features/scholars";
|
|
|
|
const props = defineProps<{
|
|
scholar: ScholarProfile | null;
|
|
imageUrlDraft: string;
|
|
imageBusy: boolean;
|
|
imageSaving: boolean;
|
|
imageUploading: boolean;
|
|
saving: boolean;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: "close"): void;
|
|
(e: "update:image-url-draft", value: string): void;
|
|
(e: "save-image-url"): void;
|
|
(e: "upload-image", event: Event): void;
|
|
(e: "reset-image"): void;
|
|
(e: "toggle"): void;
|
|
(e: "delete"): void;
|
|
}>();
|
|
|
|
function scholarLabel(profile: ScholarProfile): string {
|
|
return profile.display_name || profile.scholar_id;
|
|
}
|
|
|
|
function scholarProfileUrl(scholarId: string): string {
|
|
return `https://scholar.google.com/citations?hl=en&user=${encodeURIComponent(scholarId)}`;
|
|
}
|
|
|
|
function scholarPublicationsRoute(profile: ScholarProfile): { name: string; query: { scholar: string } } {
|
|
return { name: "publications", query: { scholar: String(profile.id) } };
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<AppModal :open="scholar !== null" title="Scholar settings" @close="emit('close')">
|
|
<div v-if="scholar" class="grid gap-4">
|
|
<div class="flex items-start gap-3">
|
|
<ScholarAvatar
|
|
:label="scholar.display_name"
|
|
:scholar-id="scholar.scholar_id"
|
|
:image-url="scholar.profile_image_url"
|
|
/>
|
|
<div class="min-w-0 space-y-1">
|
|
<p class="truncate text-sm font-semibold text-ink-primary">
|
|
{{ scholarLabel(scholar) }}
|
|
<span
|
|
v-if="!scholar.baseline_completed"
|
|
class="ml-1.5 inline-flex items-center rounded-full bg-amber-50 px-1.5 py-0.5 text-[10px] font-medium text-amber-700 ring-1 ring-inset ring-amber-600/20 dark:bg-amber-950 dark:text-amber-400 dark:ring-amber-400/20"
|
|
title="This scholar hasn't been scraped yet. Publications will appear after the next run."
|
|
>Pending</span>
|
|
</p>
|
|
<p class="text-xs text-secondary">ID: <code>{{ scholar.scholar_id }}</code></p>
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<RouterLink :to="scholarPublicationsRoute(scholar)" class="link-inline text-xs">
|
|
View publications
|
|
</RouterLink>
|
|
<a :href="scholarProfileUrl(scholar.scholar_id)" target="_blank" rel="noreferrer" class="link-inline text-xs">
|
|
Open Google Scholar profile
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="grid gap-2">
|
|
<label class="text-sm font-medium text-ink-secondary" :for="`scholar-image-url-${scholar.id}`">
|
|
Profile image URL override
|
|
</label>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<div class="min-w-0 flex-1">
|
|
<AppInput
|
|
:id="`scholar-image-url-${scholar.id}`"
|
|
:model-value="imageUrlDraft"
|
|
placeholder="https://example.com/avatar.jpg"
|
|
:disabled="imageBusy"
|
|
@update:model-value="emit('update:image-url-draft', $event as string)"
|
|
/>
|
|
</div>
|
|
<AppButton variant="secondary" :disabled="imageBusy" @click="emit('save-image-url')">
|
|
{{ imageSaving ? "Saving..." : "Save URL" }}
|
|
</AppButton>
|
|
</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<label
|
|
:for="`scholar-image-upload-${scholar.id}`"
|
|
class="inline-flex min-h-10 cursor-pointer items-center justify-center rounded-lg border border-stroke-strong bg-action-secondary-bg px-3 py-2 text-sm font-semibold text-action-secondary-text transition hover:bg-action-secondary-hover-bg focus-within:outline-none focus-within:ring-2 focus-within:ring-focus-ring focus-within:ring-offset-2 focus-within:ring-offset-focus-offset"
|
|
:class="{ 'pointer-events-none opacity-60': imageBusy }"
|
|
>
|
|
{{ imageUploading ? "Uploading..." : "Upload image" }}
|
|
</label>
|
|
<input
|
|
:id="`scholar-image-upload-${scholar.id}`"
|
|
type="file"
|
|
class="sr-only"
|
|
accept="image/jpeg,image/png,image/webp,image/gif"
|
|
:disabled="imageBusy"
|
|
@change="emit('upload-image', $event)"
|
|
/>
|
|
<AppButton variant="ghost" :disabled="imageBusy" @click="emit('reset-image')">
|
|
Reset image
|
|
</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="imageBusy || saving" @click="emit('toggle')">
|
|
{{ scholar.is_enabled ? "Disable scholar" : "Enable scholar" }}
|
|
</AppButton>
|
|
<AppButton variant="danger" :disabled="imageBusy || saving" @click="emit('delete')">
|
|
Delete scholar
|
|
</AppButton>
|
|
</div>
|
|
</div>
|
|
</AppModal>
|
|
</template>
|