Added source segment
This commit is contained in:
parent
f13861c580
commit
1b1784f4ee
8 changed files with 311 additions and 6 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -35,4 +35,7 @@ data/
|
|||
Thumbs.db
|
||||
|
||||
.claude/
|
||||
.claude
|
||||
.claude
|
||||
|
||||
# Content drafts
|
||||
drafts/
|
||||
|
|
@ -13,6 +13,7 @@ class SegmentType(StrEnum):
|
|||
IFRAME = "iframe"
|
||||
GALLERY = "gallery"
|
||||
TEAM = "team"
|
||||
SOURCES = "sources"
|
||||
|
||||
|
||||
class Segment(BaseModel):
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import type { SegmentType } from "@/types/segment";
|
||||
import type { SegmentType, Source } from "@/types/segment";
|
||||
import AssetUploader from "@/components/admin/AssetUploader.vue";
|
||||
import RichTextEditor from "@/components/admin/RichTextEditor.vue";
|
||||
import { formatApaCitation } from "@/utils/formatApa";
|
||||
|
||||
const emit = defineEmits<{
|
||||
create: [data: { type: SegmentType; title: string; content: string; metadata?: Record<string, unknown> }];
|
||||
|
|
@ -16,14 +17,30 @@ const segmentTypes: { value: SegmentType; label: string; description: string; ic
|
|||
{ value: "video", label: "Video", description: "Embed a video file", icon: '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><polygon points="5 3 19 12 5 21 5 3" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" /></svg>' },
|
||||
{ value: "audio", label: "Audio", description: "Embed an audio file", icon: '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072M12 6v12m0 0a3 3 0 003-3V9a3 3 0 00-3 3m0 0a3 3 0 01-3 3V9a3 3 0 013-3" /></svg>' },
|
||||
{ value: "iframe", label: "Embed", description: "Embed an external website or tool", icon: '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" /></svg>' },
|
||||
{ value: "sources", label: "Sources", description: "Add an APA reference list", icon: '<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.746 0 3.332.477 4.5 1.253v13C19.832 18.477 18.246 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" /></svg>' },
|
||||
];
|
||||
|
||||
const selectedType = ref<SegmentType>("markdown");
|
||||
const title = ref("");
|
||||
const content = ref("");
|
||||
const galleryImages = ref<string[]>([]);
|
||||
const sources = ref<Source[]>([]);
|
||||
const sourceForm = ref({ authors: "", year: "", title: "", source: "", url: "" });
|
||||
|
||||
function addSource() {
|
||||
const f = sourceForm.value;
|
||||
if (!f.authors.trim() && !f.title.trim()) return;
|
||||
sources.value = [...sources.value, { id: crypto.randomUUID(), ...f }];
|
||||
sourceForm.value = { authors: "", year: "", title: "", source: "", url: "" };
|
||||
}
|
||||
|
||||
function removeSource(id: string) {
|
||||
sources.value = sources.value.filter((s) => s.id !== id);
|
||||
}
|
||||
|
||||
const canSubmit = computed(() => {
|
||||
if (selectedType.value === "gallery") return galleryImages.value.length > 0;
|
||||
if (selectedType.value === "sources") return sources.value.length > 0;
|
||||
if (selectedType.value === "markdown") return true;
|
||||
return content.value.trim().length > 0;
|
||||
});
|
||||
|
|
@ -49,6 +66,9 @@ function handleSubmit() {
|
|||
if (selectedType.value === "gallery") {
|
||||
data.metadata = { images: galleryImages.value };
|
||||
}
|
||||
if (selectedType.value === "sources") {
|
||||
data.metadata = { sources: sources.value };
|
||||
}
|
||||
emit("create", data);
|
||||
}
|
||||
|
||||
|
|
@ -70,7 +90,7 @@ const assetAcceptMap: Record<string, string> = {
|
|||
|
||||
<!-- Type selector: icon cards -->
|
||||
<div class="mb-5">
|
||||
<div class="grid grid-cols-3 gap-2 sm:grid-cols-6">
|
||||
<div class="grid grid-cols-3 gap-2 sm:grid-cols-7">
|
||||
<button
|
||||
v-for="st in segmentTypes"
|
||||
:key="st.value"
|
||||
|
|
@ -82,7 +102,7 @@ const assetAcceptMap: Record<string, string> = {
|
|||
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
||||
"
|
||||
:title="st.description"
|
||||
@click="selectedType = st.value; content = ''; galleryImages = []"
|
||||
@click="selectedType = st.value; content = ''; galleryImages = []; sources = []"
|
||||
>
|
||||
<span class="text-gray-500 dark:text-gray-400" v-html="st.icon" />
|
||||
<span class="text-xs font-medium text-gray-900 dark:text-gray-100">{{ st.label }}</span>
|
||||
|
|
@ -154,6 +174,74 @@ const assetAcceptMap: Record<string, string> = {
|
|||
</div>
|
||||
<AssetUploader accept="image/*" :multiple="true" label="Click to upload photos, or drag and drop" @uploaded="handleGalleryImageUploaded" />
|
||||
</div>
|
||||
|
||||
<!-- Sources -->
|
||||
<div v-else-if="selectedType === 'sources'">
|
||||
<!-- Added sources list -->
|
||||
<div v-if="sources.length > 0" class="mb-3 space-y-2">
|
||||
<div
|
||||
v-for="source in sources"
|
||||
:key="source.id"
|
||||
class="group flex items-start gap-2 rounded bg-gray-50 px-3 py-2 text-sm dark:bg-gray-700"
|
||||
>
|
||||
<p
|
||||
class="min-w-0 flex-1 pl-6 -indent-6 text-gray-700 dark:text-gray-200"
|
||||
v-html="formatApaCitation(source)"
|
||||
/>
|
||||
<button
|
||||
class="shrink-0 text-red-400 hover:text-red-600"
|
||||
@click="removeSource(source.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Source input form -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="sourceForm.authors"
|
||||
type="text"
|
||||
placeholder="Authors (e.g. Liu, Z., Zhang, W., & Yang, P.)"
|
||||
class="min-w-0 flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
<input
|
||||
v-model="sourceForm.year"
|
||||
type="text"
|
||||
placeholder="Year"
|
||||
class="w-20 rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-model="sourceForm.title"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
class="w-full rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="sourceForm.source"
|
||||
type="text"
|
||||
placeholder="Journal, publisher, or site name"
|
||||
class="min-w-0 flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
<input
|
||||
v-model="sourceForm.url"
|
||||
type="text"
|
||||
placeholder="URL or DOI link (optional)"
|
||||
class="min-w-0 flex-1 rounded border border-gray-300 px-3 py-1.5 text-sm focus:border-primary-400 focus:outline-none dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
:disabled="!sourceForm.authors.trim() && !sourceForm.title.trim()"
|
||||
class="rounded bg-gray-200 px-3 py-1.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-300 disabled:opacity-50 dark:bg-gray-600 dark:text-gray-200 dark:hover:bg-gray-500"
|
||||
@click="addSource"
|
||||
>
|
||||
Add source
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from "vue";
|
||||
import type { Segment } from "@/types/segment";
|
||||
import type { Segment, Source } from "@/types/segment";
|
||||
import AssetUploader from "@/components/admin/AssetUploader.vue";
|
||||
import RichTextEditor from "@/components/admin/RichTextEditor.vue";
|
||||
import { formatApaCitation } from "@/utils/formatApa";
|
||||
|
||||
const props = defineProps<{
|
||||
segment: Segment;
|
||||
|
|
@ -26,6 +27,48 @@ const teamMembers = ref<{ name: string; student_number: string }[]>(
|
|||
const newMemberName = ref("");
|
||||
const newMemberNumber = ref("");
|
||||
|
||||
const sources = ref<Source[]>(
|
||||
Array.isArray(props.segment.metadata.sources)
|
||||
? [...(props.segment.metadata.sources as Source[])]
|
||||
: [],
|
||||
);
|
||||
const editingSourceId = ref<string | null>(null);
|
||||
const sourceForm = ref({ authors: "", year: "", title: "", source: "", url: "" });
|
||||
|
||||
function resetSourceForm() {
|
||||
sourceForm.value = { authors: "", year: "", title: "", source: "", url: "" };
|
||||
editingSourceId.value = null;
|
||||
}
|
||||
|
||||
function addOrUpdateSource() {
|
||||
const f = sourceForm.value;
|
||||
if (!f.authors.trim() && !f.title.trim()) return;
|
||||
if (editingSourceId.value) {
|
||||
sources.value = sources.value.map((s) =>
|
||||
s.id === editingSourceId.value ? { ...s, ...f } : s,
|
||||
);
|
||||
} else {
|
||||
sources.value = [...sources.value, { id: crypto.randomUUID(), ...f }];
|
||||
}
|
||||
resetSourceForm();
|
||||
}
|
||||
|
||||
function editSource(source: Source) {
|
||||
editingSourceId.value = source.id;
|
||||
sourceForm.value = {
|
||||
authors: source.authors,
|
||||
year: source.year,
|
||||
title: source.title,
|
||||
source: source.source,
|
||||
url: source.url,
|
||||
};
|
||||
}
|
||||
|
||||
function removeSource(id: string) {
|
||||
sources.value = sources.value.filter((s) => s.id !== id);
|
||||
if (editingSourceId.value === id) resetSourceForm();
|
||||
}
|
||||
|
||||
function normaliseUrl(url: string): string {
|
||||
const trimmed = url.trim();
|
||||
if (trimmed && !/^https?:\/\//i.test(trimmed)) return `https://${trimmed}`;
|
||||
|
|
@ -41,6 +84,9 @@ function handleSave() {
|
|||
if (props.segment.type === "team") {
|
||||
metadata.value = { ...metadata.value, members: teamMembers.value };
|
||||
}
|
||||
if (props.segment.type === "sources") {
|
||||
metadata.value = { ...metadata.value, sources: sources.value };
|
||||
}
|
||||
if (JSON.stringify(metadata.value) !== JSON.stringify(props.segment.metadata)) {
|
||||
updates.metadata = metadata.value;
|
||||
}
|
||||
|
|
@ -184,6 +230,95 @@ const assetAcceptMap: Record<string, string> = {
|
|||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sources: reference list -->
|
||||
<div v-else-if="segment.type === 'sources'">
|
||||
<!-- Existing sources -->
|
||||
<div v-if="sources.length > 0" class="mb-4 space-y-2">
|
||||
<div
|
||||
v-for="source in sources"
|
||||
:key="source.id"
|
||||
class="group flex items-start gap-2 rounded bg-white px-3 py-2 text-sm dark:bg-slate-700"
|
||||
>
|
||||
<p
|
||||
class="min-w-0 flex-1 pl-6 -indent-6 text-slate-700 dark:text-slate-200"
|
||||
v-html="formatApaCitation(source)"
|
||||
/>
|
||||
<div class="flex shrink-0 gap-1 opacity-0 group-hover:opacity-100">
|
||||
<button
|
||||
class="rounded p-1 text-slate-400 hover:text-slate-700 dark:hover:text-slate-200"
|
||||
title="Edit"
|
||||
@click="editSource(source)"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
class="rounded p-1 text-red-400 hover:text-red-600"
|
||||
title="Remove"
|
||||
@click="removeSource(source.id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/edit source form -->
|
||||
<div class="space-y-2">
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="sourceForm.authors"
|
||||
type="text"
|
||||
placeholder="Authors (e.g. Liu, Z., Zhang, W., & Yang, P.)"
|
||||
class="min-w-0 flex-1 rounded border border-slate-300 px-3 py-1.5 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
|
||||
/>
|
||||
<input
|
||||
v-model="sourceForm.year"
|
||||
type="text"
|
||||
placeholder="Year"
|
||||
class="w-20 rounded border border-slate-300 px-3 py-1.5 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-model="sourceForm.title"
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
class="w-full rounded border border-slate-300 px-3 py-1.5 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="sourceForm.source"
|
||||
type="text"
|
||||
placeholder="Journal, publisher, or site name"
|
||||
class="min-w-0 flex-1 rounded border border-slate-300 px-3 py-1.5 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
|
||||
/>
|
||||
<input
|
||||
v-model="sourceForm.url"
|
||||
type="text"
|
||||
placeholder="URL or DOI link (optional)"
|
||||
class="min-w-0 flex-1 rounded border border-slate-300 px-3 py-1.5 text-sm focus:border-primary-400 focus:outline-none dark:border-slate-600 dark:bg-slate-700 dark:text-slate-100 dark:placeholder-slate-500"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
:disabled="!sourceForm.authors.trim() && !sourceForm.title.trim()"
|
||||
class="rounded bg-slate-200 px-3 py-1.5 text-sm font-medium text-slate-700 transition-colors hover:bg-slate-300 disabled:opacity-50 dark:bg-slate-600 dark:text-slate-200 dark:hover:bg-slate-500"
|
||||
@click="addOrUpdateSource"
|
||||
>
|
||||
{{ editingSourceId ? "Update" : "Add" }}
|
||||
</button>
|
||||
<button
|
||||
v-if="editingSourceId"
|
||||
class="rounded px-3 py-1.5 text-sm text-slate-500 transition-colors hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
|
||||
@click="resetSourceForm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import VideoSegment from "@/components/segments/VideoSegment.vue";
|
|||
import AudioSegment from "@/components/segments/AudioSegment.vue";
|
||||
import IframeSegment from "@/components/segments/IframeSegment.vue";
|
||||
import GallerySegment from "@/components/segments/GallerySegment.vue";
|
||||
import SourcesSegment from "@/components/segments/SourcesSegment.vue";
|
||||
|
||||
defineProps<{
|
||||
segment: Segment;
|
||||
|
|
@ -19,4 +20,5 @@ defineProps<{
|
|||
<AudioSegment v-else-if="segment.type === 'audio'" :segment="segment" />
|
||||
<IframeSegment v-else-if="segment.type === 'iframe'" :segment="segment" />
|
||||
<GallerySegment v-else-if="segment.type === 'gallery'" :segment="segment" />
|
||||
<SourcesSegment v-else-if="segment.type === 'sources'" :segment="segment" />
|
||||
</template>
|
||||
|
|
|
|||
30
frontend/src/components/segments/SourcesSegment.vue
Normal file
30
frontend/src/components/segments/SourcesSegment.vue
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from "vue";
|
||||
import type { Segment, Source } from "@/types/segment";
|
||||
import { formatApaCitation } from "@/utils/formatApa";
|
||||
|
||||
const props = defineProps<{
|
||||
segment: Segment;
|
||||
}>();
|
||||
|
||||
const sortedSources = computed(() => {
|
||||
const sources = Array.isArray(props.segment.metadata.sources)
|
||||
? ([...props.segment.metadata.sources] as Source[])
|
||||
: [];
|
||||
return sources.sort((a, b) =>
|
||||
a.authors.localeCompare(b.authors, undefined, { sensitivity: "base" }),
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="sortedSources.length > 0" class="space-y-2">
|
||||
<p
|
||||
v-for="source in sortedSources"
|
||||
:key="source.id"
|
||||
class="pl-8 -indent-8 text-sm text-slate-800 dark:text-slate-200"
|
||||
v-html="formatApaCitation(source)"
|
||||
/>
|
||||
</div>
|
||||
<p v-else class="text-sm italic text-slate-400 dark:text-slate-500">No sources added yet.</p>
|
||||
</template>
|
||||
|
|
@ -1,4 +1,13 @@
|
|||
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery" | "team";
|
||||
export type SegmentType = "markdown" | "pdf" | "video" | "audio" | "iframe" | "gallery" | "team" | "sources";
|
||||
|
||||
export interface Source {
|
||||
id: string;
|
||||
authors: string;
|
||||
year: string;
|
||||
title: string;
|
||||
source: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface Segment {
|
||||
id: string;
|
||||
|
|
|
|||
37
frontend/src/utils/formatApa.ts
Normal file
37
frontend/src/utils/formatApa.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import type { Source } from "@/types/segment";
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function formatUrl(url: string): string {
|
||||
const escaped = escapeHtml(url);
|
||||
return `<a href="${escaped}" target="_blank" rel="noopener noreferrer" class="text-primary-600 underline break-all dark:text-primary-400">${escaped}</a>`;
|
||||
}
|
||||
|
||||
export function formatApaCitation(source: Source): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (source.authors) {
|
||||
const authors = escapeHtml(source.authors);
|
||||
parts.push(authors.endsWith(".") ? authors : `${authors}.`);
|
||||
}
|
||||
|
||||
parts.push(`(${escapeHtml(source.year || "n.d.")}).`);
|
||||
|
||||
if (source.title) {
|
||||
const title = escapeHtml(source.title);
|
||||
parts.push(title.endsWith(".") ? `<em>${title}</em>` : `<em>${title}</em>.`);
|
||||
}
|
||||
|
||||
if (source.source) {
|
||||
const src = escapeHtml(source.source);
|
||||
parts.push(src.endsWith(".") ? `<em>${src}</em>` : `<em>${src}</em>.`);
|
||||
}
|
||||
|
||||
if (source.url) {
|
||||
parts.push(formatUrl(source.url));
|
||||
}
|
||||
|
||||
return parts.join(" ").replace(/\.\./g, ".");
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue