diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..9cc212a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +# node / frontend +frontend/node_modules + +# build output +frontend/dist + +# misc +.git +.gitignore \ No newline at end of file diff --git a/backend/app/crud/isl_verse_markers_crud.py b/backend/app/crud/isl_verse_markers_crud.py index c0521a9..b9ee2dc 100644 --- a/backend/app/crud/isl_verse_markers_crud.py +++ b/backend/app/crud/isl_verse_markers_crud.py @@ -9,10 +9,11 @@ from dependencies import logger -VERSIFICATION_PATH = (Path("data/versification.json").resolve()) - -with open(VERSIFICATION_PATH, "r", encoding="utf-8") as file: - VERSIFICATION = json.load(file) +def _get_versification(db_session: Session) -> dict: + record = db_session.query(db_models.VersificationSchema).filter_by(is_default=True).first() + if not record: + raise UnprocessableException(detail="No versification schema found in database") + return record.data def _ensure_verse_zero(markers): """ @@ -148,25 +149,20 @@ def _timestamp_to_frames(timestamp: str) -> int: hours, minutes, seconds, frames = map(int, timestamp.split(":")) return (((hours * 60) + minutes) * 60 + seconds) * 100 + frames -def _validate_marker_verses(db_session: Session,isl_video, markers): - """ - Validate verses using versification json. - """ - +def _validate_marker_verses(db_session: Session, isl_video, markers): + """Validate marker verses""" chapter = isl_video.chapter # Allow intro chapter if chapter == 0: return - book = (db_session.query(db_models.BookLookup) - .filter_by(book_id=isl_video.book_id) - .first()) + # Fetch versification from DB instead of module-level constant + versification = _get_versification(db_session) + book = db_session.query(db_models.BookLookup).filter_by(book_id=isl_video.book_id).first() if not book: - raise UnprocessableException( - detail="Book lookup not found" - ) + raise UnprocessableException(detail="Book lookup not found") book_code = book.book_code.upper() @@ -175,34 +171,22 @@ def _validate_marker_verses(db_session: Session,isl_video, markers): detail="Unable to determine book code" ) - max_verses_data = ( - VERSIFICATION["maxVerses"] - .get(book_code.upper()) - ) + max_verses_data = versification["maxVerses"].get(book_code) if not max_verses_data: - raise UnprocessableException( - detail=f"No versification data for {book_code}" - ) + raise UnprocessableException(detail=f"No versification data for {book_code}") if chapter > len(max_verses_data): - raise UnprocessableException( - detail=f"Invalid chapter {chapter}" - ) + raise UnprocessableException(detail=f"Invalid chapter {chapter}") max_verse = int(max_verses_data[chapter - 1]) for marker in markers: verse = marker["verse"] - - # allow intro marker if verse == 0: continue - if isinstance(verse, str) and "_" in verse: - start, end = map(int, verse.split("_")) - if start > max_verse or end > max_verse: raise UnprocessableException( detail=( diff --git a/backend/app/crud/structural_crud.py b/backend/app/crud/structural_crud.py index 20e75d7..e27537b 100644 --- a/backend/app/crud/structural_crud.py +++ b/backend/app/crud/structural_crud.py @@ -633,6 +633,7 @@ def delete_resources_bulk(db: Session, resource_ids: List[int]): db_models.AudioBible, db_models.Obs, db_models.Infographic, + db_models.IslVideo, ] for rid in resource_ids: diff --git a/backend/app/db_models.py b/backend/app/db_models.py index 836db88..2832850 100644 --- a/backend/app/db_models.py +++ b/backend/app/db_models.py @@ -3,10 +3,12 @@ from datetime import datetime, timezone from sqlalchemy import ( Column, - Integer, String, Text, ForeignKey, DateTime, UniqueConstraint,Boolean, Index + Integer, String, Text, ForeignKey, DateTime, UniqueConstraint,Boolean, Index,text ) from sqlalchemy.orm import declarative_base from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.sql import func + Base = declarative_base() @@ -319,3 +321,27 @@ class IslVerseMarkers(Base): isl_video_id = Column(Integer, ForeignKey("isl_video.id", ondelete="CASCADE"), nullable=False, unique=True) verse_markers_json = Column(JSONB, nullable=False) +class VersificationSchema(Base): + """Corresponds to table versification_schemas in vachan DB(postgres)""" + __tablename__ = "versification_schemas" + + id = Column(Integer, primary_key=True) + name = Column(String(100), nullable=False, unique=True) + description = Column(Text, nullable=True) + data = Column(JSONB, nullable=False) + is_default = Column(Boolean, default=False) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + ) + + __table_args__ = ( + Index( + "idx_versification_default", + "is_default", + unique=True, + postgresql_where=(text("is_default = TRUE")), + ), + ) \ No newline at end of file diff --git a/backend/app/load_data.py b/backend/app/load_data.py index b159288..ce5b136 100644 --- a/backend/app/load_data.py +++ b/backend/app/load_data.py @@ -1,3 +1,4 @@ +import json from csv import DictReader from pathlib import Path from sqlalchemy.orm import Session @@ -125,6 +126,42 @@ def populate_book_names_table(db_: Session, file: str) -> None: if missing_languages: print("Missing language codes:", missing_languages) + +def populate_versification_table( + db_: Session, + file: str +) -> None: + """Populates versification_schema table from JSON file.""" + + existing = db_.query( + db_models.VersificationSchema + ).filter_by( + name="English-ERV" + ).first() + + if existing: + return + + with open(file, "r", encoding="utf-8") as file_pointer: + data = json.load(file_pointer) + + try: + record = db_models.VersificationSchema( + name="English-ERV", + description=None, + data=data, + is_default=True, + ) + + db_.add(record) + db_.commit() + + except Exception: + db_.rollback() + print( + "Versification schema already seeded by another process, skipping." + ) + def load_initial_data(): """Populate the database""" with SessionLocal() as session: @@ -150,8 +187,6 @@ def load_initial_data(): if session.query(db_models.BookName).count() == 0: csv_file_booknames = Path("data/booknames.csv").resolve() populate_book_names_table(session, str(csv_file_booknames)) - - - - - \ No newline at end of file + if session.query(db_models.VersificationSchema).count() == 0: + json_file_versification = Path("data/versification.json").resolve() + populate_versification_table(session,str(json_file_versification)) diff --git a/backend/app/main.py b/backend/app/main.py index 94c8dac..cfcfe5b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -52,7 +52,8 @@ os.getenv("SUPERTOKENS_WEBSITE_DOMAIN", "http://localhost:5174"), # keep localhost for local dev "http://localhost:5174", - "http://localhost:5173" + "http://localhost:5173", + "http://localhost:8081", ] app.add_middleware( diff --git a/docker/docker_frontend/Dockerfile b/docker/docker_frontend/Dockerfile index 8823eff..402ef54 100644 --- a/docker/docker_frontend/Dockerfile +++ b/docker/docker_frontend/Dockerfile @@ -1,15 +1,42 @@ -FROM node:20-alpine +# ===== Build stage ===== +FROM node:20-alpine AS builder -WORKDIR /app +# Build-time arguments +ARG VITE_FASTAPI_BASE_URL +ARG VITE_SUPERTOKENS_API_DOMAIN +ARG VITE_SUPERTOKENS_WEBSITE_DOMAIN +ARG VITE_RENDER_BASE_URL + +# Make them available to Vite +ENV VITE_FASTAPI_BASE_URL=$VITE_FASTAPI_BASE_URL +ENV VITE_SUPERTOKENS_API_DOMAIN=$VITE_SUPERTOKENS_API_DOMAIN +ENV VITE_SUPERTOKENS_WEBSITE_DOMAIN=$VITE_SUPERTOKENS_WEBSITE_DOMAIN +ENV VITE_RENDER_BASE_URL=$VITE_RENDER_BASE_URL -COPY package*.json ./ +WORKDIR /app +# Copy frontend package files +COPY frontend/package*.json ./ RUN npm install -COPY . . +# Copy full frontend source +COPY frontend/ ./ +# Build frontend RUN npm run build -EXPOSE 5173 +# ===== Runtime stage ===== +FROM nginx:alpine + +# Remove default nginx config +RUN rm /etc/nginx/conf.d/default.conf + +# Copy our nginx config +COPY docker/docker_frontend/nginx/frontend.conf /etc/nginx/conf.d/default.conf + +# Copy built frontend +COPY --from=builder /app/dist /usr/share/nginx/html -CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"] \ No newline at end of file +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] + \ No newline at end of file diff --git a/docker/docker_frontend/docker-compose.yml b/docker/docker_frontend/docker-compose.yml index 9e72b6d..47db250 100644 --- a/docker/docker_frontend/docker-compose.yml +++ b/docker/docker_frontend/docker-compose.yml @@ -1,23 +1,23 @@ version: '3.9' - + services: vachan-admin-frontend: build: - context: ../../frontend - dockerfile: ../docker/docker_frontend/Dockerfile + context: ../../ # repo root + dockerfile: docker/docker_frontend/Dockerfile + args: + # === Build-time Vite env vars === + VITE_FASTAPI_BASE_URL: ${VITE_FASTAPI_BASE_URL} + VITE_SUPERTOKENS_API_DOMAIN: ${VITE_SUPERTOKENS_API_DOMAIN} + VITE_SUPERTOKENS_WEBSITE_DOMAIN: ${VITE_SUPERTOKENS_WEBSITE_DOMAIN} + VITE_RENDER_BASE_URL: ${VITE_RENDER_BASE_URL} container_name: vachan-admin-frontend ports: - - "5173:5173" # host:container - environment: - # === Backend and Frontend URLs === - - VITE_FASTAPI_BASE_URL=${VITE_FASTAPI_BASE_URL} - - VITE_SUPERTOKENS_API_DOMAIN=${VITE_SUPERTOKENS_API_DOMAIN} - - VITE_SUPERTOKENS_WEBSITE_DOMAIN=${VITE_SUPERTOKENS_WEBSITE_DOMAIN} - - restart: always + - "127.0.0.1:8081:80" + restart: unless-stopped networks: - va-network - + networks: va-network: - external: true + external: true \ No newline at end of file diff --git a/docker/docker_frontend/nginx/frontend.conf b/docker/docker_frontend/nginx/frontend.conf new file mode 100644 index 0000000..07d0e36 --- /dev/null +++ b/docker/docker_frontend/nginx/frontend.conf @@ -0,0 +1,11 @@ +server { + listen 80; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri /index.html; + } +} \ No newline at end of file diff --git a/frontend/src/components/BibleSelector.tsx b/frontend/src/components/BibleSelector.tsx index a65531e..356ac8d 100644 --- a/frontend/src/components/BibleSelector.tsx +++ b/frontend/src/components/BibleSelector.tsx @@ -350,9 +350,9 @@ export const BibleBookSelector = ({ const { data: bibleBooksResp } = useGetBibleBooks(resourceId ?? undefined); const resourceBibles = bibleBooksResp?.books ?? []; const hasUploadedBooks = (bibleBooksResp?.books?.length ?? 0) > 0; - const { data: bibleContentData } = useDownloadBibleContent( + const { data: bibleContentData, isFetching: isFetchingContent } = useDownloadBibleContent( resourceId as number, - hasUploadedBooks, + isOpen && hasUploadedBooks, ); const { isFetching: isDownloading, refetch: fetchBibleContent } = useDownloadBibleContent(resourceId as number, false); @@ -865,10 +865,10 @@ export const BibleBookSelector = ({ )} @@ -886,7 +886,7 @@ export const BibleBookSelector = ({ )} + {(isAdmin || isEditor) && extraViewActions} {(isAdmin || isEditor) && ( + + )} + + {/* ── Parsing: spinner before entries are set ── */} + {isUploading && entries.length === 0 && ( +
+ + Reading file… +
+ )} + + {/* ── Progress / results ── */} + {entries.length > 0 && ( +
+ {/* Legend */} + {mode === "bulk" && ( +
+ + + error + + + ! + chapters skipped — no matching video entry + + + + N added + + — new chapters + + + + N updated + + — existing chapters + +
+ )} + + {/* Summary line */} +
+ + {successCount}/{totalChapters} chapter(s) uploaded + {errorCount > 0 && ( + · {errorCount} failed + )} + + + {/* Single mode summary badges */} + {mode === "single" && uploadState === "done" && ( +
+ {singleAdded > 0 && ( + + {singleAdded} added + + )} + {singleUpdated > 0 && ( + + {singleUpdated} updated + + )} +
+ )} + + {unmatched.length > 0 && mode === "bulk" && ( + + {unmatched.length} file(s) skipped — entry not found + + )} +
+ + {/* Progress bar */} +
+
+
+ + {/* Table */} +
+ {mode === "bulk" ? ( + + + + + + + + + + + + {/* Matched books */} + {bookSummaries.map((summary) => ( + + + + + + + + ))} + + {/* Fully-skipped books (zero matches) */} + {fullySkippedBooks.map((skipped) => ( + + + + + + + + ))} + +
BookChaptersVerse MarkersAdded / UpdatedStatus
+ {summary.bookName} + + {summary.uploadedChapters}/{summary.totalCsvForBook} + + {summary.totalMarkers.toLocaleString()} + + + +
+ {summary.isUploading && ( + + )} + {!summary.isUploading && summary.hasError && ( + + )} + {!summary.isUploading && + !summary.hasError && + summary.uploadedChapters > 0 && ( + + )} + {summary.skippedChapters > 0 && ( + + + + + ! + + + +

+ {summary.skippedChapters} chapter(s) skipped — no + matching video entry found +

+
+
+
+ )} +
+
+ {skipped.bookName} + + 0/{skipped.csvCount} + + + + + + ! + + + +

+ All {skipped.csvCount} chapter(s) skipped — no matching + video entries found for this book +

+
+
+
+
+ ) : ( + // Single mode + + + + + + + + + + + + {entries.map((entry) => { + const status = statusMap[entry.video_id]; + const isEntryUploading = + status?.state === "uploading" || status?.state === "pending"; + const entryAdded = + status?.state === "success" && status.wasCreate === true ? 1 : 0; + const entryUpdated = + status?.state === "success" && status.wasCreate === false ? 1 : 0; + + return ( + + + + + + + + ); + })} + +
BookChapterVerse MarkersAdded / UpdatedStatus
+ {getBookName(entry.book)} + {entry.chapter}{entry.markers.length} + + + {status?.state === "pending" && ( + + )} + {status?.state === "uploading" && ( + + )} + {status?.state === "success" && ( + + )} + {status?.state === "error" && ( + + )} +
+ )} +
+
+ )} + + {/* ── Footer ── */} +
+ +
+ + + + ); +} + +// Badge chip: shows added (green) and/or updated (blue) chapter counts +function AddedUpdatedBadges({ + added, + updated, + isUploading, +}: { + added?: number; + updated?: number; + isUploading?: boolean; +}) { + if (isUploading) return ; + + const showAdded = (added ?? 0) > 0; + const showUpdated = (updated ?? 0) > 0; + + if (!showAdded && !showUpdated) return ; + + return ( +
+ {showAdded && ( + + {added} added + + )} + {showUpdated && ( + + {updated} updated + + )} +
+ ); +} \ No newline at end of file diff --git a/frontend/src/components/VerseMarkerViewDialog.tsx b/frontend/src/components/VerseMarkerViewDialog.tsx new file mode 100644 index 0000000..e8bedf5 --- /dev/null +++ b/frontend/src/components/VerseMarkerViewDialog.tsx @@ -0,0 +1,163 @@ +import { useMemo, useState } from "react"; +import { createColumnHelper, type ColumnDef } from "@tanstack/react-table"; +import { Dialog, DialogContent, DialogHeader } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { DataTable } from "@/components/Datatable"; +import Papa from "papaparse"; +import { toast } from "sonner"; +import { useDeleteISLVerseMarkers, useISLVerseMarker } from "@/hooks/useAPI"; +import { extractErrorMessage } from "@/utils/errorUtils"; +import { VerseMarkerUploadDialog } from "@/components/VerseMarkerUploadDialog"; +import { DeleteDialog } from "./DeleteDialog"; +import { useUserRole } from "@/hooks/useUserRole"; + +interface VerseMarker { + verse: number | string; + time: string; +} + +interface Props { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly videoId: number; + readonly book: string; + readonly chapter: number; + readonly onDeleted?: () => void; +} + +export function VerseMarkerViewDialog({ + open, + onOpenChange, + videoId, + book, + chapter, + onDeleted, +}: Props) { + const { data, isLoading } = useISLVerseMarker(videoId); + const markers: VerseMarker[] = data?.markers ?? []; + const { isAdmin, isEditor } = useUserRole(); + const canEdit = isAdmin || isEditor; + + const deleteMutation = useDeleteISLVerseMarkers(); + const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); + const [uploadOpen, setUploadOpen] = useState(false); + + const columnHelper = createColumnHelper(); + const columns: ColumnDef[] = useMemo( + () => [ + columnHelper.accessor("verse", { header: "Verse", enableSorting: true }), + columnHelper.accessor("time", { header: "Time", enableSorting: true }), + ], + [], + ) as ColumnDef[]; + + const handleDownload = () => { + if (!markers.length) { + toast.error("No data to download"); + return; + } + const csv = Papa.unparse(markers, { header: true }); + const blob = new Blob(["\uFEFF", csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = `${book}_${chapter}_verse_markers.csv`; + link.click(); + URL.revokeObjectURL(url); + }; + + const handleDeleteConfirm = async () => { + try { + const response = await deleteMutation.mutateAsync([videoId]); + const deleted = response?.deletedIds ?? []; + if (deleted.includes(videoId)) { + toast.success("Verse markers deleted successfully"); + setDeleteConfirmOpen(false); + onDeleted?.(); + onOpenChange(false); + } else { + toast.error(response?.errors?.[0] ?? "Failed to delete verse markers"); + } + } catch (err: any) { + toast.error(extractErrorMessage(err) ?? "Failed to delete verse markers"); + } + }; + + const hasExisting = markers.length > 0; + + return ( + <> + + + + +
+ +
+ +
+ {canEdit && ( + + )} + + {canEdit && ( + + )} + +
+
+
+ + {/* Delete confirmation */} + + + {canEdit && ( + + )} + + ); +} diff --git a/frontend/src/hooks/useAPI.ts b/frontend/src/hooks/useAPI.ts index 98a082c..648ae4f 100644 --- a/frontend/src/hooks/useAPI.ts +++ b/frontend/src/hooks/useAPI.ts @@ -68,6 +68,10 @@ import { uploadBookNames, updateBookNames, getBibleContent, + getISLVerseMarker, + updateISLVerseMarker, + uploadISLVerseMarkers, + deleteISLVerseMarkers, } from "../utils/api"; import type { AuditLogQuery, @@ -713,6 +717,71 @@ export const useDeleteISLBibles = () => { }); }; +// ISL Verse markers +export const useISLVerseMarker = (isl_bible_id?: number) => + useQuery({ + queryKey: ["isl-verse-marker", isl_bible_id], + queryFn: async () => { + try { + return await getISLVerseMarker(isl_bible_id as number); + } catch (err: any) { + if (err.response?.status === 404) return null; + throw err; + } + }, + enabled: !!isl_bible_id, + retry: false, + }); + +export const useUploadISLVerseMarkers = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ( + payload: Record, + ) => uploadISLVerseMarkers(payload), + onSuccess: (_data, variables) => { + Object.keys(variables).forEach((id) => { + queryClient.invalidateQueries({ + queryKey: ["isl-verse-marker", Number(id)], + }); + }); + }, + }); +}; + +export const useUpdateISLVerseMarker = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + isl_bible_id, + markers, + }: { + isl_bible_id: number; + markers: { verse: number | string; time: string }[]; + }) => updateISLVerseMarker(isl_bible_id, markers), + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ + queryKey: ["isl-verse-marker", variables.isl_bible_id], + }); + }, + }); +}; + +export const useDeleteISLVerseMarkers = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (isl_bible_ids: number[]) => + deleteISLVerseMarkers(isl_bible_ids), + onSuccess: (_data, variables) => { + variables.forEach((id) => { + queryClient.invalidateQueries({ + queryKey: ["isl-verse-marker", id], + }); + }); + }, + }); +}; + // commentaries export const useListCommentaries = (resource_id?: number) => @@ -804,7 +873,10 @@ export const useErrorLogs = (params: ErrorLogQuery) => { refetchOnWindowFocus: false, }); }; -export const useDownloadBibleContent = (resource_id?: number, autoFetch = false) => { +export const useDownloadBibleContent = ( + resource_id?: number, + autoFetch = false, +) => { return useQuery({ queryKey: ["bible-content", resource_id], queryFn: () => downloadBibleContent(resource_id as number), diff --git a/frontend/src/pages/ISL.tsx b/frontend/src/pages/ISL.tsx index 5092a01..9a6ba27 100644 --- a/frontend/src/pages/ISL.tsx +++ b/frontend/src/pages/ISL.tsx @@ -1,7 +1,20 @@ -import { useState, useMemo } from "react"; -import { Edit, Trash, Info, CheckCircle, XCircle, CloudUpload } from "lucide-react"; +import { useState, useMemo, useEffect, useCallback } from "react"; +import { + Edit, + Trash, + Info, + CheckCircle, + XCircle, + CloudUpload, + Upload, + LoaderCircle, + Download, +} from "lucide-react"; import { createColumnHelper, type ColumnDef } from "@tanstack/react-table"; import { toast } from "sonner"; +import JSZip from "jszip"; +import Papa from "papaparse"; +import { useQueryClient } from "@tanstack/react-query"; import { DataTable } from "@/components/Datatable"; import type { @@ -17,6 +30,7 @@ import { useListISLBible, useUpdateISLBible, useDeleteISLBibles, + useISLVerseMarker, } from "@/hooks/useAPI"; import { useUserRole } from "@/hooks/useUserRole"; import { extractErrorMessage } from "@/utils/errorUtils"; @@ -28,6 +42,9 @@ import { DetailsDialog } from "@/components/DetailsDialog"; import ContentTypeAction from "@/components/ContentTypeAction"; import { API } from "@/utils/axios"; import { PublishDialog } from "@/components/PublishDialog"; +import { VerseMarkerUploadDialog } from "@/components/VerseMarkerUploadDialog"; +import { VerseMarkerViewDialog } from "@/components/VerseMarkerViewDialog"; +import { BOOKS } from "@/utils/books"; // Table row type interface ISLRow { @@ -50,18 +67,329 @@ interface ISLBibleDialogRow { chapter: number; title: string; description: string; - url: string; + "vimeo url": string; +} + +const BOOK_CODE_TO_NAME: Record = Object.fromEntries( + BOOKS.map((b) => [b.book_code.toLowerCase(), b.book_name]), +); + +/** Capitalise the first letter of every word */ +function toTitleCase(str: string): string { + return str.replace(/\w\S*/g, (w) => w.charAt(0).toUpperCase() + w.slice(1)); +} + +function getBookName(code: string): string { + const name = BOOK_CODE_TO_NAME[code.toLowerCase()]; + return name ? toTitleCase(name) : code.toUpperCase(); +} + +function getCachedMarkerStatus( + queryClient: ReturnType, + videoId: number, +): boolean | null { + const cached = queryClient.getQueryData<{ markers?: any[] } | null>([ + "isl-verse-marker", + videoId, + ]); + if (cached === undefined) return null; + // null means 404 (no data) + if (cached === null) return false; + return Array.isArray(cached.markers) && cached.markers.length > 0; +} + +function VerseMarkerCell({ + videoId, + book, + chapter, + onStatusKnown, +}: { + readonly videoId: number; + readonly book: string; + readonly chapter: number; + readonly onStatusKnown?: (videoId: number, hasData: boolean) => void; +}) { + const { data, isLoading } = useISLVerseMarker(videoId); + const { isAdmin, isEditor } = useUserRole(); + const [viewOpen, setViewOpen] = useState(false); + const [uploadOpen, setUploadOpen] = useState(false); + + const hasData = + !isLoading && + data != null && + Array.isArray(data.markers) && + data.markers.length > 0; + + useEffect(() => { + if (!isLoading) { + onStatusKnown?.(videoId, hasData); + } + }, [isLoading, hasData, videoId, onStatusKnown]); + + const canEdit = isAdmin || isEditor; + + if (isLoading) { + return ( +
+ +
+ ); + } + + return ( + <> +
+ {hasData ? ( + + ) : canEdit ? ( + + ) : ( + + )} +
+ + { + onStatusKnown?.(videoId, false); + }} + /> + {canEdit && ( + + )} + + ); +} + +function DownloadVerseMarkersButton({ + rows, +}: { + rows: { video_id: number; book: string; chapter: number }[]; +}) { + const [downloading, setDownloading] = useState(false); + const queryClient = useQueryClient(); + + const handleDownload = async () => { + if (rows.length === 0) { + toast.error("No book chapters available to download."); + return; + } + setDownloading(true); + + try { + const zip = new JSZip(); + + // For each row: use cached data first, only fetch if not cached + const results = await Promise.allSettled( + rows.map(async (row) => { + const cached = queryClient.getQueryData<{ markers?: any[] } | null>([ + "isl-verse-marker", + row.video_id, + ]); + + let markers: { verse: number | string; time: string }[] = []; + + if (cached === undefined) { + // Not in cache — fetch it + try { + const resp = await API.get(`/isl-verse-markers`, { + params: { isl_bible_id: row.video_id }, + }); + markers = resp.data?.markers ?? []; + queryClient.setQueryData( + ["isl-verse-marker", row.video_id], + resp.data, + ); + } catch { + // pass + } + } else if (cached !== null) { + markers = cached.markers ?? []; + } + + return { ...row, markers }; + }), + ); + + let included = 0; + results.forEach((result) => { + if (result.status === "fulfilled") { + const { book, chapter, markers } = result.value; + if (markers.length === 0) return; + + const bookName = getBookName(book); + const csv = Papa.unparse(markers, { header: true }); + zip + .folder(bookName) + ?.file(`${bookName}_${chapter}.csv`, "\uFEFF" + csv); + included++; + } + }); + + if (included === 0) { + toast.error("No verse marker data found for any chapter."); + return; + } + + const blob = await zip.generateAsync({ type: "blob" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "verse_markers.zip"; + link.click(); + URL.revokeObjectURL(url); + + toast.success( + `Verse markers downloaded for available book chapters successfully.`, + ); + } catch (err: any) { + toast.error( + extractErrorMessage(err) ?? "Failed to download verse markers.", + ); + } finally { + setDownloading(false); + } + }; + + return ( + + ); } const ISLBibleAction = ({ resource }: { resource: Resource }) => { const { data: listData, isLoading } = useListISLBible(resource.resourceId); + const [bulkVerseMarkerOpen, setBulkVerseMarkerOpen] = useState(false); + const [markerStatusMap, setMarkerStatusMap] = useState>( + new Map(), + ); + const [isFetchingMarkerStatus, setIsFetchingMarkerStatus] = useState(false); + const queryClient = useQueryClient(); + + const reportMarkerStatus = useCallback( + (videoId: number, hasData: boolean) => { + setMarkerStatusMap((prev) => { + if (prev.get(videoId) === hasData) return prev; + const next = new Map(prev); + next.set(videoId, hasData); + return next; + }); + }, + [], + ); + + const existingMarkerIds = useMemo( + () => + new Set( + [...markerStatusMap.entries()].filter(([, v]) => v).map(([k]) => k), + ), + [markerStatusMap], + ); + + const handleOpenBulkUpload = async () => { + setIsFetchingMarkerStatus(true); + try { + await Promise.all( + verseMarkerRows.map(async ({ video_id }) => { + const cached = getCachedMarkerStatus(queryClient, video_id); + + if (cached !== null) { + // Cache is fresh — use it directly, no network call needed. + reportMarkerStatus(video_id, cached); + return; + } + + // Cache miss — fetch once and store result. + try { + const resp = await API.get(`/isl-verse-markers`, { + params: { isl_bible_id: video_id }, + }); + queryClient.setQueryData(["isl-verse-marker", video_id], resp.data); + const hasMarkers = + Array.isArray(resp.data?.markers) && resp.data.markers.length > 0; + reportMarkerStatus(video_id, hasMarkers); + } catch { + queryClient.setQueryData(["isl-verse-marker", video_id], null); + reportMarkerStatus(video_id, false); + } + }), + ); + } finally { + setIsFetchingMarkerStatus(false); + setBulkVerseMarkerOpen(true); + } + }; + + const handleBulkVerseMarkerOpenChange = useCallback( + (open: boolean) => { + setBulkVerseMarkerOpen(open); + if (!open) { + setMarkerStatusMap((prev) => { + const next = new Map(prev); + verseMarkerRows.forEach(({ video_id }) => { + const cached = getCachedMarkerStatus(queryClient, video_id); + if (cached !== null) { + next.set(video_id, cached); + } + }); + return next; + }); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [queryClient], + ); + const existingRows = useMemo(() => { if (!listData?.books) return []; const rows: (ISLBibleDialogRow & { video_id: number })[] = []; Object.entries(listData.books).forEach(([bookCode, videos]) => { - if (!Array.isArray(videos)) return; // safety guard + if (!Array.isArray(videos)) return; videos.forEach((v) => { rows.push({ @@ -69,7 +397,7 @@ const ISLBibleAction = ({ resource }: { resource: Resource }) => { chapter: v.chapter, title: v.title, description: v.description, - url: v.url, + "vimeo url": v.url, video_id: v.video_id, }); }); @@ -78,7 +406,13 @@ const ISLBibleAction = ({ resource }: { resource: Resource }) => { return rows; }, [listData]); - const REQUIRED_HEADERS = ["book", "chapter", "title", "description", "url"]; + const REQUIRED_HEADERS = [ + "book", + "chapter", + "title", + "description", + "vimeo url", + ]; const dialogColumns: ColumnDef[] = useMemo(() => { const helper = createColumnHelper(); @@ -97,45 +431,90 @@ const ISLBibleAction = ({ resource }: { resource: Resource }) => { /> ), }), - helper.accessor("url", { header: "URL" }), + helper.accessor((row) => row["vimeo url"], { + id: "vimeo url", + header: "Vimeo URL", + cell: ({ getValue }) => { + const url = getValue() as string; + if (!url?.trim()) return ; + return ( + + {url} + + ); + }, + }), ]; }, []) as ColumnDef[]; + const viewOnlyColumns: ColumnDef[] = useMemo(() => { + const helper = createColumnHelper(); + return [ + helper.display({ + id: "verse_marker", + header: "Verse Marker", + cell: ({ row }) => { + const videoId = (row.original as any).video_id; + if (!videoId) return null; + return ( + + ); + }, + }), + ]; + }, [reportMarkerStatus]) as ColumnDef[]; + const titleBuilder = (r: Resource) => - `${r.language.name}_${r.version.code.toUpperCase()}_${r.revision ?? "" - }_ISL_Bible` + `${r.language.name}_${r.version.code.toUpperCase()}_${ + r.revision ?? "" + }_ISL_Bible` .replace(/_+/g, " ") .trim(); // create const buildCreatePayload = ( rows: (ISLBibleDialogRow & { video_id?: number })[], - resourceId: number + resourceId: number, ) => ({ resourceId: resourceId, - videos: rows.map((r) => ({ - book: r.book, - chapter: r.chapter, - title: r.title, - description: r.description, - url: r.url, - })), + videos: rows + .filter((r) => r["vimeo url"]?.trim()) + .map((r) => ({ + book: r.book, + chapter: r.chapter, + title: r.title, + description: r.description, + url: r["vimeo url"], + })), }); // update const buildUpdatePayload = ( rows: (ISLBibleDialogRow & { video_id?: number })[], - resourceId: number + resourceId: number, ) => ({ resourceId: resourceId, - videos: rows.map((r) => ({ - id: r.video_id, - book: r.book, - chapter: r.chapter, - title: r.title, - description: r.description, - url: r.url, - })), + videos: rows + .filter((r) => r["vimeo url"]?.trim()) + .map((r) => ({ + id: r.video_id, + book: r.book, + chapter: r.chapter, + title: r.title, + description: r.description, + url: r["vimeo url"], + })), }); const remoteTestConfig = { @@ -161,7 +540,7 @@ const ISLBibleAction = ({ resource }: { resource: Resource }) => { }, { key: "url", - header: "URL", + header: "Vimeo URL", }, { key: "public", @@ -190,6 +569,44 @@ const ISLBibleAction = ({ resource }: { resource: Resource }) => { }, }; + const verseMarkerRows = useMemo( + () => + existingRows.map((r) => ({ + video_id: r.video_id, + book: r.book, + chapter: r.chapter, + })), + [existingRows], + ); + + const bulkUploadAction = ( + <> + + + + + ); + return ( resource={resource} @@ -209,55 +626,56 @@ const ISLBibleAction = ({ resource }: { resource: Resource }) => { chapter: r.chapter, title: r.title, description: r.description, - url: r.url, + "vimeo url": r["vimeo url"] ?? r.url ?? "", })} buildCreatePayload={buildCreatePayload} buildUpdatePayload={buildUpdatePayload} contentType="ISL Bible" remoteTestConfig={remoteTestConfig} + viewOnlyColumns={viewOnlyColumns} + extraViewActions={bulkUploadAction} /> ); -} +}; function useISLPublishState(resource?: Resource) { - const { data, isLoading } = useListISLBible(resource?.resourceId) + const { data, isLoading } = useListISLBible(resource?.resourceId); const hasAnyData = !!data?.books && Object.values(data.books).some( - (videos) => Array.isArray(videos) && videos.length > 0 - ) + (videos) => Array.isArray(videos) && videos.length > 0, + ); return { isLoading, hasAnyData, - isPublished: resource?.published === true - } + isPublished: resource?.published === true, + }; } - function ISLPublishAction({ resource, - onClick + onClick, }: { - resource: Resource, - onClick: (resource: Resource) => void + resource: Resource; + onClick: (resource: Resource) => void; }) { - const { isLoading, hasAnyData, isPublished } = useISLPublishState(resource) + const { isLoading, hasAnyData, isPublished } = useISLPublishState(resource); - const disabled = !hasAnyData || isLoading + const disabled = !hasAnyData || isLoading; const title = !hasAnyData ? "Upload isl data before publishing" : isPublished ? "Unpublish" - : "Publish" + : "Publish"; const colorClass = !hasAnyData ? "text-gray-400" : isPublished ? "text-green-600" - : "text-black" + : "text-black"; return ( - ) + ); } const ISL = () => { const { data, isLoading, error } = useResources(); const { isAdmin, isEditor } = useUserRole(); const [selectedResource, setSelectedResource] = useState( - null + null, ); const [selectedMetadataResource, setSelectedMetadataResource] = useState(null); @@ -285,9 +704,11 @@ const ISL = () => { const [editDialogOpen, setEditDialogOpen] = useState(false); const [finalConfirmOpen, setFinalConfirmOpen] = useState(false); const [deleteError, setDeleteError] = useState(null); - const [publishOpen, setPublishOpen] = useState(false) + const [publishOpen, setPublishOpen] = useState(false); - const [publishStep, setPublishStep] = useState<"confirm" | "loading" | "success">("confirm"); + const [publishStep, setPublishStep] = useState< + "confirm" | "loading" | "success" + >("confirm"); const editResourceMutate = useEditResource(); @@ -295,10 +716,11 @@ const ISL = () => { const isDeleting = deleteResourceMutation.isPending; - const requiredName = selectedResource - ? `${selectedResource.language.name - }_${selectedResource.version.code.toUpperCase()}_${selectedResource.revision ?? "" + ? `${ + selectedResource.language.name + }_${selectedResource.version.code.toUpperCase()}_${ + selectedResource.revision ?? "" }_ISL_Bible`.trim() : ""; const requiredPhrase = "delete isl bible"; @@ -319,8 +741,8 @@ const ISL = () => { license_id: selectedResource.license.id, metadata: selectedResource.metadata && - typeof selectedResource.metadata === "object" && - Object.keys(selectedResource.metadata).length > 0 + typeof selectedResource.metadata === "object" && + Object.keys(selectedResource.metadata).length > 0 ? JSON.stringify(selectedResource.metadata, null, 2) : "", } as Partial; @@ -336,7 +758,6 @@ const ISL = () => { }; const handleEditSubmit = async (formData: any) => { - console.log("formData", formData); if (!selectedResource) return; try { @@ -372,7 +793,7 @@ const ISL = () => { if (!selectedResource) return; try { const response = await deleteResourceMutation.mutateAsync( - selectedResource.resourceId + selectedResource.resourceId, ); const deletedIds = response?.deletedIds ?? []; const errors = response?.errors ?? []; @@ -400,12 +821,9 @@ const ISL = () => { setPublishOpen(true); }; - - const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - const handlePublish = async () => { if (!selectedResource) return; @@ -417,7 +835,7 @@ const ISL = () => { resourceId: selectedResource.resourceId, published: !selectedResource.published, }), - wait(2000) + wait(2000), ]); setPublishStep("success"); @@ -427,8 +845,6 @@ const ISL = () => { } }; - - const islItems: ISLRow[] = useMemo(() => { if (!Array.isArray(data)) return []; @@ -464,7 +880,7 @@ const ISL = () => { version: v.version, license: v.license, }, - })) + })), ); }, [data]); @@ -500,11 +916,8 @@ const ISL = () => {
{ - if (hasMetadata) { - return handleMetadataClick(row.original.fullResource); - } - if (isAdmin) { - return handleMetadataClick(row.original.fullResource); + if (hasMetadata || isAdmin) { + handleMetadataClick(row.original.fullResource); } }} title={ @@ -551,38 +964,38 @@ const ISL = () => { }), ...(isAdmin ? [ - columnHelper.display({ - id: "actions", - header: "Actions", - cell: ({ row }) => ( -
- - - -
- ), - }), - ] + columnHelper.display({ + id: "actions", + header: "Actions", + cell: ({ row }) => ( +
+ + + +
+ ), + }), + ] : []), ], - [data, isAdmin, isEditor] + [data, isAdmin, isEditor], ) as ColumnDef[]; return ( diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index 123827f..119d55f 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -446,6 +446,35 @@ export const deleteISLBibles = async ( return res.data; }; +export const getISLVerseMarker = async (isl_bible_id: number) => { + const res = await API.get(`/isl-verse-markers`, { + params: { isl_bible_id }, + }); + return res.data; +}; + +export const uploadISLVerseMarkers = async ( + payload: Record +) => { + const res = await API.post(`/isl-verse-markers`, payload); + return res.data; +}; + +export const updateISLVerseMarker = async ( + isl_bible_id: number, + markers: { verse: number | string; time: string }[] +) => { + const res = await API.put(`/isl-verse-markers/${isl_bible_id}`, { markers }); + return res.data; +}; + +export const deleteISLVerseMarkers = async (isl_bible_ids: number[]) => { + const res = await API.delete(`/isl-verse-markers/bulk-delete`, { + data: { isl_bible_ids }, + }); + return res.data; +}; + export const getCommentaries = async (resource_id: number) => { const res = await API.get(`/commentary/${resource_id}`); return res.data; diff --git a/frontend/src/utils/types.ts b/frontend/src/utils/types.ts index 8d8267f..127877d 100644 --- a/frontend/src/utils/types.ts +++ b/frontend/src/utils/types.ts @@ -329,6 +329,8 @@ export interface ContentTypeActionProps { identityKey: string; normalizeApiRow?: (row: any) => E; remoteTestConfig?: RemoteTestConfig; + viewOnlyColumns?: ColumnDef[]; + extraViewActions?: React.ReactNode; } export interface DuplicateCsvDialogProps { @@ -360,6 +362,8 @@ export interface UploadOrViewDialogProps { normalizeApiRow?: (row: any) => any; clearPreview?: () => void; remoteTestConfig?: RemoteTestConfig; + viewOnlyColumns?: ColumnDef[]; + extraViewActions?: React.ReactNode; } // dictionaries diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 326b90b..559c00a 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,14 +1,17 @@ -import path from "path" -import { defineConfig } from 'vite' -import tailwindcss from "@tailwindcss/vite" -import react from '@vitejs/plugin-react' +import path from "path"; +import { defineConfig } from "vite"; +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; // https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], + server: { + allowedHosts: ["admin.vachanengine.org"], + }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, -}) +});