diff --git a/hosting/src/app/(protected)/content/article/[documentId]/page.tsx b/hosting/src/app/(protected)/content/article/[documentId]/page.tsx index fb450b86..4b580e00 100644 --- a/hosting/src/app/(protected)/content/article/[documentId]/page.tsx +++ b/hosting/src/app/(protected)/content/article/[documentId]/page.tsx @@ -1,4 +1,6 @@ "use client"; +import {Button} from "@/components/Button"; +import {Input} from "@/components/Form"; import Loader from "@/components/common/Loader"; import Notification from "@/components/common/Notification"; import PageHeader from "@/components/common/PageHeader"; @@ -18,8 +20,12 @@ export default function DocumentDetailsPage() { const {documentId} = useParams<{documentId: string}>() ?? {}; const {data: document, error: documentError} = useTanamDocument(documentId); const {update, error: writeError} = useCrudTanamDocument(); + + const [title, setTitle] = useState(""); const [readonlyMode] = useState(false); + const [updateTitleShown, setUpdateTitleShown] = useState(false); const [notification, setNotification] = useState(null); + if (!!document?.documentType && document?.documentType !== "article") { router.push(`/content/${document?.documentType}/${document?.id}`); return ; @@ -29,6 +35,30 @@ export default function DocumentDetailsPage() { setNotification(documentError || writeError); }, [documentError, writeError]); + useEffect(() => { + if (updateTitleShown) return; + + onDocumentTitleChange(title); + }, [updateTitleShown]); + + useEffect(() => { + if (document) { + setTitle(document.data.title as string); + } + + return () => setTitle(""); + }, [document]); + + async function onDocumentTitleChange(title: string) { + console.log("[onDocumentTitleChange]", title); + if (!document) { + return; + } + + document.data.title = title; + await update(document); + } + async function onDocumentContentChange(content: string) { console.log("[onDocumentContentChange]", content); if (!document) { @@ -41,13 +71,41 @@ export default function DocumentDetailsPage() { return ( <> - }> - {document ? : } - {notification && ( )} + }> + {document ? ( + <> +
+ {!updateTitleShown && } + + {updateTitleShown && ( + setTitle(e.target.value)} + /> + )} + + +
+ + ) : ( + + )} +
+ + {/* Start button to close the audio input modal */} + + {/* End button to close the audio input modal */} + + {/* Start button to save changes audio input */} + + {/* End button to save changes audio input */} + + ); + return ( <> }> @@ -86,22 +119,20 @@ export default function DocumentTypeDocumentsPage() { tabIndex={-1} > @@ -122,10 +153,11 @@ export default function DocumentTypeDocumentsPage() { {isDialogOpen && ( - setIsDialogOpen(false)} + disableOverlayClose={true} + onClose={resetAudioInput} + actions={modalActionAudioInput} title={"Tell your story"} > {status === ProcessingState.Ready ? ( @@ -149,7 +181,7 @@ export default function DocumentTypeDocumentsPage() { {status === ProcessingState.Finalizing &&

Finalizing...

} )} -
+ )} ) : ( diff --git a/hosting/src/components/Button.tsx b/hosting/src/components/Button.tsx index cfcfcef8..0819bf02 100644 --- a/hosting/src/components/Button.tsx +++ b/hosting/src/components/Button.tsx @@ -3,7 +3,7 @@ import React, {useState} from "react"; interface ButtonProps { title: string; onClick: () => Promise | void; - style?: "normal" | "rounded" | "outline" | "icon"; + style?: "normal" | "rounded" | "outline" | "outline-rounded" | "icon" | "icon-rounded"; color?: "primary" | "meta-3" | "black"; children?: React.ReactNode; } @@ -20,7 +20,7 @@ export function Button({title, onClick, style = "rounded", color = "primary", ch } }; - const styles = [ + let styles = [ "inline-flex", "items-center", "justify-center", @@ -53,10 +53,18 @@ export function Button({title, onClick, style = "rounded", color = "primary", ch styles.push("rounded-md"); break; case "outline": + styles = styles.filter((style) => style !== "text-white"); styles.push(`border`, `border-${color}`, `text-${color}`, `bg-transparent`); break; + case "outline-rounded": + styles = styles.filter((style) => style !== "text-white"); + styles.push(`border`, `border-${color}`, `text-${color}`, `bg-transparent`, "rounded-md"); + break; case "icon": break; + case "icon-rounded": + styles.push("rounded-md"); + break; default: break; } @@ -64,7 +72,7 @@ export function Button({title, onClick, style = "rounded", color = "primary", ch return ( ); } diff --git a/hosting/src/components/Dialog.tsx b/hosting/src/components/Dialog.tsx deleted file mode 100644 index 0ed4001b..00000000 --- a/hosting/src/components/Dialog.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import {ReactNode, useEffect, useState} from "react"; - -interface DialogProps { - isOpen: boolean; - isLoading?: boolean; - title: string; - children: ReactNode; - onClose?: () => void; - onSubmit?: () => void; - onLoadingChange?: (isLoading: boolean) => void; -} - -export default function Dialog(props: DialogProps) { - const {isOpen, title, children, isLoading, onClose, onSubmit, onLoadingChange} = props; - const [loading, setLoading] = useState(false); - - /** - * Effect to trigger onLoadingChange whenever the loading prop changes. - */ - useEffect(() => { - if (onLoadingChange) { - onLoadingChange(loading); - } - }, [loading, onLoadingChange]); - - /** - * Effect to trigger setLoading. - */ - useEffect(() => { - setLoading(isLoading ?? false); - - return () => setLoading(false); - }, [isLoading]); - - if (!isOpen) return null; - - return ( -
-
-

{title}

-
{children}
-
- {onClose && ( - - )} - - {onSubmit && ( - - )} -
-
-
- ); -} diff --git a/hosting/src/components/Form/DatePicker.tsx b/hosting/src/components/Form/DatePicker.tsx index 30ee5570..328b83d2 100644 --- a/hosting/src/components/Form/DatePicker.tsx +++ b/hosting/src/components/Form/DatePicker.tsx @@ -1,12 +1,20 @@ "use client"; -import React, {useEffect} from "react"; import flatpickr from "flatpickr"; -import {BaseOptions} from "flatpickr/dist/types/options"; +import {BaseOptions, DateLimit, DateOption} from "flatpickr/dist/types/options"; +import {useEffect, useRef} from "react"; interface DatePickerProps { label: string; placeholder: string; - defaultValue?: Date; + enableTime?: boolean; + dateFormat?: string; + defaultValue?: Date | null; + disabledDates?: DateLimit[]; + enabledDates?: DateLimit[]; + maxDate?: DateOption; + maxTime?: DateOption; + minDate?: DateOption; + minTime?: DateOption; onChange?: (date: Date) => void; styleType?: "default" | "static" | "withArrows"; disabled?: boolean; @@ -17,18 +25,36 @@ interface DatePickerProps { * @param {DatePickerProps} props - The properties for the date picker component. * @return {JSX.Element} The rendered date picker component. */ -export function DatePicker({ - label, - placeholder, - defaultValue, - onChange, - styleType = "default", - disabled = false, -}: DatePickerProps) { +export function DatePicker(props: DatePickerProps) { + const { + label, + placeholder, + defaultValue, + onChange, + styleType = "default", + dateFormat = "M j, Y", + disabled = false, + enableTime = false, + maxDate, + maxTime, + minDate, + minTime, + } = props; + + const inputRef = useRef(null); + const pickerRef = useRef(null); + const config = { mode: "single", - dateFormat: "M j, Y", + dateFormat, + enableTime, + maxDate, + maxTime, + minDate, + minTime, defaultDate: defaultValue, + // Prevents Flatpickr from closing when selecting a date/time + closeOnSelect: false, onChange: (selectedDates: Date[]) => { if (onChange && selectedDates.length > 0) { onChange(selectedDates[0]); @@ -39,9 +65,13 @@ export function DatePicker({ function getFlatPickr() { switch (styleType) { case "static": - return flatpickr(".form-datepicker", {...config, static: true, monthSelectorType: "static"}); + return flatpickr(inputRef.current!, { + ...config, + static: true, + monthSelectorType: "static", + }); case "withArrows": - return flatpickr(".form-datepicker", { + return flatpickr(inputRef.current!, { ...config, static: true, monthSelectorType: "static", @@ -49,17 +79,16 @@ export function DatePicker({ nextArrow: ``, }); default: - return flatpickr(".form-datepicker", config); + return flatpickr(inputRef.current!, config); } } useEffect(() => { - const fp = getFlatPickr(); + pickerRef.current = getFlatPickr(); + return () => { - if (Array.isArray(fp)) { - fp.forEach((el) => el.destroy()); - } else { - fp.destroy(); + if (pickerRef.current) { + pickerRef.current.destroy(); } }; }, [onChange, styleType, defaultValue]); @@ -69,6 +98,7 @@ export function DatePicker({
+
{/* Start background overlay */}
() ?? {}; const {data: document, changeStatus} = useTanamDocument(documentId); + const maxDate = new Date(); + maxDate.setDate(maxDate.getDate() + 30); + + const [isLoading, setIsLoading] = useState(false); + const [isDialogOpen, setIsDialogOpen] = useState(false); + const [isDropdownPublishOpen, setIsDropdownPublishOpen] = useState(false); + const [publishedAt, setPublishedAt] = useState(null); + + const publishDate = useMemo(() => { + const date = document?.publishedAt?.toDate() ?? null; + + if (!date) return date; + + return ( + + Scheduled for {formatDate(date, "MMMM DD, YYYY")} at{" "} + {formatDate(date, "hh:mm A")} + + ); + }, [document]); + + function pruneState() { + setIsLoading(false); + setIsDialogOpen(false); + setIsDropdownPublishOpen(false); + setPublishedAt(null); + } + async function onTogglePublishDocument() { if (!document) { return; } - // Toggle the status of the document - return changeStatus( - document.status === TanamPublishStatus.Unpublished - ? TanamPublishStatus.Unpublished - : TanamPublishStatus.Published, - ); + if (isDialogOpen && !publishedAt) { + window.alert("Please fill published at"); + return; + } + + setIsDropdownPublishOpen(false); + + try { + await changeStatus( + document.status === TanamPublishStatus.Published + ? TanamPublishStatus.Unpublished + : publishedAt + ? TanamPublishStatus.Scheduled + : TanamPublishStatus.Published, + publishedAt ? publishedAt : null, + ); + } catch (error) { + console.error("Error :: ", error); + } finally { + pruneState(); + } } + /** + * Modal actions for saving or canceling schedule publish. + * @constant + * @type {JSX.Element} + */ + const modalActionSchedulePublish = ( +
+ {/* Start button to close the schedule publish modal */} + + {/* End button to close the schedule publish modal */} + + {/* Start button to save changes schedule publish */} + + {/* End button to save changes schedule publish */} +
+ ); + return ( documentId && ( <> - + +
+
+
+ + {isDialogOpen && ( + + + + )} ) ); diff --git a/hosting/src/hooks/useTanamDocuments.tsx b/hosting/src/hooks/useTanamDocuments.tsx index 22eb5f6a..a52bacfb 100644 --- a/hosting/src/hooks/useTanamDocuments.tsx +++ b/hosting/src/hooks/useTanamDocuments.tsx @@ -2,7 +2,17 @@ import {TanamDocumentClient} from "@/models/TanamDocumentClient"; import {UserNotification} from "@/models/UserNotification"; import {firestore} from "@/plugins/firebase"; -import {collection, doc, onSnapshot, query, serverTimestamp, setDoc, updateDoc, where} from "firebase/firestore"; +import { + collection, + doc, + onSnapshot, + query, + serverTimestamp, + setDoc, + Timestamp, + updateDoc, + where, +} from "firebase/firestore"; import {useEffect, useState} from "react"; import {TanamPublishStatus} from "tanam-shared/definitions/TanamPublishStatus"; @@ -55,7 +65,7 @@ export function useTanamDocuments(documentTypeId?: string): UseTanamDocumentsRes interface UseTanamDocumentResult { data: TanamDocumentClient | null; - changeStatus: (status: TanamPublishStatus) => Promise; + changeStatus: (status: TanamPublishStatus, publishedAt?: Date | null) => Promise; error: UserNotification | null; isLoading: boolean; } @@ -98,9 +108,10 @@ export function useTanamDocument(documentId?: string): UseTanamDocumentResult { * Method to publish or unpublish a document * * @param {TanamPublishStatus} status Flag to publish or unpublish the document + * @param {Date | null} publishedAt Time for publish document * @return {Promise} Promise */ - async function changeStatus(status: TanamPublishStatus): Promise { + async function changeStatus(status: TanamPublishStatus, publishedAt?: Date | null): Promise { if (!documentId) { setError(new UserNotification("error", "Missing parameter", "Document id parameter is missing")); return; @@ -109,7 +120,12 @@ export function useTanamDocument(documentId?: string): UseTanamDocumentResult { try { const typeRef = doc(firestore, "tanam-documents", documentId); await updateDoc(typeRef, { - publishedAt: status === TanamPublishStatus.Published ? serverTimestamp() : null, + publishedAt: + status === TanamPublishStatus.Published || status === TanamPublishStatus.Scheduled + ? publishedAt + ? Timestamp.fromDate(publishedAt) + : serverTimestamp() + : null, status, } as Partial); } catch (err) { diff --git a/hosting/src/utils/date.ts b/hosting/src/utils/date.ts new file mode 100644 index 00000000..b1ca2d8e --- /dev/null +++ b/hosting/src/utils/date.ts @@ -0,0 +1,57 @@ +/** + * Format date based on a given format string + * @param {Date} date - The date object to format + * @param {string} format - The format string specifying the desired date format + * @return {string} The formatted date string based on the provided format + */ +export function formatDate(date: Date, format: string): string { + const map: {[key: string]: string} = { + YYYY: date.getFullYear().toString(), + // Month as two digits + MM: String(date.getMonth() + 1).padStart(2, "0"), + // Day of the month as two digits + DD: String(date.getDate()).padStart(2, "0"), + // Hour (12-hour clock) + hh: String(date.getHours() % 12 || 12).padStart(2, "0"), + // Hour (24-hour clock) + HH: String(date.getHours()).padStart(2, "0"), + // Minutes as two digits + mm: String(date.getMinutes()).padStart(2, "0"), + // Seconds as two digits + ss: String(date.getSeconds()).padStart(2, "0"), + // AM/PM marker + A: date.getHours() >= 12 ? "PM" : "AM", + // am/pm marker + a: date.getHours() >= 12 ? "pm" : "am", + // Full month name + MMMM: date.toLocaleString("default", {month: "long"}), + // Short month name + MMM: date.toLocaleString("default", {month: "short"}), + // Full weekday name + dddd: date.toLocaleString("default", {weekday: "long"}), + // Short weekday name + ddd: date.toLocaleString("default", {weekday: "short"}), + }; + + // Regex for AM/PM markers + const amPmRegex = /A|a/g; + // Regex for other tokens + const otherTokensRegex = /MMMM|MMM|dddd|ddd|YYYY|MM|DD|hh|HH|mm|ss/g; + + // First replace the AM/PM markers + let result = format.replace(amPmRegex, (matched) => map[matched] || matched); + + // Then replace the other tokens + result = result.replace(otherTokensRegex, (matched) => map[matched] || matched); + + return result; +} + +/** + * Get the current date formatted based on a given format string + * @param {string} format - The format string specifying the desired date format + * @return {string} The current date formatted based on the provided format + */ +export function getCurrentDateFormatted(format: string): string { + return formatDate(new Date(), format); +} diff --git a/shared/src/models/TanamDocument.ts b/shared/src/models/TanamDocument.ts index 7e083bae..877d35f0 100644 --- a/shared/src/models/TanamDocument.ts +++ b/shared/src/models/TanamDocument.ts @@ -23,8 +23,6 @@ export abstract class TanamDocument { // The status of the document is determined by the publishedAt field this.status = json.publishedAt ? TanamPublishStatus.Published : TanamPublishStatus.Unpublished; - - this.status = json.status || json.publishedAt ? TanamPublishStatus.Published : TanamPublishStatus.Unpublished; this.revision = json.revision ?? 0; this.createdAt = json.createdAt; this.updatedAt = json.updatedAt;