diff --git a/src/assets/locales/en.json b/src/assets/locales/en.json index 86c6d2ea..32845ede 100644 --- a/src/assets/locales/en.json +++ b/src/assets/locales/en.json @@ -1003,7 +1003,8 @@ "types": { "intro": "Intro", "recap": "Recap", - "credits": "Credits" + "credits": "Credits", + "preview": "Preview" }, "startTimeLabel": "Start (s)", "endTimeLabel": "End (s)", @@ -1011,12 +1012,14 @@ "start": { "intro": "2:30 or 150 (leave empty for start at beginning)", "recap": "2:30 or 150 (leave empty for start at beginning)", - "credits": "2:30 or 150 (required)" + "credits": "2:30 or 150 (required)", + "preview": "2:30 or 150 (required)" }, "end": { "intro": "3:30 or 210 (required)", "recap": "3:30 or 210 (required)", - "credits": "3:30 or 210 (leave empty for end of media)" + "credits": "3:30 or 210 (leave empty for end of media)", + "preview": "3:30 or 210 (leave empty for end of media)" } }, "whenToTitle": "Timestamps guide:", @@ -1029,7 +1032,35 @@ "durationLabel": "Duration:", "durationDesc": "Min 5s if end provided", "excludeLabel": "Exclude:", - "excludeDesc": "Post-credits scenes" + "excludeDesc": "Post-credits scenes", + "intro": { + "whenToTitle": "Intro timestamps guide:", + "startDesc": "Optional - leave empty if the intro starts at the beginning of the episode.", + "endDesc": "Required - when the intro ends (usually when the title card or main episode begins).", + "durationDesc": "Most intros are 30–90 seconds. Minimum 5s if both start and end are set.", + "excludeDesc": "Do not include cold opens or pre-intro scenes; only the main title sequence." + }, + "recap": { + "whenToTitle": "Recap timestamps guide:", + "startDesc": "Optional - leave empty if the recap starts at the beginning of the episode.", + "endDesc": "Required - when the recap ends and the new content begins.", + "durationDesc": "Recaps vary in length. Minimum 5s if both start and end are set.", + "excludeDesc": "Only the \"previously on\" or recap segment; do not include the intro or new scenes." + }, + "credits": { + "whenToTitle": "Credits timestamps guide:", + "startDesc": "Required - when the closing credits begin (e.g. when the first credit text appears).", + "endDesc": "Optional - leave empty if credits run to the end of the file.", + "durationDesc": "Minimum 5s if end is provided. Can extend to end of media.", + "excludeDesc": "Exclude mid-credits or post-credits scenes; only the main credit roll." + }, + "preview": { + "whenToTitle": "Preview timestamps guide:", + "startDesc": "Required - when the preview starts (e.g. when the preview text appears).", + "endDesc": "Optional - leave empty if the preview ends at the end of the media.", + "durationDesc": "Preview varies in length. Minimum 5s.", + "excludeDesc": "Preview is usually 15-90 seconds at the end of the episode previewing the next episode." + } }, "cancel": "Cancel", "submit": "Submit", diff --git a/src/components/overlays/Modal.tsx b/src/components/overlays/Modal.tsx index fea9fe41..b8abfc12 100644 --- a/src/components/overlays/Modal.tsx +++ b/src/components/overlays/Modal.tsx @@ -26,8 +26,13 @@ export function ModalCard(props: { className?: ReactNode; }) { return ( -
-
+
+
{props.children}
@@ -50,7 +55,7 @@ export function Modal(props: { id: string; children?: ReactNode }) { -
+
{props.children}
diff --git a/src/components/player/TIDBSubmissionForm.tsx b/src/components/player/TIDBSubmissionForm.tsx index 02e6f629..a1a19b40 100644 --- a/src/components/player/TIDBSubmissionForm.tsx +++ b/src/components/player/TIDBSubmissionForm.tsx @@ -1,8 +1,8 @@ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/buttons/Button"; -import { Dropdown } from "@/components/form/Dropdown"; +import { Icon, Icons } from "@/components/Icon"; import { Modal, useModal } from "@/components/overlays/Modal"; import { SegmentData } from "@/components/player/hooks/useSkipTime"; import { AuthInputBox } from "@/components/text-inputs/AuthInputBox"; @@ -11,7 +11,7 @@ import { usePlayerStore } from "@/stores/player/store"; import { usePreferencesStore } from "@/stores/preferences"; import { submitIntro } from "@/utils/tidb"; -type SegmentType = "intro" | "recap" | "credits"; +type SegmentType = "intro" | "recap" | "credits" | "preview"; // Helper function to parse time format (hh:mm:ss, mm:ss, or seconds) // Returns null if empty string, NaN if invalid, or number if valid @@ -19,46 +19,43 @@ function parseTimeToSeconds(timeStr: string): number | null { if (!timeStr.trim()) return null; // Check if it's in hh:mm:ss format - const hhmmssMatch = timeStr.match(/^(\d{1,2}):([0-5]?\d):([0-5]?\d)$/); + const hhmmssMatch = timeStr.match(/^($\d{1,2}$):($[0-5]?\d$):($[0-5]?\d$)$/); if (hhmmssMatch) { const hours = parseInt(hhmmssMatch[1], 10); const minutes = parseInt(hhmmssMatch[2], 10); const seconds = parseInt(hhmmssMatch[3], 10); - // Validate reasonable bounds (max 99 hours, minutes/seconds 0-59) if (hours > 99 || minutes > 59 || seconds > 59) { - return NaN; // Invalid format + return NaN; } return hours * 3600 + minutes * 60 + seconds; } // Check if it's in mm:ss format - const mmssMatch = timeStr.match(/^(\d{1,3}):([0-5]?\d)$/); + const mmssMatch = timeStr.match(/^($\d{1,3}$):($[0-5]?\d$)$/); if (mmssMatch) { const minutes = parseInt(mmssMatch[1], 10); const seconds = parseInt(mmssMatch[2], 10); - // Validate reasonable bounds (max 999 minutes, seconds 0-59) if (minutes > 999 || seconds > 59) { - return NaN; // Invalid format + return NaN; } return minutes * 60 + seconds; } - // Otherwise, treat as plain seconds (but only if no colons in input) if (timeStr.includes(":")) { - return NaN; // Invalid time format - has colons but didn't match time patterns + return NaN; } const parsed = parseFloat(timeStr); if ( Number.isNaN(parsed) || !Number.isFinite(parsed) || parsed < 0 || - parsed > 20000000 + parsed > 21600000 ) { - return NaN; // Invalid input + return NaN; } return parsed; @@ -79,6 +76,7 @@ export function TIDBSubmissionForm({ const meta = usePlayerStore((s) => s.meta); const tidbKey = usePreferencesStore((s) => s.tidbKey); const submissionModal = useModal("tidb-submission"); + const formRef = useRef(null); const [isSubmitting, setIsSubmitting] = useState(false); const [formData, setFormData] = useState<{ segment: SegmentType; @@ -109,7 +107,6 @@ export function TIDBSubmissionForm({ const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); - // Check if form is valid if (!formData.segment) { // eslint-disable-next-line no-alert alert(t("player.skipTime.feedback.modal.error.segment")); @@ -131,7 +128,7 @@ export function TIDBSubmissionForm({ const startSeconds = parseTimeToSeconds(formData.start); const endSeconds = parseTimeToSeconds(formData.end); - // Basic validation + // Validate required fields based on segment type if (formData.segment === "intro" || formData.segment === "recap") { if (endSeconds === null || Number.isNaN(endSeconds)) { // eslint-disable-next-line no-alert @@ -139,7 +136,10 @@ export function TIDBSubmissionForm({ setIsSubmitting(false); return; } - } else if (formData.segment === "credits") { + } else if ( + formData.segment === "credits" || + formData.segment === "preview" + ) { if (startSeconds === null || Number.isNaN(startSeconds)) { // eslint-disable-next-line no-alert alert(t("player.skipTime.feedback.modal.error.startTime")); @@ -148,31 +148,38 @@ export function TIDBSubmissionForm({ } } - // Prepare submission data - const submissionData: any = { + const submissionData: { + tmdb_id: number; + type: "movie" | "tv"; + segment: SegmentType; + season?: number; + episode?: number; + start_sec?: number | null; + end_sec?: number | null; + } = { tmdb_id: parseInt(meta.tmdbId.toString(), 10), type: meta.type === "show" ? "tv" : "movie", segment: formData.segment, }; - // Add season/episode for TV shows if (meta.type === "show" && meta.season && meta.episode) { submissionData.season = meta.season.number; submissionData.episode = meta.episode.number; } - // Set start_sec and end_sec based on segment type if (formData.segment === "intro" || formData.segment === "recap") { submissionData.start_sec = startSeconds !== null ? startSeconds : null; submissionData.end_sec = endSeconds!; - } else if (formData.segment === "credits") { + } else if ( + formData.segment === "credits" || + formData.segment === "preview" + ) { submissionData.start_sec = startSeconds!; submissionData.end_sec = endSeconds !== null ? endSeconds : null; } await submitIntro(submissionData, tidbKey); - // Success submissionModal.hide(); if (onSuccess) onSuccess(); } catch (error) { @@ -186,175 +193,200 @@ export function TIDBSubmissionForm({ } }; + const handleClose = () => { + submissionModal.hide(); + if (onCancel) onCancel(); + }; + return ( -
-
- - {t("player.skipTime.feedback.modal.title")} - - +
+
+
+ + + {t("player.skipTime.feedback.modal.title")} + +
+ {t("player.skipTime.feedback.modal.description")} -
- {/* Section: Segment timestamps */} -
-