after audit
This commit is contained in:
parent
e8e20637e6
commit
a5165dc3ba
80 changed files with 8489 additions and 7302 deletions
|
|
@ -0,0 +1,76 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ScholarBatchAdd from "./ScholarBatchAdd.vue";
|
||||
|
||||
const defaultProps = { saving: false, loading: false };
|
||||
|
||||
describe("ScholarBatchAdd", () => {
|
||||
it("renders the heading and input", () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
expect(wrapper.text()).toContain("Add Scholar Profiles");
|
||||
expect(wrapper.find("textarea").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows parsed ID count as 0 for empty input", () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
expect(wrapper.text()).toContain("Parsed IDs:");
|
||||
});
|
||||
|
||||
it("parses bare scholar IDs from textarea input", async () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
await wrapper.find("textarea").setValue("A-UbBTPM15wL");
|
||||
expect(wrapper.text()).toContain("1");
|
||||
});
|
||||
|
||||
it("parses scholar IDs from Google Scholar URLs", async () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
await wrapper.find("textarea").setValue(
|
||||
"https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL",
|
||||
);
|
||||
expect(wrapper.text()).toContain("1");
|
||||
});
|
||||
|
||||
it("deduplicates IDs from mixed input", async () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
await wrapper.find("textarea").setValue(
|
||||
"A-UbBTPM15wL\nhttps://scholar.google.com/citations?user=A-UbBTPM15wL",
|
||||
);
|
||||
expect(wrapper.text()).toContain("1");
|
||||
});
|
||||
|
||||
it("parses multiple IDs separated by commas", async () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
await wrapper.find("textarea").setValue("A-UbBTPM15wL, B-UbBTPM15wL");
|
||||
expect(wrapper.text()).toContain("2");
|
||||
});
|
||||
|
||||
it("emits add-scholars with parsed IDs on submit", async () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
await wrapper.find("textarea").setValue("A-UbBTPM15wL");
|
||||
await wrapper.find("form").trigger("submit");
|
||||
const emitted = wrapper.emitted("add-scholars");
|
||||
expect(emitted).toHaveLength(1);
|
||||
expect(emitted![0][0]).toEqual(["A-UbBTPM15wL"]);
|
||||
});
|
||||
|
||||
it("clears textarea after successful submit", async () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
const textarea = wrapper.find("textarea");
|
||||
await textarea.setValue("A-UbBTPM15wL");
|
||||
await wrapper.find("form").trigger("submit");
|
||||
expect((textarea.element as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
it("does not emit when input contains no valid IDs", async () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: defaultProps });
|
||||
await wrapper.find("textarea").setValue("not-a-valid-id");
|
||||
await wrapper.find("form").trigger("submit");
|
||||
expect(wrapper.emitted("add-scholars")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("shows Adding... label when saving prop is true", () => {
|
||||
const wrapper = mount(ScholarBatchAdd, { props: { ...defaultProps, saving: true } });
|
||||
expect(wrapper.text()).toContain("Adding...");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
|
||||
defineProps<{
|
||||
saving: boolean;
|
||||
loading: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "add-scholars", ids: string[]): void;
|
||||
}>();
|
||||
|
||||
const scholarBatchInput = ref("");
|
||||
|
||||
const SCHOLAR_ID_PATTERN = /^[a-zA-Z0-9_-]{12}$/;
|
||||
const URL_USER_PARAM_PATTERN = /(?:\?|&)user=([a-zA-Z0-9_-]{12})(?:&|#|$)/i;
|
||||
|
||||
function parseScholarIds(raw: string): string[] {
|
||||
const ordered: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const tokens = raw.split(/[\s,;]+/).map((v) => v.trim()).filter((v) => v.length > 0);
|
||||
for (const token of tokens) {
|
||||
let candidate: string | null = null;
|
||||
if (SCHOLAR_ID_PATTERN.test(token)) candidate = token;
|
||||
if (!candidate) {
|
||||
const directParamMatch = token.match(URL_USER_PARAM_PATTERN);
|
||||
if (directParamMatch) candidate = directParamMatch[1];
|
||||
}
|
||||
if (!candidate && token.includes("scholar.google")) {
|
||||
try {
|
||||
const parsed = new URL(token);
|
||||
const userParam = parsed.searchParams.get("user");
|
||||
if (userParam && SCHOLAR_ID_PATTERN.test(userParam)) candidate = userParam;
|
||||
} catch (_error) { /* Ignore non-URL tokens. */ }
|
||||
}
|
||||
if (!candidate || seen.has(candidate)) continue;
|
||||
seen.add(candidate);
|
||||
ordered.push(candidate);
|
||||
}
|
||||
return ordered;
|
||||
}
|
||||
|
||||
const parsedBatchCount = computed(() => parseScholarIds(scholarBatchInput.value).length);
|
||||
|
||||
function onSubmit(): void {
|
||||
const ids = parseScholarIds(scholarBatchInput.value);
|
||||
if (ids.length > 0) {
|
||||
emit("add-scholars", ids);
|
||||
scholarBatchInput.value = "";
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard class="space-y-4 xl:flex xl:min-h-0 xl:flex-col">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Add Scholar Profiles</h2>
|
||||
<AppHelpHint text="A scholar profile is a Google Scholar author page that Scholarr will monitor for publication changes." />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">Paste one or more Scholar IDs or profile URLs and add them in one action.</p>
|
||||
</div>
|
||||
|
||||
<form class="grid gap-3" @submit.prevent="onSubmit">
|
||||
<label class="grid gap-2 text-sm font-medium text-ink-secondary" for="scholar-batch-input">
|
||||
<span>Scholar IDs or profile URLs</span>
|
||||
<textarea
|
||||
id="scholar-batch-input"
|
||||
v-model="scholarBatchInput"
|
||||
rows="5"
|
||||
placeholder="A-UbBTPM15wL https://scholar.google.com/citations?hl=en&user=A-UbBTPM15wL"
|
||||
class="w-full rounded-lg border border-stroke-interactive bg-surface-input px-3 py-2 text-sm text-ink-primary outline-none ring-focus-ring transition placeholder:text-ink-muted focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-focus-offset disabled:cursor-not-allowed disabled:opacity-60"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-xs text-secondary">
|
||||
Parsed IDs: <strong class="text-ink-primary">{{ parsedBatchCount }}</strong>
|
||||
</p>
|
||||
<AppButton type="submit" :disabled="saving || loading">
|
||||
{{ saving ? "Adding..." : "Add scholars" }}
|
||||
</AppButton>
|
||||
</div>
|
||||
</form>
|
||||
</AppCard>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ScholarNameSearch from "./ScholarNameSearch.vue";
|
||||
|
||||
const defaultProps = {
|
||||
trackedScholarIds: new Set<string>(),
|
||||
addingCandidateScholarId: null,
|
||||
};
|
||||
|
||||
describe("ScholarNameSearch", () => {
|
||||
it("renders the search heading", () => {
|
||||
const wrapper = mount(ScholarNameSearch, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { ScholarAvatar: true } },
|
||||
});
|
||||
expect(wrapper.text()).toContain("Search by Name");
|
||||
});
|
||||
|
||||
it("shows WIP badge indicating the feature is incomplete", () => {
|
||||
const wrapper = mount(ScholarNameSearch, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { ScholarAvatar: true } },
|
||||
});
|
||||
expect(wrapper.text()).toContain("WIP");
|
||||
});
|
||||
|
||||
it("renders the search input", () => {
|
||||
const wrapper = mount(ScholarNameSearch, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { ScholarAvatar: true } },
|
||||
});
|
||||
expect(wrapper.find("form").exists()).toBe(true);
|
||||
});
|
||||
|
||||
it("shows warning about Google Scholar login challenges", () => {
|
||||
const wrapper = mount(ScholarNameSearch, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { ScholarAvatar: true } },
|
||||
});
|
||||
expect(wrapper.text()).toContain("login challenge");
|
||||
});
|
||||
});
|
||||
167
frontend/src/features/scholars/components/ScholarNameSearch.vue
Normal file
167
frontend/src/features/scholars/components/ScholarNameSearch.vue
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
<script setup lang="ts">
|
||||
import { computed, ref } from "vue";
|
||||
import AsyncStateGate from "@/components/patterns/AsyncStateGate.vue";
|
||||
import RequestStateAlerts from "@/components/patterns/RequestStateAlerts.vue";
|
||||
import AppAlert from "@/components/ui/AppAlert.vue";
|
||||
import AppBadge from "@/components/ui/AppBadge.vue";
|
||||
import AppButton from "@/components/ui/AppButton.vue";
|
||||
import AppCard from "@/components/ui/AppCard.vue";
|
||||
import AppHelpHint from "@/components/ui/AppHelpHint.vue";
|
||||
import AppInput from "@/components/ui/AppInput.vue";
|
||||
import ScholarAvatar from "@/features/scholars/components/ScholarAvatar.vue";
|
||||
import type { ScholarSearchCandidate, ScholarSearchResult } from "@/features/scholars";
|
||||
|
||||
defineProps<{
|
||||
trackedScholarIds: Set<string>;
|
||||
addingCandidateScholarId: string | null;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: "add-candidate", candidate: ScholarSearchCandidate): void;
|
||||
}>();
|
||||
|
||||
const nameSearchWip = true;
|
||||
const searchingByName = ref(false);
|
||||
const searchQuery = ref("");
|
||||
const searchResult = ref<ScholarSearchResult | null>(null);
|
||||
const searchErrorMessage = ref<string | null>(null);
|
||||
const searchErrorRequestId = ref<string | null>(null);
|
||||
|
||||
const searchHasRun = computed(() => searchResult.value !== null);
|
||||
const searchIsDegraded = computed(() => {
|
||||
if (!searchResult.value) return false;
|
||||
return searchResult.value.state === "blocked_or_captcha" || searchResult.value.state === "network_error";
|
||||
});
|
||||
const searchStateTone = computed(() => {
|
||||
const result = searchResult.value;
|
||||
if (!result) return "neutral" as const;
|
||||
if (result.state === "ok") return "success" as const;
|
||||
if (result.state === "blocked_or_captcha" || result.state === "network_error") return "warning" as const;
|
||||
return "neutral" as const;
|
||||
});
|
||||
const emptySearchCandidates = computed(() => {
|
||||
const result = searchResult.value;
|
||||
if (!result) return false;
|
||||
return result.candidates.length === 0;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppCard class="relative space-y-4 select-none xl:flex xl:min-h-0 xl:flex-col xl:overflow-hidden">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div class="space-y-1">
|
||||
<div class="flex items-center gap-1">
|
||||
<h2 class="text-lg font-semibold text-ink-primary">Search by Name</h2>
|
||||
<AppHelpHint text="Google Scholar now challenges this endpoint with account login and anti-bot checks, so this workflow stays disabled." />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
This helper remains paused because Google Scholar currently requires account login for reliable name search access.
|
||||
</p>
|
||||
</div>
|
||||
<AppHelpHint text="Name search is kept as WIP. Use direct Scholar ID or profile URL adds for production tracking.">
|
||||
<template #trigger>
|
||||
<AppBadge tone="warning">WIP</AppBadge>
|
||||
</template>
|
||||
</AppHelpHint>
|
||||
</div>
|
||||
|
||||
<form class="pointer-events-none flex flex-wrap items-center gap-2 opacity-60">
|
||||
<div class="min-w-0 flex-1">
|
||||
<AppInput
|
||||
id="scholar-search-name"
|
||||
v-model="searchQuery"
|
||||
placeholder="e.g. Geoffrey Hinton"
|
||||
:disabled="nameSearchWip"
|
||||
/>
|
||||
</div>
|
||||
<AppButton type="button" :disabled="nameSearchWip">
|
||||
Search
|
||||
</AppButton>
|
||||
</form>
|
||||
<p class="text-xs text-secondary">
|
||||
Direct Scholar ID/URL adds above are the supported path until name search can run without login challenges.
|
||||
</p>
|
||||
|
||||
<div class="min-h-0 xl:flex-1 xl:overflow-y-auto xl:pr-1">
|
||||
<RequestStateAlerts
|
||||
:error-message="searchErrorMessage"
|
||||
:error-request-id="searchErrorRequestId"
|
||||
error-title="Search request failed"
|
||||
/>
|
||||
|
||||
<AsyncStateGate :loading="searchingByName" :loading-lines="4" :show-empty="false">
|
||||
<template v-if="searchResult">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<p class="text-sm text-secondary">
|
||||
{{ searchResult.candidates.length }} candidate{{ searchResult.candidates.length === 1 ? "" : "s" }}
|
||||
for <strong class="text-ink-primary">{{ searchResult.query }}</strong>
|
||||
</p>
|
||||
<AppBadge :tone="searchStateTone">{{ searchResult.state }}</AppBadge>
|
||||
</div>
|
||||
|
||||
<p v-if="searchResult.state !== 'ok' || searchResult.warnings.length > 0" class="text-xs text-secondary">
|
||||
<span>State reason: <code>{{ searchResult.state_reason }}</code></span>
|
||||
<span v-if="searchResult.action_hint">. {{ searchResult.action_hint }}</span>
|
||||
<span v-if="searchResult.warnings.length > 0">. Warnings: {{ searchResult.warnings.join(", ") }}</span>
|
||||
</p>
|
||||
|
||||
<AppAlert v-if="searchIsDegraded" tone="warning">
|
||||
<template #title>Name search is degraded</template>
|
||||
<p>This endpoint throttles aggressively to avoid blocks. Use Scholar URL/ID adds for dependable tracking.</p>
|
||||
</AppAlert>
|
||||
|
||||
<AsyncStateGate
|
||||
:loading="false"
|
||||
:show-empty="true"
|
||||
:empty="emptySearchCandidates"
|
||||
empty-title="No scholar matches returned"
|
||||
empty-body="Try another query later or add directly by Scholar URL/ID."
|
||||
>
|
||||
<ul class="grid gap-3">
|
||||
<li
|
||||
v-for="candidate in searchResult.candidates"
|
||||
:key="candidate.scholar_id"
|
||||
class="rounded-xl border border-stroke-default bg-surface-card-muted/70 p-3"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<ScholarAvatar
|
||||
size="sm"
|
||||
:label="candidate.display_name"
|
||||
:scholar-id="candidate.scholar_id"
|
||||
:image-url="candidate.profile_image_url"
|
||||
/>
|
||||
<div class="min-w-0 flex-1 space-y-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<strong class="text-sm text-ink-primary">{{ candidate.display_name }}</strong>
|
||||
<code class="text-xs text-secondary">{{ candidate.scholar_id }}</code>
|
||||
<AppBadge v-if="trackedScholarIds.has(candidate.scholar_id)" tone="success">Tracked</AppBadge>
|
||||
</div>
|
||||
<p class="truncate text-xs text-secondary">{{ candidate.affiliation || "No affiliation provided" }}</p>
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs text-secondary">
|
||||
<span v-if="candidate.email_domain">Email: {{ candidate.email_domain }}</span>
|
||||
<span v-if="candidate.cited_by_count !== null">Cited by: {{ candidate.cited_by_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid shrink-0 gap-2">
|
||||
<AppButton
|
||||
:disabled="trackedScholarIds.has(candidate.scholar_id) || addingCandidateScholarId === candidate.scholar_id"
|
||||
@click="emit('add-candidate', candidate)"
|
||||
>
|
||||
{{ addingCandidateScholarId === candidate.scholar_id ? "Adding..." : "Add" }}
|
||||
</AppButton>
|
||||
<a :href="candidate.profile_url" target="_blank" rel="noreferrer" class="link-inline text-xs text-center">
|
||||
Open profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</AsyncStateGate>
|
||||
</template>
|
||||
<p v-else-if="!searchErrorMessage && !searchHasRun" class="text-sm text-secondary">
|
||||
Name search remains disabled while login-gated responses are unresolved.
|
||||
</p>
|
||||
</AsyncStateGate>
|
||||
</div>
|
||||
</AppCard>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
// @vitest-environment happy-dom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { mount } from "@vue/test-utils";
|
||||
import ScholarSettingsModal from "./ScholarSettingsModal.vue";
|
||||
import type { ScholarProfile } from "@/features/scholars";
|
||||
|
||||
function buildScholar(overrides: Partial<ScholarProfile> = {}): ScholarProfile {
|
||||
return {
|
||||
id: 1,
|
||||
scholar_id: "abcDEF123456",
|
||||
display_name: "Dr. Test Scholar",
|
||||
profile_image_url: null,
|
||||
profile_image_source: "none" as const,
|
||||
is_enabled: true,
|
||||
baseline_completed: true,
|
||||
last_run_dt: null,
|
||||
last_run_status: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
scholar: buildScholar(),
|
||||
imageUrlDraft: "",
|
||||
imageBusy: false,
|
||||
imageSaving: false,
|
||||
imageUploading: false,
|
||||
saving: false,
|
||||
};
|
||||
|
||||
describe("ScholarSettingsModal", () => {
|
||||
it("renders scholar name and ID when scholar is provided", () => {
|
||||
const wrapper = mount(ScholarSettingsModal, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { AppModal: false, RouterLink: true, ScholarAvatar: true } },
|
||||
});
|
||||
expect(wrapper.text()).toContain("Dr. Test Scholar");
|
||||
expect(wrapper.text()).toContain("abcDEF123456");
|
||||
});
|
||||
|
||||
it("renders nothing when scholar is null", () => {
|
||||
const wrapper = mount(ScholarSettingsModal, {
|
||||
props: { ...defaultProps, scholar: null },
|
||||
global: { stubs: { AppModal: false, RouterLink: true, ScholarAvatar: true } },
|
||||
});
|
||||
expect(wrapper.text()).not.toContain("Dr. Test Scholar");
|
||||
});
|
||||
|
||||
it("emits close when close event fires", async () => {
|
||||
const wrapper = mount(ScholarSettingsModal, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { RouterLink: true, ScholarAvatar: true } },
|
||||
});
|
||||
const modal = wrapper.findComponent({ name: "AppModal" });
|
||||
if (modal.exists()) {
|
||||
modal.vm.$emit("close");
|
||||
expect(wrapper.emitted("close")).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("emits delete when delete button is clicked", async () => {
|
||||
const wrapper = mount(ScholarSettingsModal, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { RouterLink: true, ScholarAvatar: true } },
|
||||
});
|
||||
const deleteButton = wrapper.findAll("button").find((b) => b.text().includes("Delete"));
|
||||
if (deleteButton) {
|
||||
await deleteButton.trigger("click");
|
||||
expect(wrapper.emitted("delete")).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("emits toggle when enable/disable button is clicked", async () => {
|
||||
const wrapper = mount(ScholarSettingsModal, {
|
||||
props: defaultProps,
|
||||
global: { stubs: { RouterLink: true, ScholarAvatar: true } },
|
||||
});
|
||||
const toggleButton = wrapper.findAll("button").find((b) => b.text().includes("Disable"));
|
||||
if (toggleButton) {
|
||||
await toggleButton.trigger("click");
|
||||
expect(wrapper.emitted("toggle")).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
|
||||
it("shows Enable button for disabled scholars", () => {
|
||||
const wrapper = mount(ScholarSettingsModal, {
|
||||
props: { ...defaultProps, scholar: buildScholar({ is_enabled: false }) },
|
||||
global: { stubs: { RouterLink: true, ScholarAvatar: true } },
|
||||
});
|
||||
expect(wrapper.text()).toContain("Enable");
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
<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) }}</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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue