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
|
Thumbs.db
|
||||||
|
|
||||||
.claude/
|
.claude/
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
|
# Content drafts
|
||||||
|
drafts/
|
||||||
|
|
@ -13,6 +13,7 @@ class SegmentType(StrEnum):
|
||||||
IFRAME = "iframe"
|
IFRAME = "iframe"
|
||||||
GALLERY = "gallery"
|
GALLERY = "gallery"
|
||||||
TEAM = "team"
|
TEAM = "team"
|
||||||
|
SOURCES = "sources"
|
||||||
|
|
||||||
|
|
||||||
class Segment(BaseModel):
|
class Segment(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed } from "vue";
|
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 AssetUploader from "@/components/admin/AssetUploader.vue";
|
||||||
import RichTextEditor from "@/components/admin/RichTextEditor.vue";
|
import RichTextEditor from "@/components/admin/RichTextEditor.vue";
|
||||||
|
import { formatApaCitation } from "@/utils/formatApa";
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
create: [data: { type: SegmentType; title: string; content: string; metadata?: Record<string, unknown> }];
|
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: "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: "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: "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 selectedType = ref<SegmentType>("markdown");
|
||||||
const title = ref("");
|
const title = ref("");
|
||||||
const content = ref("");
|
const content = ref("");
|
||||||
const galleryImages = ref<string[]>([]);
|
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(() => {
|
const canSubmit = computed(() => {
|
||||||
if (selectedType.value === "gallery") return galleryImages.value.length > 0;
|
if (selectedType.value === "gallery") return galleryImages.value.length > 0;
|
||||||
|
if (selectedType.value === "sources") return sources.value.length > 0;
|
||||||
if (selectedType.value === "markdown") return true;
|
if (selectedType.value === "markdown") return true;
|
||||||
return content.value.trim().length > 0;
|
return content.value.trim().length > 0;
|
||||||
});
|
});
|
||||||
|
|
@ -49,6 +66,9 @@ function handleSubmit() {
|
||||||
if (selectedType.value === "gallery") {
|
if (selectedType.value === "gallery") {
|
||||||
data.metadata = { images: galleryImages.value };
|
data.metadata = { images: galleryImages.value };
|
||||||
}
|
}
|
||||||
|
if (selectedType.value === "sources") {
|
||||||
|
data.metadata = { sources: sources.value };
|
||||||
|
}
|
||||||
emit("create", data);
|
emit("create", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -70,7 +90,7 @@ const assetAcceptMap: Record<string, string> = {
|
||||||
|
|
||||||
<!-- Type selector: icon cards -->
|
<!-- Type selector: icon cards -->
|
||||||
<div class="mb-5">
|
<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
|
<button
|
||||||
v-for="st in segmentTypes"
|
v-for="st in segmentTypes"
|
||||||
:key="st.value"
|
: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'
|
: 'border-gray-200 hover:border-gray-300 dark:border-gray-700 dark:hover:border-gray-600'
|
||||||
"
|
"
|
||||||
:title="st.description"
|
: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-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>
|
<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>
|
</div>
|
||||||
<AssetUploader accept="image/*" :multiple="true" label="Click to upload photos, or drag and drop" @uploaded="handleGalleryImageUploaded" />
|
<AssetUploader accept="image/*" :multiple="true" label="Click to upload photos, or drag and drop" @uploaded="handleGalleryImageUploaded" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from "vue";
|
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 AssetUploader from "@/components/admin/AssetUploader.vue";
|
||||||
import RichTextEditor from "@/components/admin/RichTextEditor.vue";
|
import RichTextEditor from "@/components/admin/RichTextEditor.vue";
|
||||||
|
import { formatApaCitation } from "@/utils/formatApa";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
segment: Segment;
|
segment: Segment;
|
||||||
|
|
@ -26,6 +27,48 @@ const teamMembers = ref<{ name: string; student_number: string }[]>(
|
||||||
const newMemberName = ref("");
|
const newMemberName = ref("");
|
||||||
const newMemberNumber = 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 {
|
function normaliseUrl(url: string): string {
|
||||||
const trimmed = url.trim();
|
const trimmed = url.trim();
|
||||||
if (trimmed && !/^https?:\/\//i.test(trimmed)) return `https://${trimmed}`;
|
if (trimmed && !/^https?:\/\//i.test(trimmed)) return `https://${trimmed}`;
|
||||||
|
|
@ -41,6 +84,9 @@ function handleSave() {
|
||||||
if (props.segment.type === "team") {
|
if (props.segment.type === "team") {
|
||||||
metadata.value = { ...metadata.value, members: teamMembers.value };
|
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)) {
|
if (JSON.stringify(metadata.value) !== JSON.stringify(props.segment.metadata)) {
|
||||||
updates.metadata = metadata.value;
|
updates.metadata = metadata.value;
|
||||||
}
|
}
|
||||||
|
|
@ -184,6 +230,95 @@ const assetAcceptMap: Record<string, string> = {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Actions -->
|
<!-- Actions -->
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import VideoSegment from "@/components/segments/VideoSegment.vue";
|
||||||
import AudioSegment from "@/components/segments/AudioSegment.vue";
|
import AudioSegment from "@/components/segments/AudioSegment.vue";
|
||||||
import IframeSegment from "@/components/segments/IframeSegment.vue";
|
import IframeSegment from "@/components/segments/IframeSegment.vue";
|
||||||
import GallerySegment from "@/components/segments/GallerySegment.vue";
|
import GallerySegment from "@/components/segments/GallerySegment.vue";
|
||||||
|
import SourcesSegment from "@/components/segments/SourcesSegment.vue";
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
segment: Segment;
|
segment: Segment;
|
||||||
|
|
@ -19,4 +20,5 @@ defineProps<{
|
||||||
<AudioSegment v-else-if="segment.type === 'audio'" :segment="segment" />
|
<AudioSegment v-else-if="segment.type === 'audio'" :segment="segment" />
|
||||||
<IframeSegment v-else-if="segment.type === 'iframe'" :segment="segment" />
|
<IframeSegment v-else-if="segment.type === 'iframe'" :segment="segment" />
|
||||||
<GallerySegment v-else-if="segment.type === 'gallery'" :segment="segment" />
|
<GallerySegment v-else-if="segment.type === 'gallery'" :segment="segment" />
|
||||||
|
<SourcesSegment v-else-if="segment.type === 'sources'" :segment="segment" />
|
||||||
</template>
|
</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 {
|
export interface Segment {
|
||||||
id: string;
|
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