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';
import type { SortOrderParameter } from './sortOrderParameter';
import type { PageParameter } from './pageParameter';
import type { PageSizeParameter } from './pageSizeParameter';
import type { PriorityFeaturedParameter } from './priorityFeaturedParameter';
export type GetApiV10PostParams = {
/**
......@@ -34,4 +35,8 @@ page?: PageParameter;
* @minimum 1
*/
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';
export * from './postTag';
export * from './postTagBulkCreate';
export * from './postTagMutate';
export * from './priorityFeaturedParameter';
export * from './putApiV10AdvertisementId200';
export * from './putApiV10AdvertisementId200AllOf';
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 @@
import {
type FormEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import dayjs from "dayjs";
......@@ -70,7 +72,9 @@ import {
selectTriggerClassName,
SEARCH_TAG_VISIBLE_LIMIT,
} from "./constants";
import { DateTimePicker } from "@/components/shared/date-time-picker";
import { EventDatesDatePicker } from "./event-dates-date-picker";
import { DraftRestoreDialog } from "./draft-restore-dialog";
import { FormSection } from "./form-section";
import { HeaderCategoryMultiPicker } from "./header-category-multi-picker";
import { HeaderCategorySinglePicker } from "./header-category-single-picker";
......@@ -81,6 +85,7 @@ import {
isUuid,
toImageRef,
} from "./utils";
import useNewsDraftStore, { type NewsDraftData } from "@/store/useNewsDraftStore";
export function AdminNewsFormContent() {
const params = useParams();
......@@ -102,6 +107,15 @@ export function AdminNewsFormContent() {
const [isMissingPost, setIsMissingPost] = 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(() => {
let cancelled = false;
......@@ -276,6 +290,43 @@ export function AdminNewsFormContent() {
};
}, [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(() => {
return flattenHeaderTree(headerTree);
}, [headerTree]);
......@@ -540,6 +591,9 @@ export function AdminNewsFormContent() {
}
toast.success(isCreate ? "Đã tạo bài viết" : "Đã cập nhật bài viết");
if (isCreate) {
clearDraft();
}
router.push(backPath);
} catch (error) {
toast.error(error instanceof Error ? error.message : "Không thể lưu bài viết");
......@@ -727,25 +781,17 @@ export function AdminNewsFormContent() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div>
<Label className="mb-1.5 block text-gray-700">Ngày xuất bản</Label>
<Input
type="datetime-local"
<DateTimePicker
value={form.published_at}
onChange={(event) =>
handleField("published_at", event.target.value)
}
className={fieldClassName}
onChange={(value) => handleField("published_at", value)}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Ngày hết hạn</Label>
<Input
type="datetime-local"
<DateTimePicker
value={form.expired_at}
onChange={(event) =>
handleField("expired_at", event.target.value)
}
className={fieldClassName}
onChange={(value) => handleField("expired_at", value)}
/>
</div>
</div>
......@@ -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">
{"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>
)}
</div>
......@@ -932,21 +978,17 @@ export function AdminNewsFormContent() {
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3">
<div>
<Label className="mb-1.5 block text-gray-700">Ngày bắt đầu</Label>
<Input
type="datetime-local"
<DateTimePicker
value={form.started_at}
onChange={(event) => handleField("started_at", event.target.value)}
className={fieldClassName}
onChange={(value) => handleField("started_at", value)}
/>
</div>
<div>
<Label className="mb-1.5 block text-gray-700">Ngày kết thúc</Label>
<Input
type="datetime-local"
<DateTimePicker
value={form.ended_at}
onChange={(event) => handleField("ended_at", event.target.value)}
className={fieldClassName}
onChange={(value) => handleField("ended_at", value)}
/>
</div>
......@@ -954,13 +996,9 @@ export function AdminNewsFormContent() {
<Label className="mb-1.5 block text-gray-700">
Hạn đăng ký
</Label>
<Input
type="datetime-local"
<DateTimePicker
value={form.registration_deadline}
onChange={(event) =>
handleField("registration_deadline", event.target.value)
}
className={fieldClassName}
onChange={(value) => handleField("registration_deadline", value)}
/>
</div>
......@@ -1044,6 +1082,13 @@ export function AdminNewsFormContent() {
<PostHistoryViewer postId={newsId} />
</PermissionGate>
)}
<DraftRestoreDialog
open={showDraftDialog}
draft={pendingDraft}
onRestore={handleRestoreDraft}
onDiscard={handleDiscardDraft}
/>
</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>
);
}
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import {
ChevronLeft,
ChevronRight,
......@@ -33,12 +34,12 @@ import {
TableRow,
} from "@/components/ui/table";
import {
deleteApiV10PostId,
getApiV10Post,
putApiV10PostId,
useDeleteApiV10PostId,
useGetApiV10Post,
useGetApiV10PostStats,
usePutApiV10PostId,
} from "@/api/vcci-news/endpoints/post";
import { getApiV10CategoryTree } from "@/api/vcci-news/endpoints/category";
import { useGetApiV10CategoryTree } from "@/api/vcci-news/endpoints/category";
import {
ADMIN_NEWS_TYPE_LABELS,
ADMIN_NEWS_TYPE_OPTIONS,
......@@ -68,12 +69,53 @@ const selectContentClassName = "border-[#063e8e]/15 bg-white text-gray-700";
const selectItemClassName =
"text-gray-700 focus:bg-[#063e8e]/10 focus:text-[#063e8e]";
const POST_QUERY_KEY = "/api/v1.0/post";
const POST_STATS_QUERY_KEY = "/api/v1.0/post/stats";
function mapRowToAdminNewsItem(item: any): AdminNewsItem {
const structuredContent = parsePostContent(item.content_structure);
const postContent = structuredContent.length > 0 ? structuredContent : parseLegacyPostContent(item.content);
const categories = Array.isArray(item.categories) ? item.categories : [];
const primaryCategory = categories[0] ?? null;
const primaryCategoryType = primaryCategory?.type ?? null;
return {
id: item.id ?? "",
title: item.title ?? "",
slug: item.slug ?? "",
summary: item.summary ?? "",
type: item.type === "page" || primaryCategoryType === "post" || primaryCategoryType === "page" ? "baiviettrang" : "tintuc",
header_category_id: primaryCategory?.id ?? "",
category_ids: categories.map((c: any) => c.id),
tagsearch_values: [],
is_featured: Boolean(item.is_featured),
thumbnail: item.thumbnail?.id ? {
id: item.thumbnail.id,
name: item.thumbnail.original ?? item.thumbnail.path ?? "thumbnail",
alt: item.thumbnail.original ?? item.thumbnail.path ?? "thumbnail",
url: links.resolveImageUrl(item.thumbnail.path),
} : null,
is_hidden: Boolean(item.is_hidden),
created_at: item.created_at ?? "",
updated_at: item.updated_at ?? "",
published_at: normalizeDateTimeInput(item.published_at ?? item.release_at),
expired_at: normalizeDateTimeInput(item.expired_at),
started_at: normalizeDateTimeInput(item.started_at),
ended_at: normalizeDateTimeInput(item.ended_at),
registration_deadline: normalizeDateTimeInput(item.registration_deadline),
location: item.location ?? "",
participation_fee: item.participation_fee ?? "",
event_dates: Array.isArray(item.event_dates) ? item.event_dates.filter((d: any): d is string => typeof d === "string") : [],
post_content: postContent,
creator: normalizeUser(item.creator),
editor: normalizeUser(item.editor),
};
}
export default function AdminNewsPage() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [items, setItems] = useState<AdminNewsItem[]>([]);
const [headerTree, setHeaderTree] = useState<HeaderCategoryTreeItem[]>([]);
const queryClient = useQueryClient();
const [search, setSearch] = useState(() => searchParams.get("q") ?? "");
const [typeFilter, setTypeFilter] = useState(
() => searchParams.get("type") ?? "all",
......@@ -85,15 +127,13 @@ export default function AdminNewsPage() {
() => searchParams.get("status") ?? "all",
);
const [deleteTarget, setDeleteTarget] = useState<AdminNewsItem | null>(null);
const [ready, setReady] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [togglingVisibilityId, setTogglingVisibilityId] = useState<string | null>(null);
const [page, setPage] = useState(() => {
const parsedPage = Number(searchParams.get("page") ?? 1);
return Number.isFinite(parsedPage) && parsedPage > 0 ? Math.floor(parsedPage) : 1;
});
const [pageSize] = useState(10);
const [total, setTotal] = useState(0);
const pageSize = 10;
const didMountRef = useRef(false);
const debouncedSearch = useDebouncedValue(search);
......@@ -128,21 +168,6 @@ export default function AdminNewsPage() {
[listQueryString, pathname],
);
useEffect(() => {
void getApiV10CategoryTree()
.then((response) => {
const tree = (response.responseData ?? []) as unknown as HeaderCategoryTreeItem[];
setHeaderTree(tree);
})
.catch((error) => {
toast.error(
error instanceof Error
? error.message
: "Kh\u00f4ng th\u1ec3 t\u1ea3i danh m\u1ee5c hi\u1ec3n th\u1ecb",
);
});
}, []);
const baseFilterParts = useMemo(() => {
const filters: string[] = [];
const keyword = debouncedSearch.trim();
......@@ -180,75 +205,42 @@ export default function AdminNewsPage() {
return [...baseFilterParts, ...statusFilterParts].join(",");
}, [baseFilterParts, statusFilterParts]);
const statsResponse = useGetApiV10PostStats(
// Fetch post list via orval hook
const postsQuery = useGetApiV10Post({
page,
pageSize,
sortField: "created_at",
sortOrder: "desc",
priorityFeatured: false,
filters: apiFilters?.trim() || undefined,
});
// Fetch category tree via orval hook
const categoriesQuery = useGetApiV10CategoryTree();
// Fetch stats via orval hook
const statsQuery = useGetApiV10PostStats(
{ filters: apiFilters || undefined },
);
const statsData = statsResponse.data?.responseData;
const load = useCallback(async () => {
setReady(false);
const response = await getApiV10Post({
page,
pageSize,
sortField: "created_at",
sortOrder: "desc",
filters: apiFilters?.trim() || undefined,
});
const result = (response.responseData ?? {}) as { rows?: any[]; count?: number; page?: number; pageSize?: number };
setItems((result.rows ?? []).map((item: any): AdminNewsItem => {
const structuredContent = parsePostContent(item.content_structure);
const postContent = structuredContent.length > 0 ? structuredContent : parseLegacyPostContent(item.content);
const categories = Array.isArray(item.categories) ? item.categories : [];
const primaryCategory = categories[0] ?? null;
const primaryCategoryType = primaryCategory?.type ?? null;
return {
id: item.id ?? "",
title: item.title ?? "",
slug: item.slug ?? "",
summary: item.summary ?? "",
type: item.type === "page" || primaryCategoryType === "post" || primaryCategoryType === "page" ? "baiviettrang" : "tintuc",
header_category_id: primaryCategory?.id ?? "",
category_ids: categories.map((c: any) => c.id),
tagsearch_values: [],
is_featured: Boolean(item.is_featured),
thumbnail: item.thumbnail?.id ? {
id: item.thumbnail.id,
name: item.thumbnail.original ?? item.thumbnail.path ?? "thumbnail",
alt: item.thumbnail.original ?? item.thumbnail.path ?? "thumbnail",
url: links.resolveImageUrl(item.thumbnail.path),
} : null,
is_hidden: Boolean(item.is_hidden),
created_at: item.created_at ?? "",
updated_at: item.updated_at ?? "",
published_at: normalizeDateTimeInput(item.published_at ?? item.release_at),
expired_at: normalizeDateTimeInput(item.expired_at),
started_at: normalizeDateTimeInput(item.started_at),
ended_at: normalizeDateTimeInput(item.ended_at),
registration_deadline: normalizeDateTimeInput(item.registration_deadline),
location: item.location ?? "",
participation_fee: item.participation_fee ?? "",
event_dates: Array.isArray(item.event_dates) ? item.event_dates.filter((d: any): d is string => typeof d === "string") : [],
post_content: postContent,
creator: normalizeUser(item.creator),
editor: normalizeUser(item.editor),
};
}));
setTotal(result.count ?? 0);
setReady(true);
}, [apiFilters, page, pageSize]);
useEffect(() => {
void load().catch((error) => {
toast.error(
error instanceof Error
? error.message
: "Không thể tải danh sách bài viết",
);
setReady(true);
});
}, [load]);
// Mutations
const deletePostMutation = useDeleteApiV10PostId();
const updatePostMutation = usePutApiV10PostId();
// Derived data
const postsResponse = (postsQuery.data ?? {}) as { responseData?: { rows?: any[]; count?: number } };
const items: AdminNewsItem[] = useMemo(
() => (postsResponse.responseData?.rows ?? []).map(mapRowToAdminNewsItem),
[postsResponse.responseData?.rows],
);
const total = postsResponse.responseData?.count ?? 0;
const ready = !postsQuery.isLoading && !postsQuery.isFetching;
const headerTree = useMemo(
() => ((categoriesQuery.data ?? {}).responseData ?? []) as unknown as HeaderCategoryTreeItem[],
[categoriesQuery.data],
);
const statsData = (statsQuery.data ?? {}).responseData;
useEffect(() => {
const nextPath = listQueryString ? `${pathname}?${listQueryString}` : pathname;
......@@ -294,17 +286,21 @@ export default function AdminNewsPage() {
];
}, [statsData, total]);
const invalidatePostQueries = () => {
queryClient.invalidateQueries({ queryKey: [POST_QUERY_KEY], exact: false });
queryClient.invalidateQueries({ queryKey: [POST_STATS_QUERY_KEY], exact: false });
};
const handleDelete = async () => {
if (!deleteTarget || isDeleting) return;
setIsDeleting(true);
try {
await deleteApiV10PostId(deleteTarget.id);
await deletePostMutation.mutateAsync({ id: deleteTarget.id });
toast.success("Đã xóa bài viết");
setDeleteTarget(null);
await load();
statsResponse.refetch();
invalidatePostQueries();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Không thể xóa bài viết",
......@@ -321,13 +317,15 @@ export default function AdminNewsPage() {
setTogglingVisibilityId(item.id);
try {
await putApiV10PostId(item.id, {
is_hidden: nextIsHidden,
is_active: !nextIsHidden,
} as any);
await updatePostMutation.mutateAsync({
id: item.id,
data: {
is_hidden: nextIsHidden,
is_active: !nextIsHidden,
} as any,
});
toast.success(nextIsHidden ? "Đã ẩn bài viết" : "Đã hiển thị bài viết");
await load();
statsResponse.refetch();
invalidatePostQueries();
} catch (error) {
toast.error(
error instanceof Error ? error.message : "Không thể thay đổi trạng thái hiển thị",
......
"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