From c53d07c67d00e995634a530a3e3fa93d855d28e3 Mon Sep 17 00:00:00 2001 From: KetanKBaboo Date: Thu, 29 Jan 2026 17:32:10 +0530 Subject: [PATCH 01/10] modified frontend docker files and website UI ports --- .dockerignore | 9 ++++++ backend/app/main.py | 3 +- docker/docker_backend/docker-compose.yml | 2 +- docker/docker_frontend/Dockerfile | 36 ++++++++++++++++++---- docker/docker_frontend/docker-compose.yml | 21 ++++++------- docker/docker_frontend/nginx/frontend.conf | 12 ++++++++ frontend/vite.config.ts | 5 ++- 7 files changed, 68 insertions(+), 20 deletions(-) create mode 100644 .dockerignore create mode 100644 docker/docker_frontend/nginx/frontend.conf diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..9cc212aa --- /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/main.py b/backend/app/main.py index c5c8001e..51f5a3a8 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -49,7 +49,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_backend/docker-compose.yml b/docker/docker_backend/docker-compose.yml index 1e727b42..96d638d0 100644 --- a/docker/docker_backend/docker-compose.yml +++ b/docker/docker_backend/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.9" services: # === SUPERTOKENS DB === supertokens-db: - image: "postgres:15" + image: "postgres:latest" environment: - POSTGRES_USER=${SUPERTOKENS_DB_USER} - POSTGRES_PASSWORD=${SUPERTOKENS_DB_PASSWORD} diff --git a/docker/docker_frontend/Dockerfile b/docker/docker_frontend/Dockerfile index 8823effe..51ec3155 100644 --- a/docker/docker_frontend/Dockerfile +++ b/docker/docker_frontend/Dockerfile @@ -1,15 +1,39 @@ -FROM node:20-alpine +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 + +# 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 -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 9e72b6d2..3e062ba4 100644 --- a/docker/docker_frontend/docker-compose.yml +++ b/docker/docker_frontend/docker-compose.yml @@ -3,21 +3,20 @@ 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} 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 00000000..3632503d --- /dev/null +++ b/docker/docker_frontend/nginx/frontend.conf @@ -0,0 +1,12 @@ +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/vite.config.ts b/frontend/vite.config.ts index 326b90b4..f34dcc33 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,9 +6,12 @@ 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"), }, }, -}) +}) \ No newline at end of file From d6fa8d22cdad4f2d8bfd4cd17d1f79dd7cd6e032 Mon Sep 17 00:00:00 2001 From: KetanKBaboo Date: Thu, 29 Jan 2026 17:36:24 +0530 Subject: [PATCH 02/10] changed postgres version to 15 --- docker/docker_backend/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/docker_backend/docker-compose.yml b/docker/docker_backend/docker-compose.yml index 96d638d0..1e727b42 100644 --- a/docker/docker_backend/docker-compose.yml +++ b/docker/docker_backend/docker-compose.yml @@ -2,7 +2,7 @@ version: "3.9" services: # === SUPERTOKENS DB === supertokens-db: - image: "postgres:latest" + image: "postgres:15" environment: - POSTGRES_USER=${SUPERTOKENS_DB_USER} - POSTGRES_PASSWORD=${SUPERTOKENS_DB_PASSWORD} From 22d3bd2ada47987db52c86ed50c0493757c817b9 Mon Sep 17 00:00:00 2001 From: KetanKBaboo Date: Wed, 13 May 2026 17:32:28 +0530 Subject: [PATCH 03/10] ISL Verse marker CRUD + Handle vimeo url in ISL table --- frontend/src/components/ContentTypeAction.tsx | 4 + .../src/components/UploadOrViewDialog.tsx | 38 +- .../components/VerseMarkerUploadDialog.tsx | 474 ++++++++++++++++++ .../src/components/VerseMarkerViewDialog.tsx | 163 ++++++ frontend/src/hooks/useAPI.ts | 74 ++- frontend/src/pages/ISL.tsx | 404 +++++++++++---- frontend/src/utils/api.ts | 29 ++ frontend/src/utils/types.ts | 4 + 8 files changed, 1086 insertions(+), 104 deletions(-) create mode 100644 frontend/src/components/VerseMarkerUploadDialog.tsx create mode 100644 frontend/src/components/VerseMarkerViewDialog.tsx diff --git a/frontend/src/components/ContentTypeAction.tsx b/frontend/src/components/ContentTypeAction.tsx index c81f7e21..0f665589 100644 --- a/frontend/src/components/ContentTypeAction.tsx +++ b/frontend/src/components/ContentTypeAction.tsx @@ -28,6 +28,8 @@ export default function ContentTypeAction({ identityKey, normalizeApiRow = (row: any) => row as E, remoteTestConfig, + viewOnlyColumns, + extraViewActions }: ContentTypeActionProps) { const { isAdmin, isEditor } = useUserRole(); @@ -166,6 +168,8 @@ export default function ContentTypeAction({ normalizeApiRow={normalizeApiRow} clearPreview={() => setPreviewRows([])} remoteTestConfig={remoteTestConfig} + viewOnlyColumns={viewOnlyColumns} + extraViewActions={extraViewActions} /> ({ normalizeApiRow = (row: any) => row, clearPreview, remoteTestConfig, + viewOnlyColumns = [], + extraViewActions, }: UploadOrViewDialogProps) { const [isUploading, setIsUploading] = useState(false); const [overwrite, setOverwrite] = useState(false); @@ -159,12 +161,23 @@ export default function UploadOrViewDialog({ const columnsWithStatus: ColumnDef[] = useMemo(() => { - if (!previewWithExistingData) - return columns as ColumnDef[]; + // Base columns — preview gets these only + const base = columns as ColumnDef[]; + + // View-only columns appended before the status column in view mode + const withViewOnly: ColumnDef[] = + mode === "view" + ? [ + ...base, + ...(viewOnlyColumns as ColumnDef[]), + ] + : base; + + if (!previewWithExistingData) return withViewOnly; const helper = createColumnHelper(); return [ - ...(columns as ColumnDef[]), + ...withViewOnly, helper.accessor((row) => row.status, { id: "status", header: ({ column }) => ( @@ -184,7 +197,7 @@ export default function UploadOrViewDialog({ }, }), ] as ColumnDef[]; - }, [columns, previewWithExistingData]); + }, [columns, viewOnlyColumns, mode, previewWithExistingData]); // handle upload action const handleUpload = async () => { @@ -316,13 +329,13 @@ export default function UploadOrViewDialog({ const filteredRows: Record[] = requiredHeaders?.length ? rows.map((row) => - Object.fromEntries( - requiredHeaders.map((key) => [ - key, - (row as Record)[key] ?? "", - ]), - ), - ) + Object.fromEntries( + requiredHeaders.map((key) => [ + key, + (row as Record)[key] ?? "", + ]), + ), + ) : (rows as Record[]); const csv = Papa.unparse(filteredRows, { @@ -427,7 +440,7 @@ export default function UploadOrViewDialog({ {mode === "view" && (
- {remoteTestConfig && (isAdmin) && ( + {remoteTestConfig && isAdmin && ( )} + {(isAdmin || isEditor) && extraViewActions} {(isAdmin || isEditor) && ( +
+ )} + + {/* ── Parsing zip (brief spinner before upload starts) ── */} + {isUploading && entries.length === 0 && ( +
+ + Reading file… +
+ )} + + {/* ── Progress / results ── */} + {entries.length > 0 && ( +
+ {/* summary bar */} +
+ + {successCount}/{totalCount} uploaded + {errorCount > 0 && ( + + · {errorCount} failed + + )} + + {unmatched.length > 0 && ( + + {unmatched.length} skipped + + )} +
+ + {/* progress bar */} +
+
+
+ + {/* per-row list */} +
+ + + + + + + + + + + {entries.map((entry) => { + const status = statusMap[entry.video_id]; + return ( + + + + + + + ); + })} + +
+ Book + + Chapter + + Markers + + Status +
+ {entry.book} + + {entry.chapter} + + {entry.markers.length} + + {status?.state === "pending" && ( + + )} + {status?.state === "uploading" && ( + + )} + {status?.state === "success" && ( + + )} + {status?.state === "error" && ( + + )} +
+
+
+ )} + + {/* ── Footer ── */} +
+ +
+ + + + ); +} \ 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 00000000..e8bedf52 --- /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 98a082ca..648ae4f6 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 5092a012..a0a06de7 100644 --- a/frontend/src/pages/ISL.tsx +++ b/frontend/src/pages/ISL.tsx @@ -1,5 +1,13 @@ -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, +} from "lucide-react"; import { createColumnHelper, type ColumnDef } from "@tanstack/react-table"; import { toast } from "sonner"; @@ -17,6 +25,7 @@ import { useListISLBible, useUpdateISLBible, useDeleteISLBibles, + useISLVerseMarker, } from "@/hooks/useAPI"; import { useUserRole } from "@/hooks/useUserRole"; import { extractErrorMessage } from "@/utils/errorUtils"; @@ -28,6 +37,8 @@ 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"; // Table row type interface ISLRow { @@ -50,18 +61,163 @@ interface ISLBibleDialogRow { chapter: number; title: string; description: string; - url: string; + "vimeo url": string; +} + +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 && ( + + )} + + ); } 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 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 () => { + setBulkVerseMarkerOpen(true); + + // Find video IDs whose marker status isn't known yet from cell renders + const unknownIds = verseMarkerRows + .map((r) => r.video_id) + .filter((id) => !markerStatusMap.has(id)); + + if (unknownIds.length === 0) return; + + setIsFetchingMarkerStatus(true); + try { + await Promise.all( + unknownIds.map(async (id) => { + try { + await API.get(`/isl-verse-markers`, { + params: { isl_bible_id: id }, + }); + reportMarkerStatus(id, true); + } catch { + reportMarkerStatus(id, false); // 404 = no data + } + }), + ); + } finally { + setIsFetchingMarkerStatus(false); + } + }; + 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 +225,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 +234,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 +259,75 @@ const ISLBibleAction = ({ resource }: { resource: Resource }) => { /> ), }), - helper.accessor("url", { header: "URL" }), + helper.accessor((row) => row["vimeo url"], { + id: "vimeo url", + header: "Vimeo 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 +353,7 @@ const ISLBibleAction = ({ resource }: { resource: Resource }) => { }, { key: "url", - header: "URL", + header: "Vimeo URL", }, { key: "public", @@ -190,6 +382,36 @@ 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 +431,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 +509,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 +521,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 +546,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; @@ -372,7 +599,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 +627,9 @@ const ISL = () => { setPublishOpen(true); }; - - const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); - const handlePublish = async () => { if (!selectedResource) return; @@ -417,7 +641,7 @@ const ISL = () => { resourceId: selectedResource.resourceId, published: !selectedResource.published, }), - wait(2000) + wait(2000), ]); setPublishStep("success"); @@ -427,8 +651,6 @@ const ISL = () => { } }; - - const islItems: ISLRow[] = useMemo(() => { if (!Array.isArray(data)) return []; @@ -464,7 +686,7 @@ const ISL = () => { version: v.version, license: v.license, }, - })) + })), ); }, [data]); @@ -551,38 +773,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 123827fb..119d55f1 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 8d8267f4..127877dd 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 From 1bcb8632dc9f1fb9341775cc506dadc6886d55b7 Mon Sep 17 00:00:00 2001 From: KetanKBaboo Date: Fri, 15 May 2026 10:20:25 +0530 Subject: [PATCH 04/10] UI modification to show progress book wise and show warning or error label --- .../components/VerseMarkerUploadDialog.tsx | 411 +++++++++++++----- frontend/src/pages/ISL.tsx | 43 +- 2 files changed, 322 insertions(+), 132 deletions(-) diff --git a/frontend/src/components/VerseMarkerUploadDialog.tsx b/frontend/src/components/VerseMarkerUploadDialog.tsx index 43c9d694..198c26fe 100644 --- a/frontend/src/components/VerseMarkerUploadDialog.tsx +++ b/frontend/src/components/VerseMarkerUploadDialog.tsx @@ -1,4 +1,4 @@ -import { useState, useRef } from "react"; +import { useState, useRef, useMemo } from "react"; import Papa from "papaparse"; import JSZip from "jszip"; import { @@ -7,6 +7,12 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@/components/ui/tooltip"; import { Button } from "@/components/ui/button"; import { CheckCircle, LoaderCircle, XCircle } from "lucide-react"; import { toast } from "sonner"; @@ -35,10 +41,22 @@ type UploadState = "idle" | "uploading" | "done"; interface EntryStatus { state: "pending" | "uploading" | "success" | "error"; error?: string; + created?: number; + updated?: number; +} + +interface BookSummary { + book: string; + totalChapters: number; + uploadedChapters: number; + totalMarkers: number; + skippedChapters: number; + hasError: boolean; + isUploading: boolean; } const BOOK_NAME_TO_CODE: Record = Object.fromEntries( - BOOKS.map((b) => [b.book_name.toLowerCase(), b.book_code]) + BOOKS.map((b) => [b.book_name.toLowerCase(), b.book_code]), ); function parseVerseMarkerCsv(csvText: string): VerseMarker[] { @@ -48,12 +66,18 @@ function parseVerseMarkerCsv(csvText: string): VerseMarker[] { }); const sorted = result.data - .filter((r) => r.verse != null && r.time != null && r.time.trim() !== "") + .filter( + (r) => + r.verse != null && + r.time != null && + r.time.trim() !== "" && + r.time.trim() !== "00:00:00:00", + ) .map((r) => ({ verse: isNaN(Number(r.verse)) ? r.verse.trim() : Number(r.verse), time: r.time.trim(), })) - .sort((a, b) => a.time.localeCompare(b.time)); + .sort((a, b) => (a.time < b.time ? -1 : a.time > b.time ? 1 : 0)); // Deduplicate by verse key — keep last (latest time) after sort const seen = new Map(); @@ -144,7 +168,7 @@ export function VerseMarkerUploadDialog({ }; const handleZip = async (file: File) => { - setUploadState("uploading"); // show spinner while parsing + setUploadState("uploading"); try { const zip = await JSZip.loadAsync(file); @@ -152,7 +176,7 @@ export function VerseMarkerUploadDialog({ // Build lookup: "bookcode_chapter" → { video_id, hasExisting } const rowLookup = new Map< string, - { video_id: number, hasExisting: boolean } + { video_id: number; hasExisting: boolean } >(); for (const row of existingRows) { const key = `${row.book.toLowerCase()}_${row.chapter}`; @@ -212,9 +236,6 @@ export function VerseMarkerUploadDialog({ return; } - console.log("skipped files:", skipped); - console.log("parsed entries:", parsed); - parsed.sort( (a, b) => a.book.localeCompare(b.book) || a.chapter - b.chapter, ); @@ -236,66 +257,72 @@ export function VerseMarkerUploadDialog({ }; const uploadEntries = async ( - entriesToUpload: ParsedEntry[], - initStatus: Record, -) => { - setUploadState("uploading"); - setStatusMap({ ...initStatus }); - - const updateStatus = (video_id: number, patch: Partial) => { - setStatusMap((prev) => ({ - ...prev, - [video_id]: { ...prev[video_id], ...patch }, - })); - }; - - const toCreate = entriesToUpload.filter((e) => !e.hasExisting); - const toUpdate = entriesToUpload.filter((e) => e.hasExisting); + entriesToUpload: ParsedEntry[], + initStatus: Record, + ) => { + setUploadState("uploading"); + setStatusMap({ ...initStatus }); + + const updateStatus = (video_id: number, patch: Partial) => { + setStatusMap((prev) => ({ + ...prev, + [video_id]: { ...prev[video_id], ...patch }, + })); + }; - // POST request - if (toCreate.length > 0) { - for (const entry of toCreate) { - updateStatus(entry.video_id, { state: "uploading" }); - try { - const payload: Record = { - [String(entry.video_id)]: entry.markers, - }; - await uploadMutation.mutateAsync(payload); - updateStatus(entry.video_id, { state: "success" }); - } catch (err: any) { - updateStatus(entry.video_id, { - state: "error", - error: extractErrorMessage(err) ?? "Failed", - }); + const toCreate = entriesToUpload.filter((e) => !e.hasExisting); + const toUpdate = entriesToUpload.filter((e) => e.hasExisting); + + // POST — one request per new entry + for (const entry of toCreate) { + updateStatus(entry.video_id, { state: "uploading" }); + try { + const payload: Record< + string, + { verse: number | string; time: string }[] + > = { + [String(entry.video_id)]: entry.markers, + }; + await uploadMutation.mutateAsync(payload); + updateStatus(entry.video_id, { + state: "success", + created: entry.markers.length, + }); + } catch (err: any) { + updateStatus(entry.video_id, { + state: "error", + error: extractErrorMessage(err) ?? "Failed", + }); + } } - } -} - // PUT request: one request per existing entry - for (const entry of toUpdate) { - updateStatus(entry.video_id, { state: "uploading" }); - try { - await updateMutation.mutateAsync({ - isl_bible_id: entry.video_id, - markers: entry.markers, - }); - updateStatus(entry.video_id, { state: "success" }); - } catch (err: any) { - updateStatus(entry.video_id, { - state: "error", - error: extractErrorMessage(err) ?? "Failed", - }); + // PUT — one request per existing entry + for (const entry of toUpdate) { + updateStatus(entry.video_id, { state: "uploading" }); + try { + await updateMutation.mutateAsync({ + isl_bible_id: entry.video_id, + markers: entry.markers, + }); + updateStatus(entry.video_id, { + state: "success", + updated: entry.markers.length, + }); + } catch (err: any) { + updateStatus(entry.video_id, { + state: "error", + error: extractErrorMessage(err) ?? "Failed", + }); + } } - } - setUploadState("done"); -}; + setUploadState("done"); + }; const handleFileChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; if (fileInputRef.current) fileInputRef.current.value = ""; - if (mode === "single") { handleSingleCsv(file); } else { @@ -303,16 +330,59 @@ export function VerseMarkerUploadDialog({ } }; + const totalChapters = entries.length; const successCount = Object.values(statusMap).filter( (s) => s.state === "success", ).length; const errorCount = Object.values(statusMap).filter( (s) => s.state === "error", ).length; - const totalCount = entries.length; - const progressPct = totalCount === 0 ? 0 : (successCount / totalCount) * 100; + const progressPct = + totalChapters === 0 ? 0 : (successCount / totalChapters) * 100; const isUploading = uploadState === "uploading"; + // Count skipped chapters per book from unmatched filenames + const skippedByBook = useMemo(() => { + const map = new Map(); + unmatched.forEach((filename) => { + const parsed = parseFileName(filename); + if (parsed) { + map.set(parsed.bookCode, (map.get(parsed.bookCode) ?? 0) + 1); + } + }); + return map; + }, [unmatched]); + + // Group entries by book for summary rows + const bookSummaries = useMemo((): BookSummary[] => { + const bookMap = new Map(); + entries.forEach((e) => { + const existing = bookMap.get(e.book) ?? []; + bookMap.set(e.book, [...existing, e]); + }); + + return [...bookMap.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([bookCode, bookEntries]) => { + const statuses = bookEntries.map((e) => statusMap[e.video_id]); + return { + book: bookCode, + totalChapters: bookEntries.length, + uploadedChapters: statuses.filter((s) => s?.state === "success") + .length, + totalMarkers: bookEntries.reduce( + (sum, e) => sum + e.markers.length, + 0, + ), + skippedChapters: skippedByBook.get(bookCode) ?? 0, + hasError: statuses.some((s) => s?.state === "error"), + isUploading: statuses.some( + (s) => s?.state === "uploading" || s?.state === "pending", + ), + }; + }); + }, [entries, statusMap, skippedByBook]); + const handleClose = () => { if (isUploading) return; setEntries([]); @@ -333,7 +403,7 @@ export function VerseMarkerUploadDialog({ /> - + {mode === "single" @@ -342,6 +412,7 @@ export function VerseMarkerUploadDialog({ + {/* ── Idle: pick file ── */} {uploadState === "idle" && entries.length === 0 && (

@@ -359,7 +430,7 @@ export function VerseMarkerUploadDialog({

)} - {/* ── Parsing zip (brief spinner before upload starts) ── */} + {/* ── Parsing: spinner before entries are set ── */} {isUploading && entries.length === 0 && (
@@ -370,24 +441,65 @@ export function VerseMarkerUploadDialog({ {/* ── Progress / results ── */} {entries.length > 0 && (
- {/* summary bar */} + {/* Legend — only shown in bulk mode */} + {mode === "bulk" && ( +
+ + + error + + + + ! + + warning (chapters skipped — video not available) + +
+ )} + + {/* Summary line */}
- {successCount}/{totalCount} uploaded + {successCount}/{totalChapters} chapter(s) uploaded {errorCount > 0 && ( · {errorCount} failed )} - {unmatched.length > 0 && ( + + {/* Marker counts — single mode only */} + {mode === "single" && uploadState === "done" && ( +
+ {Object.values(statusMap).some((s) => s.created) && ( + + {Object.values(statusMap).reduce( + (sum, s) => sum + (s.created ?? 0), + 0, + )}{" "} + uploaded + + )} + {Object.values(statusMap).some((s) => s.updated) && ( + + {Object.values(statusMap).reduce( + (sum, s) => sum + (s.updated ?? 0), + 0, + )}{" "} + updated + + )} +
+ )} + + {unmatched.length > 0 && mode === "bulk" && ( - {unmatched.length} skipped + {unmatched.length} file(s) skipped as entry not found )}
- {/* progress bar */} + {/* Progress bar */}
- {/* per-row list */} + {/* Table — per-book in bulk, per-chapter in single */}
- - - - - - - - - - - {entries.map((entry) => { - const status = statusMap[entry.video_id]; - return ( + {mode === "bulk" ? ( +
- Book - - Chapter - - Markers - - Status -
+ + + + + + + + + + {bookSummaries.map((summary) => ( - - ); - })} - -
+ Book + + Chapters + + Verse Markers + + Status +
- {entry.book} + + {summary.book} - {entry.chapter} + {summary.uploadedChapters}/{summary.totalChapters} - {entry.markers.length} + {summary.totalMarkers.toLocaleString()} - {status?.state === "pending" && ( - - )} - {status?.state === "uploading" && ( - - )} - {status?.state === "success" && ( - - )} - {status?.state === "error" && ( - - )} +
+ {summary.isUploading && ( + + )} + {!summary.isUploading && summary.hasError && ( + + )} + {!summary.isUploading && + !summary.hasError && + summary.uploadedChapters === + summary.totalChapters && ( + + )} + {summary.skippedChapters > 0 && ( + + + + + ! + + + +

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

+
+
+
+ )} +
+ ))} + + + ) : ( + // Single mode — keep original per-chapter row + + + + + + + + + + + {entries.map((entry) => { + const status = statusMap[entry.video_id]; + return ( + + + + + + + ); + })} + +
+ Book + + Chapter + + Verse Markers + + Status +
+ {entry.book} + + {entry.chapter} + + {entry.markers.length} + + {status?.state === "pending" && ( + + )} + {status?.state === "uploading" && ( + + )} + {status?.state === "success" && ( + + )} + {status?.state === "error" && ( + + )} +
+ )}
)} @@ -471,4 +654,4 @@ export function VerseMarkerUploadDialog({
); -} \ No newline at end of file +} diff --git a/frontend/src/pages/ISL.tsx b/frontend/src/pages/ISL.tsx index a0a06de7..423fd2df 100644 --- a/frontend/src/pages/ISL.tsx +++ b/frontend/src/pages/ISL.tsx @@ -7,6 +7,7 @@ import { XCircle, CloudUpload, Upload, + LoaderCircle, } from "lucide-react"; import { createColumnHelper, type ColumnDef } from "@tanstack/react-table"; import { toast } from "sonner"; @@ -183,31 +184,30 @@ const ISLBibleAction = ({ resource }: { resource: Resource }) => { ); const handleOpenBulkUpload = async () => { - setBulkVerseMarkerOpen(true); - - // Find video IDs whose marker status isn't known yet from cell renders + // Find video IDs whose marker status isn't known yet const unknownIds = verseMarkerRows .map((r) => r.video_id) .filter((id) => !markerStatusMap.has(id)); - if (unknownIds.length === 0) return; - setIsFetchingMarkerStatus(true); try { - await Promise.all( - unknownIds.map(async (id) => { - try { - await API.get(`/isl-verse-markers`, { - params: { isl_bible_id: id }, - }); - reportMarkerStatus(id, true); - } catch { - reportMarkerStatus(id, false); // 404 = no data - } - }), - ); + if (unknownIds.length > 0) { + await Promise.all( + unknownIds.map(async (id) => { + try { + await API.get(`/isl-verse-markers`, { + params: { isl_bible_id: id }, + }); + reportMarkerStatus(id, true); + } catch { + reportMarkerStatus(id, false); + } + }), + ); + } } finally { setIsFetchingMarkerStatus(false); + setBulkVerseMarkerOpen(true); } }; @@ -400,7 +400,14 @@ const ISLBibleAction = ({ resource }: { resource: Resource }) => { onClick={handleOpenBulkUpload} disabled={isFetchingMarkerStatus} > - Upload Verse Markers + {isFetchingMarkerStatus ? ( + + + Please wait... + + ) : ( + "Upload Verse Markers" + )} Date: Mon, 18 May 2026 14:23:50 +0530 Subject: [PATCH 05/10] added review changes (show full book name, show updated/added count per book, download markers) --- .../components/VerseMarkerUploadDialog.tsx | 308 +++++++++++++----- frontend/src/pages/ISL.tsx | 236 ++++++++++++-- 2 files changed, 429 insertions(+), 115 deletions(-) diff --git a/frontend/src/components/VerseMarkerUploadDialog.tsx b/frontend/src/components/VerseMarkerUploadDialog.tsx index 198c26fe..9c864b78 100644 --- a/frontend/src/components/VerseMarkerUploadDialog.tsx +++ b/frontend/src/components/VerseMarkerUploadDialog.tsx @@ -41,23 +41,45 @@ type UploadState = "idle" | "uploading" | "done"; interface EntryStatus { state: "pending" | "uploading" | "success" | "error"; error?: string; - created?: number; - updated?: number; + wasCreate?: boolean; } interface BookSummary { book: string; + bookName: string; totalChapters: number; uploadedChapters: number; totalMarkers: number; skippedChapters: number; + totalCsvForBook: number; hasError: boolean; isUploading: boolean; + chaptersAdded: number; + chaptersUpdated: number; +} + +interface SkippedBookSummary { + book: string; + bookName: string; + csvCount: number; } const BOOK_NAME_TO_CODE: Record = Object.fromEntries( BOOKS.map((b) => [b.book_name.toLowerCase(), b.book_code]), ); +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 parseVerseMarkerCsv(csvText: string): VerseMarker[] { const result = Papa.parse<{ verse: string; time: string }>(csvText, { @@ -138,6 +160,9 @@ export function VerseMarkerUploadDialog({ const [statusMap, setStatusMap] = useState>({}); const [uploadState, setUploadState] = useState("idle"); const [unmatched, setUnmatched] = useState([]); + const [csvCountByBook, setCsvCountByBook] = useState>( + new Map(), + ); const uploadMutation = useUploadISLVerseMarkers(); const updateMutation = useUpdateISLVerseMarker(); @@ -188,6 +213,7 @@ export function VerseMarkerUploadDialog({ const parsed: ParsedEntry[] = []; const skipped: string[] = []; + const csvPerBook = new Map(); const csvFiles: { path: string; file: JSZip.JSZipObject }[] = []; zip.forEach((relativePath, zipEntry) => { @@ -206,6 +232,11 @@ export function VerseMarkerUploadDialog({ return; } + csvPerBook.set( + parsed_.bookCode, + (csvPerBook.get(parsed_.bookCode) ?? 0) + 1, + ); + const key = `${parsed_.bookCode}_${parsed_.chapter}`; const match = rowLookup.get(key); @@ -248,6 +279,7 @@ export function VerseMarkerUploadDialog({ setEntries(parsed); setStatusMap(initStatus); setUnmatched(skipped); + setCsvCountByBook(csvPerBook); await uploadEntries(parsed, initStatus); } catch (err: any) { @@ -273,7 +305,7 @@ export function VerseMarkerUploadDialog({ const toCreate = entriesToUpload.filter((e) => !e.hasExisting); const toUpdate = entriesToUpload.filter((e) => e.hasExisting); - // POST — one request per new entry + // POST — new chapters for (const entry of toCreate) { updateStatus(entry.video_id, { state: "uploading" }); try { @@ -284,10 +316,7 @@ export function VerseMarkerUploadDialog({ [String(entry.video_id)]: entry.markers, }; await uploadMutation.mutateAsync(payload); - updateStatus(entry.video_id, { - state: "success", - created: entry.markers.length, - }); + updateStatus(entry.video_id, { state: "success", wasCreate: true }); } catch (err: any) { updateStatus(entry.video_id, { state: "error", @@ -296,7 +325,7 @@ export function VerseMarkerUploadDialog({ } } - // PUT — one request per existing entry + // PUT — existing chapters for (const entry of toUpdate) { updateStatus(entry.video_id, { state: "uploading" }); try { @@ -304,10 +333,7 @@ export function VerseMarkerUploadDialog({ isl_bible_id: entry.video_id, markers: entry.markers, }); - updateStatus(entry.video_id, { - state: "success", - updated: entry.markers.length, - }); + updateStatus(entry.video_id, { state: "success", wasCreate: false }); } catch (err: any) { updateStatus(entry.video_id, { state: "error", @@ -353,7 +379,21 @@ export function VerseMarkerUploadDialog({ return map; }, [unmatched]); - // Group entries by book for summary rows + const fullySkippedBooks = useMemo((): SkippedBookSummary[] => { + const matchedBooks = new Set(entries.map((e) => e.book.toLowerCase())); + const result: SkippedBookSummary[] = []; + csvCountByBook.forEach((count, bookCode) => { + if (!matchedBooks.has(bookCode.toLowerCase())) { + result.push({ + book: bookCode, + bookName: getBookName(bookCode), + csvCount: count, + }); + } + }); + return result.sort((a, b) => a.book.localeCompare(b.book)); + }, [entries, csvCountByBook]); + const bookSummaries = useMemo((): BookSummary[] => { const bookMap = new Map(); entries.forEach((e) => { @@ -365,23 +405,48 @@ export function VerseMarkerUploadDialog({ .sort(([a], [b]) => a.localeCompare(b)) .map(([bookCode, bookEntries]) => { const statuses = bookEntries.map((e) => statusMap[e.video_id]); + + // Count chapters (one per successful request), not individual marker rows + const chaptersAdded = statuses.filter( + (s) => s?.state === "success" && s.wasCreate === true, + ).length; + const chaptersUpdated = statuses.filter( + (s) => s?.state === "success" && s.wasCreate === false, + ).length; + + const skippedChapters = skippedByBook.get(bookCode) ?? 0; + const matchedChapters = bookEntries.length; + const totalCsvForBook = + csvCountByBook.get(bookCode) ?? matchedChapters + skippedChapters; + return { book: bookCode, - totalChapters: bookEntries.length, + bookName: getBookName(bookCode), + totalChapters: matchedChapters, uploadedChapters: statuses.filter((s) => s?.state === "success") .length, totalMarkers: bookEntries.reduce( (sum, e) => sum + e.markers.length, 0, ), - skippedChapters: skippedByBook.get(bookCode) ?? 0, + skippedChapters, + totalCsvForBook, hasError: statuses.some((s) => s?.state === "error"), isUploading: statuses.some( (s) => s?.state === "uploading" || s?.state === "pending", ), + chaptersAdded, + chaptersUpdated, }; }); - }, [entries, statusMap, skippedByBook]); + }, [entries, statusMap, skippedByBook, csvCountByBook]); + + // Single mode: always exactly 1 chapter + const singleStatus = videoId != null ? statusMap[videoId] : undefined; + const singleAdded = + singleStatus?.state === "success" && singleStatus.wasCreate === true ? 1 : 0; + const singleUpdated = + singleStatus?.state === "success" && singleStatus.wasCreate === false ? 1 : 0; const handleClose = () => { if (isUploading) return; @@ -389,6 +454,7 @@ export function VerseMarkerUploadDialog({ setStatusMap({}); setUploadState("idle"); setUnmatched([]); + setCsvCountByBook(new Map()); onOpenChange(false); }; @@ -403,11 +469,11 @@ export function VerseMarkerUploadDialog({ /> - + {mode === "single" - ? `Upload Verse Markers — ${book} ${chapter}` + ? `Upload Verse Markers — ${book ? getBookName(book) : book} ${chapter}` : "Upload Verse Markers"} @@ -441,18 +507,28 @@ export function VerseMarkerUploadDialog({ {/* ── Progress / results ── */} {entries.length > 0 && (
- {/* Legend — only shown in bulk mode */} + {/* Legend */} {mode === "bulk" && ( -
+
error - - ! + ! + chapters skipped — no matching video entry + + + + N added - warning (chapters skipped — video not available) + — new chapters + + + + N updated + + — existing chapters
)} @@ -462,31 +538,21 @@ export function VerseMarkerUploadDialog({ {successCount}/{totalChapters} chapter(s) uploaded {errorCount > 0 && ( - - · {errorCount} failed - + · {errorCount} failed )} - {/* Marker counts — single mode only */} + {/* Single mode summary badges */} {mode === "single" && uploadState === "done" && (
- {Object.values(statusMap).some((s) => s.created) && ( - - {Object.values(statusMap).reduce( - (sum, s) => sum + (s.created ?? 0), - 0, - )}{" "} - uploaded + {singleAdded > 0 && ( + + {singleAdded} added )} - {Object.values(statusMap).some((s) => s.updated) && ( - - {Object.values(statusMap).reduce( - (sum, s) => sum + (s.updated ?? 0), - 0, - )}{" "} - updated + {singleUpdated > 0 && ( + + {singleUpdated} updated )}
@@ -494,7 +560,7 @@ export function VerseMarkerUploadDialog({ {unmatched.length > 0 && mode === "bulk" && ( - {unmatched.length} file(s) skipped as entry not found + {unmatched.length} file(s) skipped — entry not found )}
@@ -507,41 +573,39 @@ export function VerseMarkerUploadDialog({ />
- {/* Table — per-book in bulk, per-chapter in single */} + {/* Table */}
{mode === "bulk" ? ( - - - - + + + + + + {/* Matched books */} {bookSummaries.map((summary) => ( - - + + ))} + + {/* Fully-skipped books (zero matches) */} + {fullySkippedBooks.map((skipped) => ( + + + + + + + + ))}
- Book - - Chapters - - Verse Markers - - Status - BookChaptersVerse MarkersAdded / UpdatedStatus
- {summary.book} +
+ {summary.bookName} - {summary.uploadedChapters}/{summary.totalChapters} + {summary.uploadedChapters}/{summary.totalCsvForBook} {summary.totalMarkers.toLocaleString()} + +
{summary.isUploading && ( @@ -552,8 +616,7 @@ export function VerseMarkerUploadDialog({ )} {!summary.isUploading && !summary.hasError && - summary.uploadedChapters === - summary.totalChapters && ( + summary.uploadedChapters > 0 && ( )} {summary.skippedChapters > 0 && ( @@ -566,8 +629,8 @@ export function VerseMarkerUploadDialog({

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

@@ -577,43 +640,77 @@ export function VerseMarkerUploadDialog({
+ {skipped.bookName} + + 0/{skipped.csvCount} + + + + + + ! + + + +

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

+
+
+
+
) : ( - // Single mode — keep original per-chapter row + // 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 ( - - + + + -
- Book - - Chapter - - Verse Markers - - Status - BookChapterVerse MarkersAdded / UpdatedStatus
- {entry.book} +
+ {getBookName(entry.book)} {entry.chapter}{entry.markers.length} - {entry.chapter} - - {entry.markers.length} + {status?.state === "pending" && ( @@ -655,3 +752,36 @@ export function VerseMarkerUploadDialog({ ); } + +// 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/pages/ISL.tsx b/frontend/src/pages/ISL.tsx index 423fd2df..9a6ba27e 100644 --- a/frontend/src/pages/ISL.tsx +++ b/frontend/src/pages/ISL.tsx @@ -8,9 +8,13 @@ import { 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 { @@ -40,6 +44,7 @@ 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 { @@ -65,6 +70,34 @@ interface ISLBibleDialogRow { "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, @@ -155,6 +188,118 @@ function VerseMarkerCell({ ); } +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); @@ -162,6 +307,7 @@ const ISLBibleAction = ({ resource }: { resource: Resource }) => { new Map(), ); const [isFetchingMarkerStatus, setIsFetchingMarkerStatus] = useState(false); + const queryClient = useQueryClient(); const reportMarkerStatus = useCallback( (videoId: number, hasData: boolean) => { @@ -184,33 +330,59 @@ const ISLBibleAction = ({ resource }: { resource: Resource }) => { ); const handleOpenBulkUpload = async () => { - // Find video IDs whose marker status isn't known yet - const unknownIds = verseMarkerRows - .map((r) => r.video_id) - .filter((id) => !markerStatusMap.has(id)); - setIsFetchingMarkerStatus(true); try { - if (unknownIds.length > 0) { - await Promise.all( - unknownIds.map(async (id) => { - try { - await API.get(`/isl-verse-markers`, { - params: { isl_bible_id: id }, - }); - reportMarkerStatus(id, true); - } catch { - reportMarkerStatus(id, false); - } - }), - ); - } + 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 []; @@ -262,6 +434,21 @@ const ISLBibleAction = ({ resource }: { resource: Resource }) => { 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[]; @@ -409,9 +596,10 @@ const ISLBibleAction = ({ resource }: { resource: Resource }) => { "Upload Verse Markers" )} + { }; const handleEditSubmit = async (formData: any) => { - console.log("formData", formData); if (!selectedResource) return; try { @@ -729,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={ From bb9eee57a59107ff8bd19c5bb6f7b62959deec03 Mon Sep 17 00:00:00 2001 From: KetanKBaboo Date: Tue, 26 May 2026 14:35:55 +0530 Subject: [PATCH 06/10] added an env variable to frontend docker --- docker/docker_frontend/Dockerfile | 3 +++ docker/docker_frontend/docker-compose.yml | 5 +++-- docker/docker_frontend/nginx/frontend.conf | 7 +++---- frontend/vite.config.ts | 12 ++++++------ 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/docker/docker_frontend/Dockerfile b/docker/docker_frontend/Dockerfile index 51ec3155..402ef540 100644 --- a/docker/docker_frontend/Dockerfile +++ b/docker/docker_frontend/Dockerfile @@ -1,14 +1,17 @@ +# ===== Build stage ===== FROM node:20-alpine AS builder # 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 WORKDIR /app diff --git a/docker/docker_frontend/docker-compose.yml b/docker/docker_frontend/docker-compose.yml index 3e062ba4..47db2506 100644 --- a/docker/docker_frontend/docker-compose.yml +++ b/docker/docker_frontend/docker-compose.yml @@ -1,5 +1,5 @@ version: '3.9' - + services: vachan-admin-frontend: build: @@ -10,13 +10,14 @@ services: 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: - "127.0.0.1:8081:80" restart: unless-stopped networks: - va-network - + networks: va-network: external: true \ No newline at end of file diff --git a/docker/docker_frontend/nginx/frontend.conf b/docker/docker_frontend/nginx/frontend.conf index 3632503d..07d0e360 100644 --- a/docker/docker_frontend/nginx/frontend.conf +++ b/docker/docker_frontend/nginx/frontend.conf @@ -1,12 +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 +} \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index f34dcc33..559c00a1 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,17 +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'], + allowedHosts: ["admin.vachanengine.org"], }, resolve: { alias: { "@": path.resolve(__dirname, "./src"), }, }, -}) \ No newline at end of file +}); From 44008cc6b7588e4828fc43e6e31cf0db0135b339 Mon Sep 17 00:00:00 2001 From: KetanKBaboo Date: Wed, 27 May 2026 10:43:22 +0530 Subject: [PATCH 07/10] fix bug issue of preview content not available in bible book selector --- frontend/src/components/BibleSelector.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/frontend/src/components/BibleSelector.tsx b/frontend/src/components/BibleSelector.tsx index a65531ef..356ac8d7 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 = ({