Commit 882e7be7 authored by Phạm Quang Bảo's avatar Phạm Quang Bảo

feat: draft news

parent 621c31c2
...@@ -10,6 +10,7 @@ import type { SortFieldParameter } from './sortFieldParameter'; ...@@ -10,6 +10,7 @@ import type { SortFieldParameter } from './sortFieldParameter';
import type { SortOrderParameter } from './sortOrderParameter'; import type { SortOrderParameter } from './sortOrderParameter';
import type { PageParameter } from './pageParameter'; import type { PageParameter } from './pageParameter';
import type { PageSizeParameter } from './pageSizeParameter'; import type { PageSizeParameter } from './pageSizeParameter';
import type { PriorityFeaturedParameter } from './priorityFeaturedParameter';
export type GetApiV10PostParams = { export type GetApiV10PostParams = {
/** /**
...@@ -34,4 +35,8 @@ page?: PageParameter; ...@@ -34,4 +35,8 @@ page?: PageParameter;
* @minimum 1 * @minimum 1
*/ */
pageSize?: PageSizeParameter; pageSize?: PageSizeParameter;
/**
* If true, featured posts are sorted before non-featured posts. Set to false for plain sorting (e.g. admin listing). Defaults to true.
*/
priorityFeatured?: PriorityFeaturedParameter;
}; };
...@@ -332,6 +332,7 @@ export * from './postMutateEventDates'; ...@@ -332,6 +332,7 @@ export * from './postMutateEventDates';
export * from './postTag'; export * from './postTag';
export * from './postTagBulkCreate'; export * from './postTagBulkCreate';
export * from './postTagMutate'; export * from './postTagMutate';
export * from './priorityFeaturedParameter';
export * from './putApiV10AdvertisementId200'; export * from './putApiV10AdvertisementId200';
export * from './putApiV10AdvertisementId200AllOf'; export * from './putApiV10AdvertisementId200AllOf';
export * from './putApiV10BannerId200'; export * from './putApiV10BannerId200';
......
/**
* Generated by orval v7.17.0 🍺
* Do not edit manually.
* VietProDev CMS Backend API
* Generated API documentation
* OpenAPI spec version: 1.0.0
*/
/**
* If true, featured posts are sorted before non-featured posts. Set to false for plain sorting (e.g. admin listing). Defaults to true.
*/
export type PriorityFeaturedParameter = boolean;
...@@ -2,8 +2,10 @@ ...@@ -2,8 +2,10 @@
import { import {
type FormEvent, type FormEvent,
useCallback,
useEffect, useEffect,
useMemo, useMemo,
useRef,
useState, useState,
} from "react"; } from "react";
import dayjs from "dayjs"; import dayjs from "dayjs";
...@@ -70,7 +72,9 @@ import { ...@@ -70,7 +72,9 @@ import {
selectTriggerClassName, selectTriggerClassName,
SEARCH_TAG_VISIBLE_LIMIT, SEARCH_TAG_VISIBLE_LIMIT,
} from "./constants"; } from "./constants";
import { DateTimePicker } from "@/components/shared/date-time-picker";
import { EventDatesDatePicker } from "./event-dates-date-picker"; import { EventDatesDatePicker } from "./event-dates-date-picker";
import { DraftRestoreDialog } from "./draft-restore-dialog";
import { FormSection } from "./form-section"; import { FormSection } from "./form-section";
import { HeaderCategoryMultiPicker } from "./header-category-multi-picker"; import { HeaderCategoryMultiPicker } from "./header-category-multi-picker";
import { HeaderCategorySinglePicker } from "./header-category-single-picker"; import { HeaderCategorySinglePicker } from "./header-category-single-picker";
...@@ -81,6 +85,7 @@ import { ...@@ -81,6 +85,7 @@ import {
isUuid, isUuid,
toImageRef, toImageRef,
} from "./utils"; } from "./utils";
import useNewsDraftStore, { type NewsDraftData } from "@/store/useNewsDraftStore";
export function AdminNewsFormContent() { export function AdminNewsFormContent() {
const params = useParams(); const params = useParams();
...@@ -102,6 +107,15 @@ export function AdminNewsFormContent() { ...@@ -102,6 +107,15 @@ export function AdminNewsFormContent() {
const [isMissingPost, setIsMissingPost] = useState(false); const [isMissingPost, setIsMissingPost] = useState(false);
const [useEventDates, setUseEventDates] = useState(false); const [useEventDates, setUseEventDates] = useState(false);
// Draft auto-save (create mode only)
const draftHasHydrated = useNewsDraftStore((s) => s._hasHydrated);
const getDraftIfFresh = useNewsDraftStore((s) => s.getDraftIfFresh);
const saveDraft = useNewsDraftStore((s) => s.saveDraft);
const clearDraft = useNewsDraftStore((s) => s.clearDraft);
const [showDraftDialog, setShowDraftDialog] = useState(false);
const [pendingDraft, setPendingDraft] = useState<NewsDraftData | null>(null);
const draftRestoredRef = useRef(false);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
...@@ -276,6 +290,43 @@ export function AdminNewsFormContent() { ...@@ -276,6 +290,43 @@ export function AdminNewsFormContent() {
}; };
}, [isCreate, newsId]); }, [isCreate, newsId]);
// Check draft sau khi load xong + store hydrated — chỉ hỏi 1 lần
useEffect(() => {
if (!isCreate || isLoadingInitialData || !draftHasHydrated || draftRestoredRef.current) return;
draftRestoredRef.current = true;
const freshDraft = getDraftIfFresh();
if (freshDraft) {
setPendingDraft(freshDraft);
setShowDraftDialog(true);
}
}, [isCreate, isLoadingInitialData, draftHasHydrated, getDraftIfFresh]);
// Auto-save draft (debounce 800ms) — chỉ create mode, bỏ qua khi đang mở dialog
useEffect(() => {
if (!isCreate || !form || showDraftDialog) return;
const timer = setTimeout(() => {
saveDraft({ form, useEventDates });
}, 800);
return () => clearTimeout(timer);
}, [form, useEventDates, isCreate, showDraftDialog, saveDraft]);
const handleRestoreDraft = useCallback(() => {
if (!pendingDraft?.form) return;
// Deep clone để tránh tham chiếu chung giữa store và state
const restored = JSON.parse(JSON.stringify(pendingDraft.form)) as AdminNewsFormValues;
setForm(restored);
setUseEventDates(pendingDraft.useEventDates ?? false);
setShowDraftDialog(false);
setPendingDraft(null);
toast.success("Đã khôi phục bản nháp");
}, [pendingDraft]);
const handleDiscardDraft = useCallback(() => {
clearDraft();
setShowDraftDialog(false);
setPendingDraft(null);
}, [clearDraft]);
const headerOptions = useMemo(() => { const headerOptions = useMemo(() => {
return flattenHeaderTree(headerTree); return flattenHeaderTree(headerTree);
}, [headerTree]); }, [headerTree]);
...@@ -540,6 +591,9 @@ export function AdminNewsFormContent() { ...@@ -540,6 +591,9 @@ export function AdminNewsFormContent() {
} }
toast.success(isCreate ? "Đã tạo bài viết" : "Đã cập nhật bài viết"); toast.success(isCreate ? "Đã tạo bài viết" : "Đã cập nhật bài viết");
if (isCreate) {
clearDraft();
}
router.push(backPath); router.push(backPath);
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "Không thể lưu bài viết"); toast.error(error instanceof Error ? error.message : "Không thể lưu bài viết");
...@@ -727,25 +781,17 @@ export function AdminNewsFormContent() { ...@@ -727,25 +781,17 @@ export function AdminNewsFormContent() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div> <div>
<Label className="mb-1.5 block text-gray-700">Ngày xuất bản</Label> <Label className="mb-1.5 block text-gray-700">Ngày xuất bản</Label>
<Input <DateTimePicker
type="datetime-local"
value={form.published_at} value={form.published_at}
onChange={(event) => onChange={(value) => handleField("published_at", value)}
handleField("published_at", event.target.value)
}
className={fieldClassName}
/> />
</div> </div>
<div> <div>
<Label className="mb-1.5 block text-gray-700">Ngày hết hạn</Label> <Label className="mb-1.5 block text-gray-700">Ngày hết hạn</Label>
<Input <DateTimePicker
type="datetime-local"
value={form.expired_at} value={form.expired_at}
onChange={(event) => onChange={(value) => handleField("expired_at", value)}
handleField("expired_at", event.target.value)
}
className={fieldClassName}
/> />
</div> </div>
</div> </div>
...@@ -861,7 +907,7 @@ export function AdminNewsFormContent() { ...@@ -861,7 +907,7 @@ export function AdminNewsFormContent() {
</> </>
) : ( ) : (
<p className="rounded-lg border border-dashed border-[#063e8e]/20 bg-white px-3 py-2 text-sm text-gray-700"> <p className="rounded-lg border border-dashed border-[#063e8e]/20 bg-white px-3 py-2 text-sm text-gray-700">
{"Ch\u01b0a c\u00f3 tag t\u00ecm ki\u1ebfm n\u00e0o. Vui l\u00f2ng t\u1ea1o tag trong m\u1ee5c qu\u1ea3n l\u00fd tag tr\u01b0\u1edbc khi g\u00e1n cho b\u00e0i vi\u1ebft."} Chưa có tag tìm kiếm nào. Vui lòng tạo tag trong mục quản lý tag trước khi gán cho bài viết.
</p> </p>
)} )}
</div> </div>
...@@ -932,21 +978,17 @@ export function AdminNewsFormContent() { ...@@ -932,21 +978,17 @@ export function AdminNewsFormContent() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<div> <div>
<Label className="mb-1.5 block text-gray-700">Ngày bắt đầu</Label> <Label className="mb-1.5 block text-gray-700">Ngày bắt đầu</Label>
<Input <DateTimePicker
type="datetime-local"
value={form.started_at} value={form.started_at}
onChange={(event) => handleField("started_at", event.target.value)} onChange={(value) => handleField("started_at", value)}
className={fieldClassName}
/> />
</div> </div>
<div> <div>
<Label className="mb-1.5 block text-gray-700">Ngày kết thúc</Label> <Label className="mb-1.5 block text-gray-700">Ngày kết thúc</Label>
<Input <DateTimePicker
type="datetime-local"
value={form.ended_at} value={form.ended_at}
onChange={(event) => handleField("ended_at", event.target.value)} onChange={(value) => handleField("ended_at", value)}
className={fieldClassName}
/> />
</div> </div>
...@@ -954,13 +996,9 @@ export function AdminNewsFormContent() { ...@@ -954,13 +996,9 @@ export function AdminNewsFormContent() {
<Label className="mb-1.5 block text-gray-700"> <Label className="mb-1.5 block text-gray-700">
Hạn đăng ký Hạn đăng ký
</Label> </Label>
<Input <DateTimePicker
type="datetime-local"
value={form.registration_deadline} value={form.registration_deadline}
onChange={(event) => onChange={(value) => handleField("registration_deadline", value)}
handleField("registration_deadline", event.target.value)
}
className={fieldClassName}
/> />
</div> </div>
...@@ -1044,6 +1082,13 @@ export function AdminNewsFormContent() { ...@@ -1044,6 +1082,13 @@ export function AdminNewsFormContent() {
<PostHistoryViewer postId={newsId} /> <PostHistoryViewer postId={newsId} />
</PermissionGate> </PermissionGate>
)} )}
<DraftRestoreDialog
open={showDraftDialog}
draft={pendingDraft}
onRestore={handleRestoreDraft}
onDiscard={handleDiscardDraft}
/>
</div> </div>
); );
} }
"use client";
import * as React from "react";
import dayjs from "dayjs";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import type { NewsDraftData } from "@/store/useNewsDraftStore";
export interface DraftRestoreDialogProps {
open: boolean;
draft: NewsDraftData | null;
onRestore: () => void;
onDiscard: () => void;
}
export function DraftRestoreDialog({
open,
draft,
onRestore,
onDiscard,
}: DraftRestoreDialogProps) {
const savedLabel = draft?.savedAt
? dayjs(draft.savedAt).format("DD/MM/YYYY HH:mm")
: "";
const titlePreview = draft?.form?.title?.trim() || "(chưa nhập tiêu đề)";
return (
<Dialog open={open}>
<DialogContent className="rounded-3xl border border-[#063e8e]/15 bg-white p-6 shadow-xl">
<DialogHeader>
<DialogTitle className="text-xl font-semibold text-gray-900">
Khôi phục bản nháp?
</DialogTitle>
<DialogDescription className="text-sm leading-6 text-gray-700">
Phát hiện bản nháp chưa hoàn tất{" "}
{savedLabel ? `(lưu lúc ${savedLabel})` : ""}.
<br />
Tiêu đề: <strong className="text-gray-900">{titlePreview}</strong>
<br />
Bạn có muốn khôi phục dữ liệu đã nhập không?
</DialogDescription>
</DialogHeader>
<DialogFooter className="mt-2 gap-2">
<Button
type="button"
variant="outline"
className="border-[#063e8e]/15 bg-white text-gray-700 hover:bg-gray-50 hover:text-gray-900"
onClick={onDiscard}
>
Không, tạo mới
</Button>
<Button
type="button"
className="bg-[#063e8e] text-white hover:bg-[#063e8e]/90"
onClick={onRestore}
>
Có, khôi phục
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
This diff is collapsed.
"use client";
import * as React from "react";
import dayjs from "dayjs";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import { TimePicker } from "@/components/shared/time-picker";
function splitDateTime(value: string): { date: string; time: string } {
if (!value) return { date: "", time: "" };
// Hỗ trợ cả "T" và space làm separator (YYYY-MM-DDTHH:mm hoặc YYYY-MM-DD HH:mm:ss)
const separator = value.includes("T") ? "T" : " ";
const [date, time] = value.split(separator);
if (!time) return { date: date ?? "", time: "" };
// Trim seconds & timezone (VD: 19:39:00.000Z -> 19:39)
const cleanTime = time.slice(0, 5);
return { date: date ?? "", time: cleanTime };
}
function combineDateTime(date: string, time: string): string {
if (!date && !time) return "";
if (!time) return date;
if (!date) return "";
return `${date}T${time}`;
}
export function DateTimePicker({
value,
onChange,
className,
}: {
value: string;
onChange: (value: string) => void;
className?: string;
}) {
// Giữ date/time trong local state để không mất khi pick time trước date
const initial = splitDateTime(value);
const [date, setDate] = React.useState(initial.date);
const [time, setTime] = React.useState(initial.time);
// Sync từ prop khi value thay đổi từ bên ngoài (VD: load data xong)
React.useEffect(() => {
const { date: nextDate, time: nextTime } = splitDateTime(value);
setDate(nextDate);
setTime(nextTime);
}, [value]);
const emit = React.useCallback(
(nextDate: string, nextTime: string) => {
setDate(nextDate);
setTime(nextTime);
// Nếu pick time mà chưa có date → auto-fill hôm nay
if (!nextDate && nextTime) {
const today = dayjs().format("YYYY-MM-DD");
onChange(`${today}T${nextTime}`);
return;
}
onChange(combineDateTime(nextDate, nextTime));
},
[onChange],
);
return (
<div className={cn("flex gap-2", className)}>
<Input
type="date"
value={date}
onChange={(event) => emit(event.target.value, time)}
className="rounded-xl border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700 focus-visible:ring-[#063e8e]/30 [color-scheme:light]"
/>
<TimePicker
value={time}
onChange={(newTime) => emit(date, newTime)}
/>
</div>
);
}
"use client";
import * as React from "react";
import { Clock } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
const HOURS = Array.from({ length: 24 }, (_, i) => i);
const MINUTES = Array.from({ length: 12 }, (_, i) => i * 5);
function pad(num: number): string {
return num.toString().padStart(2, "0");
}
export function TimePicker({
value,
onChange,
}: {
value: string;
onChange: (time: string) => void;
}) {
const [open, setOpen] = React.useState(false);
const [hour, setHour] = React.useState<number | null>(null);
const [minute, setMinute] = React.useState<number | null>(null);
React.useEffect(() => {
if (!open) return;
if (value) {
// Trim seconds & timezone (VD: 19:39:00.000Z -> 19:39)
const cleanTime = value.slice(0, 5);
const [h, m] = cleanTime.split(":").map(Number);
setHour(Number.isNaN(h) ? null : h);
// Round minute về bội số 5 gần nhất (grid chỉ có 0,5,10,...,55)
const roundedM = Number.isNaN(m) ? null : Math.round(m / 5) * 5;
setMinute(roundedM === 60 ? 55 : roundedM);
} else {
setHour(null);
setMinute(null);
}
}, [open, value]);
const handleSelectHour = (h: number) => {
setHour(h);
if (minute !== null) {
onChange(`${pad(h)}:${pad(minute)}`);
setOpen(false);
}
};
const handleSelectMinute = (m: number) => {
setMinute(m);
if (hour !== null) {
onChange(`${pad(hour)}:${pad(m)}`);
setOpen(false);
}
};
const handleClear = () => {
setHour(null);
setMinute(null);
onChange("");
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
className={cn(
"flex h-9 w-full items-center gap-2 rounded-xl border px-3 py-1 text-base shadow-sm transition-colors md:text-sm",
"border-[#063e8e]/15 bg-white text-gray-700 placeholder:text-gray-700",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[#063e8e]/30",
)}
>
<Clock className="h-4 w-4 shrink-0 text-[#063e8e]" />
<span className={cn("truncate", value ? "text-gray-700" : "text-gray-400")}>
{value || "HH:mm"}
</span>
</button>
</PopoverTrigger>
<PopoverContent className="w-fit p-3" align="start">
<div className="flex gap-4">
<div>
<p className="mb-2 text-center text-sm font-semibold text-[#063e8e]">Giờ</p>
<div className="grid grid-cols-5 gap-1">
{HOURS.map((h) => (
<button
key={h}
type="button"
onClick={() => handleSelectHour(h)}
className={cn(
"h-8 w-9 rounded-md text-sm transition-colors",
hour === h
? "bg-[#063e8e] font-semibold text-white"
: "bg-[#063e8e]/5 text-gray-700 hover:bg-[#063e8e]/15",
)}
>
{pad(h)}
</button>
))}
</div>
</div>
<div>
<p className="mb-2 text-center text-sm font-semibold text-[#063e8e]">Phút</p>
<div className="grid grid-cols-3 gap-1">
{MINUTES.map((m) => (
<button
key={m}
type="button"
onClick={() => handleSelectMinute(m)}
className={cn(
"h-8 w-9 rounded-md text-sm transition-colors",
minute === m
? "bg-[#063e8e] font-semibold text-white"
: "bg-[#063e8e]/5 text-gray-700 hover:bg-[#063e8e]/15",
)}
>
{pad(m)}
</button>
))}
</div>
</div>
</div>
{value && (
<div className="mt-3 flex items-center justify-between border-t border-gray-100 pt-3">
<span className="text-sm text-gray-700">Đã chọn: {value}</span>
<button
type="button"
onClick={handleClear}
className="text-sm text-red-600 hover:text-red-700"
>
Xóa
</button>
</div>
)}
</PopoverContent>
</Popover>
);
}
"use client";
import { create } from "zustand";
import { createJSONStorage, devtools, persist } from "zustand/middleware";
import type { AdminNewsFormValues } from "@/mockdata/admin-news";
const DRAFT_STORAGE_KEY = "vcci-news.admin-news-draft.v1";
const MAX_DRAFT_AGE_MS = 1000 * 60 * 60 * 24 * 7; // 7 ngày
export interface NewsDraftData {
form: AdminNewsFormValues;
useEventDates: boolean;
savedAt: string; // ISO timestamp
}
export interface NewsDraftStoreState {
draft: NewsDraftData | null;
_hasHydrated: boolean;
setHasHydrated: (hasHydrated?: boolean) => void;
saveDraft: (data: Omit<NewsDraftData, "savedAt">) => void;
clearDraft: () => void;
getDraftIfFresh: () => NewsDraftData | null;
}
function isDraftFresh(draft: NewsDraftData | null): boolean {
if (!draft?.savedAt) return false;
const age = Date.now() - new Date(draft.savedAt).getTime();
return age >= 0 && age < MAX_DRAFT_AGE_MS;
}
const useNewsDraftStore = create<NewsDraftStoreState>()(
devtools(
persist(
(set, get) => ({
draft: null,
_hasHydrated: false,
setHasHydrated: (hasHydrated = true) =>
set(() => ({ _hasHydrated: hasHydrated })),
saveDraft: (data) =>
set(() => ({
draft: {
...data,
savedAt: new Date().toISOString(),
},
})),
clearDraft: () => set(() => ({ draft: null })),
getDraftIfFresh: () => {
const { draft } = get();
return isDraftFresh(draft) ? draft : null;
},
}),
{
name: DRAFT_STORAGE_KEY,
storage: createJSONStorage(() => ({
getItem: (name) => {
if (typeof window === "undefined") return null;
return localStorage.getItem(name);
},
setItem: (name, value) => {
if (typeof window === "undefined") return;
try {
localStorage.setItem(name, value);
} catch {
// Storage đầy hoặc bị chặn — bỏ qua
}
},
removeItem: (name) => {
if (typeof window === "undefined") return;
localStorage.removeItem(name);
},
})),
partialize: (state) => ({ draft: state.draft }),
onRehydrateStorage: () => {
return (state: NewsDraftStoreState | undefined, error: unknown) => {
if (error) {
useNewsDraftStore.persist.clearStorage();
}
// Tự dọn dẹp draft hết hạn khi hydrate
const current = state ?? useNewsDraftStore.getState();
if (!isDraftFresh(current.draft)) {
current.clearDraft();
}
current.setHasHydrated(true);
};
},
},
),
),
);
export default useNewsDraftStore;
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment