+
{isLoading ? (
) : filteredAlerts.length > 0 ? (
filteredAlerts.map((alert) => (
@@ -207,12 +163,10 @@ const Alerts = () => {
/>
))
) : (
-
-
- {hasSearchQuery && alerts.length > 0
- ? "검색 결과가 없습니다."
- : "공지사항이 없습니다."}
-
+
+ {hasSearchQuery && alerts.length > 0
+ ? "검색 결과가 없습니다."
+ : "공지사항이 없습니다."}
)}
diff --git a/src/components/Tabs/Alerts/MyAlertsView.tsx b/src/components/Tabs/Alerts/MyAlertsView.tsx
deleted file mode 100644
index 2ee3fe61..00000000
--- a/src/components/Tabs/Alerts/MyAlertsView.tsx
+++ /dev/null
@@ -1,284 +0,0 @@
-import { useState, useEffect, useCallback, useMemo } from "react";
-import {
- getSubscriptions,
- getMySubscriptions,
- getMyAlerts,
- subscribeDepartment,
- unsubscribeDepartment,
-} from "@/apis";
-import type { Department, Subscription, GeneralAlert } from "@/types/api";
-import { Badge } from "@/components/ui/badge";
-import { Button } from "@/components/ui/button";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@/components/ui/popover";
-import {
- Command,
- CommandEmpty,
- CommandGroup,
- CommandInput,
- CommandItem,
- CommandList,
-} from "@/components/ui/command";
-import { X, Bell, Loader2, Search } from "lucide-react";
-import { toast } from "sonner";
-import AlertItem from "./AlertItem";
-import { captureErrorLog } from '@/utils/logger';
-import { matchesAlertQuery } from "./alertSearchUtils";
-import { sendAlertsSubscriptionChange } from '@/utils/analytics';
-
-interface MyAlertsViewProps {
- searchQuery: string;
-}
-
-const MyAlertsView = ({ searchQuery }: MyAlertsViewProps) => {
- const [departments, setDepartments] = useState
([]);
- const [mySubscriptions, setMySubscriptions] = useState([]);
- const [myAlerts, setMyAlerts] = useState([]);
- const [isLoading, setIsLoading] = useState(true);
- const [isSubscribing, setIsSubscribing] = useState(false);
- const [isDepartmentsLoaded, setIsDepartmentsLoaded] = useState(false);
- const [isLoadingDepartments, setIsLoadingDepartments] = useState(false);
- const [open, setOpen] = useState(false);
-
- // 전체 학과 목록 로드 (드롭다운 열 때만)
- const loadDepartments = useCallback(async () => {
- if (isDepartmentsLoaded || isLoadingDepartments) return;
-
- setIsLoadingDepartments(true);
- try {
- const result = await getSubscriptions();
- if (result.success && Array.isArray(result.data)) {
- setDepartments(result.data);
- setIsDepartmentsLoaded(true);
- }
- } finally {
- setIsLoadingDepartments(false);
- }
- }, [isDepartmentsLoaded, isLoadingDepartments]);
-
- // 내 구독 + 내 공지 로드
- const loadMyData = useCallback(async () => {
- setIsLoading(true);
- try {
- const [subscriptionsResult, alertsResult] = await Promise.all([
- getMySubscriptions(),
- getMyAlerts(),
- ]);
-
- if (subscriptionsResult.success && Array.isArray(subscriptionsResult.data)) {
- setMySubscriptions(subscriptionsResult.data);
- }
-
- if (alertsResult.success && Array.isArray(alertsResult.data)) {
- setMyAlerts(alertsResult.data);
- }
- } catch (error) {
- captureErrorLog("Failed to load my data:", error);
- toast.error("데이터를 불러오는데 실패했습니다.");
- } finally {
- setIsLoading(false);
- }
- }, []);
-
- // Popover 열릴 때 학과 목록 로드
- const handleOpenChange = (isOpen: boolean) => {
- setOpen(isOpen);
- if (isOpen) {
- loadDepartments();
- }
- };
-
- // 초기 로드 (내 구독 + 내 공지만)
- useEffect(() => {
- loadMyData();
- }, [loadMyData]);
-
- // 학과 구독
- const handleSubscribe = async (departmentId: number) => {
- // 이미 구독 중인지 확인
- if (mySubscriptions.some((sub) => sub.department.id === departmentId)) {
- toast.info("이미 구독 중인 학과입니다.");
- return;
- }
-
- setIsSubscribing(true);
- try {
- const result = await subscribeDepartment(departmentId);
- if (result.success) {
- const departmentName = departments.find(
- (department) => department.id === departmentId,
- )?.name;
- if (departmentName) {
- sendAlertsSubscriptionChange(departmentName, 'subscribe');
- }
- toast.success("학과 구독 완료!");
- await loadMyData(); // 목록 새로고침
- } else {
- toast.error(result.error?.message || "구독에 실패했습니다.");
- }
- } catch (error) {
- captureErrorLog("Subscribe error:", error);
- toast.error("구독 중 오류가 발생했습니다.");
- } finally {
- setIsSubscribing(false);
- }
- };
-
- // 구독 취소
- const handleUnsubscribe = async (departmentId: number) => {
- try {
- const result = await unsubscribeDepartment(departmentId);
- if (result.success) {
- const departmentName = mySubscriptions.find(
- (subscription) => subscription.department.id === departmentId,
- )?.department.name;
- if (departmentName) {
- sendAlertsSubscriptionChange(departmentName, 'unsubscribe');
- }
- toast.success("구독 취소 완료");
- await loadMyData(); // 목록 새로고침
- } else {
- toast.error(result.error?.message || "구독 취소에 실패했습니다.");
- }
- } catch (error) {
- captureErrorLog("Unsubscribe error:", error);
- toast.error("구독 취소 중 오류가 발생했습니다.");
- }
- };
-
- // 구독 가능한 학과만 필터링 (이미 구독 중인 학과 제외)
- const availableDepartments = departments.filter(
- (dept) => !mySubscriptions.some((sub) => sub.department.id === dept.id)
- );
-
- // 공지사항 시간순 정렬 (최신순)
- const sortedAlerts = useMemo(() => {
- return [...myAlerts].sort((a, b) => {
- const dateA = new Date(a.publishedAt).getTime();
- const dateB = new Date(b.publishedAt).getTime();
- return dateB - dateA;
- });
- }, [myAlerts]);
-
- const filteredAlerts = useMemo(
- () => sortedAlerts.filter((alert) => matchesAlertQuery(alert, searchQuery)),
- [searchQuery, sortedAlerts]
- );
-
- return (
-
- {/* 학과 구독 섹션 */}
-
-
-
- 학과 구독
-
-
- {/* 학과 검색 Combobox */}
-
-
-
-
-
-
-
-
- {isLoadingDepartments ? (
-
-
-
- ) : (
- <>
- 검색 결과가 없습니다
-
- {availableDepartments.map((dept) => (
- {
- handleSubscribe(dept.id);
- setOpen(false);
- }}
- >
- {dept.name}
-
- ))}
-
- >
- )}
-
-
-
-
-
- {/* 구독 중인 학과 뱃지 */}
- {mySubscriptions.length > 0 && (
-
- {mySubscriptions.map((sub) => (
- handleUnsubscribe(sub.department.id)}
- >
- {sub.department.name}
-
-
- ))}
-
- )}
-
-
- {/* 구분선 */}
-
-
- {/* 내 공지사항 목록 */}
-
- {isLoading ? (
-
-
-
- ) : filteredAlerts.length > 0 ? (
-
- {filteredAlerts.map((alert) => (
-
- ))}
-
- ) : mySubscriptions.length === 0 ? (
-
-
구독한 학과가 없습니다.
-
위에서 학과를 선택해 구독해보세요!
-
- ) : sortedAlerts.length === 0 ? (
-
- ) : (
-
- )}
-
-
- );
-};
-
-export default MyAlertsView;
diff --git a/src/components/Tabs/Alerts/alertSearchUtils.ts b/src/components/Tabs/Alerts/alertSearchUtils.ts
index 5b913d22..82264a23 100644
--- a/src/components/Tabs/Alerts/alertSearchUtils.ts
+++ b/src/components/Tabs/Alerts/alertSearchUtils.ts
@@ -16,10 +16,8 @@ export const matchesAlertQuery = (alert: Alert, query: string) => {
return true;
}
- const sourceName =
- "department" in alert ? alert.department.name : alert.category;
const searchableText = normalizeSearchText(
- [alert.title, alert.content, sourceName].filter(Boolean).join(" ")
+ [alert.title, alert.content, alert.category].filter(Boolean).join(" ")
);
return queryTokens.every((token) => searchableText.includes(token));
diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx
deleted file mode 100644
index 61c9a709..00000000
--- a/src/components/ui/avatar.tsx
+++ /dev/null
@@ -1,52 +0,0 @@
-/**
- * Avatar component - shadcn/ui pattern
- * Displays user profile pictures with fallback support
- */
-
-import * as React from "react";
-import * as AvatarPrimitive from "@radix-ui/react-avatar";
-import { cn } from "@/lib/utils";
-
-const Avatar = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-Avatar.displayName = AvatarPrimitive.Root.displayName;
-
-const AvatarImage = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-AvatarImage.displayName = AvatarPrimitive.Image.displayName;
-
-const AvatarFallback = React.forwardRef<
- React.ElementRef,
- React.ComponentPropsWithoutRef
->(({ className, ...props }, ref) => (
-
-));
-AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName;
-
-export { Avatar, AvatarImage, AvatarFallback };
diff --git a/src/constants/template.ts b/src/constants/template.ts
index 058002e1..1db00059 100644
--- a/src/constants/template.ts
+++ b/src/constants/template.ts
@@ -2,7 +2,7 @@
* The rules that define a template: what it may contain, and how an unsaved
* one is spelled.
*
- * Kept in a leaf module so the storage layer, the share codec and the renderer
+ * Kept in a leaf module so the storage layer, cloud codec and renderer
* validate against the same numbers without pulling React or icon rendering
* along with them.
*/
@@ -21,8 +21,8 @@ export const MAX_TEMPLATE_NAME_LENGTH = 80;
export const MAX_SITE_URL_LENGTH = 2_048;
/**
- * Icon images that may travel inside a shared template or be registered as an
- * asset. SVG is excluded on purpose: it can carry script.
+ * Icon images that may be imported into local storage. SVG is excluded on
+ * purpose because it can carry script.
*/
export const PORTABLE_ICON_PATTERN =
/^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/u;
diff --git a/src/constants/templateIcons.ts b/src/constants/templateIcons.ts
index be26a694..cb1d5071 100644
--- a/src/constants/templateIcons.ts
+++ b/src/constants/templateIcons.ts
@@ -8,9 +8,8 @@ import { convertLucideIconToDataUri } from "@/utils/iconDataUri";
let bundledIcons: Icon[] | undefined;
/**
- * Fallback icon for links whose original image cannot travel — a remote URL
- * that we refuse to re-request from a shared template, or a bundled icon the
- * receiving version no longer has.
+ * Fallback icon for remote image URLs that are not portable or bundled icons
+ * that a newer or older client no longer recognizes.
*
* It is a bundled icon rather than a synthetic placeholder so the editor can
* resolve it like any other: an item pointing at an icon that no list holds
diff --git a/src/contexts/EditorContext.tsx b/src/contexts/EditorContext.tsx
index c96a36a1..e6faf05a 100644
--- a/src/contexts/EditorContext.tsx
+++ b/src/contexts/EditorContext.tsx
@@ -11,7 +11,7 @@ import { createDefaultLinkList } from '@/constants/LinkList';
import { BULLETIN_FALLBACK } from '@/constants/bulletin';
import { getBundledTemplateIcons } from '@/constants/templateIcons';
import { convertLinkListToTemplateItems, calculateTemplateHeight } from '@/utils/template';
-import { getLocalTemplate } from '@/utils/templateStorage';
+import { getLocalTemplate } from '@/storage/templates/repository';
import { debugLog, captureErrorLog } from '@/utils/logger';
import { EditorContext } from './EditorContextObject';
import { GRID_COLUMNS, UNSAVED_TEMPLATE_ID } from '@/constants/template';
diff --git a/src/hooks/useAccountSync.ts b/src/hooks/useAccountSync.ts
new file mode 100644
index 00000000..4855ece6
--- /dev/null
+++ b/src/hooks/useAccountSync.ts
@@ -0,0 +1,80 @@
+import { useEffect } from "react";
+import { isLoggedIn } from "@/utils/oauth";
+import { getGoogleAccountId } from "@/apis/supabase/account";
+import { isSupabaseConfigured } from "@/apis/supabase/client";
+import {
+ activateSyncAccount,
+ getActiveSyncAccountId,
+ SyncAccountMismatchError,
+} from "@/storage/account/syncRepository";
+import { syncAccount } from "@/utils/accountSync";
+import { isExpectedNetworkFailure } from "@/utils/networkFailure";
+import { captureErrorLog } from "@/utils/logger";
+import { recordBreadcrumb } from "@/monitoring";
+import { UserFacingError } from "@/errors/userFacingError";
+
+export function useAccountSync(): void {
+ useEffect(() => {
+ if (!isSupabaseConfigured()) return;
+ let disposed = false;
+
+ const run = async () => {
+ const boundAccountId = await getActiveSyncAccountId();
+ if (!boundAccountId || !(await isLoggedIn())) return;
+ const result = await syncAccount();
+ if (!disposed && result.failed > 0) {
+ recordBreadcrumb(
+ "account.sync",
+ "background sync completed with deferred operations",
+ { failed: result.failed, conflicts: result.conflicts },
+ "warning",
+ );
+ }
+ };
+
+ const initialize = async () => {
+ const accountId = await getGoogleAccountId();
+ if (!accountId) return;
+ await activateSyncAccount(accountId);
+ await run();
+ };
+
+ const report = (error: unknown) => {
+ if (
+ error instanceof SyncAccountMismatchError ||
+ error instanceof UserFacingError ||
+ isExpectedNetworkFailure(error)
+ ) {
+ recordBreadcrumb(
+ "account.sync",
+ "automatic sync unavailable",
+ {
+ reason:
+ error instanceof SyncAccountMismatchError
+ ? "account_mismatch"
+ : error instanceof UserFacingError
+ ? error.code
+ : "network",
+ },
+ "warning",
+ );
+ return;
+ }
+ captureErrorLog("[Account sync] Automatic sync failed", error);
+ };
+
+ const trigger = () => {
+ void run().catch(report);
+ };
+ void initialize().catch(report);
+ window.addEventListener("auth:login", trigger);
+ window.addEventListener("online", trigger);
+ window.addEventListener("linku:templates-changed", trigger);
+ return () => {
+ disposed = true;
+ window.removeEventListener("auth:login", trigger);
+ window.removeEventListener("online", trigger);
+ window.removeEventListener("linku:templates-changed", trigger);
+ };
+ }, []);
+}
diff --git a/src/hooks/useSelectedTemplate.ts b/src/hooks/useSelectedTemplate.ts
index 1c3fd303..52068c05 100644
--- a/src/hooks/useSelectedTemplate.ts
+++ b/src/hooks/useSelectedTemplate.ts
@@ -16,7 +16,7 @@ import {
type LinkListElement,
} from "@/constants/LinkList";
import type { BulletinInfo } from "@/constants/bulletin";
-import { getLocalTemplate } from "@/utils/templateStorage";
+import { getLocalTemplate } from "@/storage/templates/repository";
import { debugLog, captureErrorLog } from '@/utils/logger';
import { UNSAVED_TEMPLATE_ID } from '@/constants/template';
diff --git a/src/pages/GalleryPage.tsx b/src/pages/GalleryPage.tsx
index 81caacde..fae2e5b8 100644
--- a/src/pages/GalleryPage.tsx
+++ b/src/pages/GalleryPage.tsx
@@ -1,57 +1,282 @@
-import { useEffect, useState } from 'react';
-import { useNavigate } from 'react-router';
-import { ArrowLeft, Download, Sparkles } from 'lucide-react';
-import { TemplateCard } from '@/components/Editor/TemplatePreview/TemplateCard';
-import { Button } from '@/components/ui/button';
-import { useToast } from '@/components/ui/use-toast';
-import { createBundledDefaultTemplate } from '@/utils/defaultTemplate';
-import { importTemplateCopy } from '@/utils/templateStorage';
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { FormEvent } from "react";
+import { useNavigate } from "react-router";
import {
- resolveLatestBulletin,
- subscribeLatestBulletin,
-} from '@/apis/external/bulletin';
-import { captureErrorLog } from '@/utils/logger';
+ ArrowLeft,
+ Copy,
+ Download,
+ Heart,
+ Loader2,
+ Search,
+ Sparkles,
+} from "lucide-react";
+import { TemplateCard } from "@/components/Editor/TemplatePreview/TemplateCard";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { useToast } from "@/components/ui/use-toast";
+import { createBundledDefaultTemplate } from "@/utils/defaultTemplate";
+import { importTemplateCopy } from "@/storage/templates/repository";
+import {
+ browsePublications,
+ clonePublication,
+ createPublicationPreview,
+ setPublicationLiked,
+} from "@/apis/supabase/community";
+import {
+ SupabaseConfigurationError,
+ isSupabaseConfigured,
+} from "@/apis/supabase/client";
+import type {
+ PublicationSort,
+ TemplatePublication,
+} from "@/types/account";
+import type { Template } from "@/types/api";
+import { isLoggedIn, startGoogleLogin } from "@/utils/oauth";
+import { captureErrorLog } from "@/utils/logger";
+import { isExpectedNetworkFailure } from "@/utils/networkFailure";
+import { recordBreadcrumb } from "@/monitoring";
+import { UserFacingError } from "@/errors/userFacingError";
+
+const PAGE_SIZE = 12;
+const MAX_SEARCH_LENGTH = 80;
+
+function reportCommunityFailure(message: string, error: unknown) {
+ if (
+ error instanceof UserFacingError ||
+ error instanceof SupabaseConfigurationError ||
+ isExpectedNetworkFailure(error)
+ ) {
+ recordBreadcrumb(
+ "community.gallery",
+ message,
+ {
+ reason:
+ error instanceof UserFacingError
+ ? error.code
+ : error instanceof SupabaseConfigurationError
+ ? "not_configured"
+ : "network",
+ },
+ "warning",
+ );
+ return;
+ }
+ captureErrorLog(message, error);
+}
+
+function PublicationCard({
+ publication,
+ preview,
+ busy,
+ onClone,
+ onLike,
+}: {
+ publication: TemplatePublication;
+ preview: Template;
+ busy: boolean;
+ onClone: () => void;
+ onLike: () => void;
+}) {
+ return (
+
+
+
+
+
+ {publication.authorNickname}
+
+
+
+
+ {publication.likeCount}
+
+
+
+ {publication.cloneCount}
+
+
+
+
+
+
+
+
+
+ );
+}
export const GalleryPage = () => {
const navigate = useNavigate();
const { toast } = useToast();
- const [importing, setImporting] = useState(false);
- const [template, setTemplate] = useState(createBundledDefaultTemplate);
+ const [queryInput, setQueryInput] = useState("");
+ const [query, setQuery] = useState("");
+ const [sort, setSort] = useState("latest");
+ const [publications, setPublications] = useState([]);
+ const [previews, setPreviews] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const [loadingMore, setLoadingMore] = useState(false);
+ const [hasMore, setHasMore] = useState(false);
+ const [communityUnavailable, setCommunityUnavailable] = useState(
+ !isSupabaseConfigured(),
+ );
+ const [busyTemplateId, setBusyTemplateId] = useState(null);
+ const bundledTemplate = useMemo(() => createBundledDefaultTemplate(), []);
+ const loadRequestIdRef = useRef(0);
+
+ const load = useCallback(
+ async (offset = 0) => {
+ const requestId = ++loadRequestIdRef.current;
+ if (!isSupabaseConfigured()) {
+ setCommunityUnavailable(true);
+ setLoading(false);
+ return;
+ }
+ if (offset === 0) {
+ setLoading(true);
+ } else {
+ setLoadingMore(true);
+ }
+ try {
+ const next = await browsePublications({
+ query,
+ sort,
+ offset,
+ limit: PAGE_SIZE,
+ });
+ const nextPreviews = await Promise.all(
+ next.map(async (publication) => [
+ publication.templateId,
+ await createPublicationPreview(publication),
+ ] as const),
+ );
+ if (requestId !== loadRequestIdRef.current) return;
+ setPublications((current) => (offset === 0 ? next : [...current, ...next]));
+ setPreviews((current) => ({
+ ...(offset === 0 ? {} : current),
+ ...Object.fromEntries(nextPreviews),
+ }));
+ setHasMore(next.length === PAGE_SIZE);
+ setCommunityUnavailable(false);
+ } catch (error) {
+ if (requestId !== loadRequestIdRef.current) return;
+ if (offset === 0) {
+ setCommunityUnavailable(true);
+ setPublications([]);
+ setPreviews({});
+ } else {
+ toast({
+ title: "더 불러오지 못했습니다",
+ description: "잠시 후 다시 시도해 주세요.",
+ variant: "destructive",
+ });
+ }
+ reportCommunityFailure("community gallery unavailable", error);
+ } finally {
+ if (requestId === loadRequestIdRef.current) {
+ setLoading(false);
+ setLoadingMore(false);
+ }
+ }
+ },
+ [query, sort, toast],
+ );
useEffect(() => {
- const applyBulletin = (bulletin: Parameters[0]) => {
- setTemplate(createBundledDefaultTemplate(bulletin));
+ void load();
+ return () => {
+ loadRequestIdRef.current += 1;
};
- const unsubscribe = subscribeLatestBulletin(applyBulletin);
- void resolveLatestBulletin().then(applyBulletin);
- return unsubscribe;
- }, []);
+ }, [load]);
+
+ const handleSearch = (event: FormEvent) => {
+ event.preventDefault();
+ setQuery(queryInput.trim());
+ };
- const handleImport = async () => {
- setImporting(true);
+ const handleClone = async (publication: TemplatePublication) => {
+ setBusyTemplateId(publication.templateId);
try {
- const stored = await importTemplateCopy(template);
+ const templateId = await clonePublication(publication);
toast({
- title: '템플릿 추가 완료',
- description: '서버 연결 없이 이 기기에 저장했습니다.',
+ title: "템플릿 복제 완료",
+ description: "이 기기에 독립적인 복사본으로 저장했습니다.",
});
- navigate(`/editor/${stored.template.templateId}`);
+ navigate(`/editor/${templateId}`);
+ } catch (error) {
+ reportCommunityFailure("[Gallery] Failed to clone publication", error);
+ toast({
+ title: "복제 실패",
+ description: error instanceof Error ? error.message : "템플릿을 저장하지 못했습니다.",
+ variant: "destructive",
+ });
+ } finally {
+ setBusyTemplateId(null);
+ }
+ };
+
+ const handleLike = async (publication: TemplatePublication) => {
+ setBusyTemplateId(publication.templateId);
+ try {
+ if (!(await isLoggedIn())) {
+ const login = await startGoogleLogin();
+ if (!login.success) {
+ toast({ title: "Google 로그인 필요", description: login.error });
+ return;
+ }
+ }
+ const liked = !publication.isLiked;
+ const likeCount = await setPublicationLiked(publication.templateId, liked);
+ setPublications((current) =>
+ current.map((item) =>
+ item.templateId === publication.templateId
+ ? { ...item, isLiked: liked, likeCount }
+ : item,
+ ),
+ );
} catch (error) {
- captureErrorLog('Failed to import bundled template', error);
+ reportCommunityFailure("[Gallery] Failed to update like", error);
toast({
- title: '가져오기 실패',
- description: '브라우저 저장소에 템플릿을 추가하지 못했습니다.',
- variant: 'destructive',
+ title: "좋아요 저장 실패",
+ description: "잠시 후 다시 시도해 주세요.",
+ variant: "destructive",
});
} finally {
- setImporting(false);
+ setBusyTemplateId(null);
+ }
+ };
+
+ const handleBundledImport = async () => {
+ try {
+ const stored = await importTemplateCopy(bundledTemplate);
+ navigate(`/editor/${stored.template.templateId}`);
+ } catch (error) {
+ captureErrorLog("[Gallery] Failed to import bundled template", error);
+ toast({
+ title: "가져오기 실패",
+ description: "브라우저 저장소에 템플릿을 추가하지 못했습니다.",
+ variant: "destructive",
+ });
}
};
return (
-
-
-
-
+
-
-
-
-
- {importing ? '저장 중...' : '내 템플릿으로 가져오기'}
-
-
-
+
+
+
+ {([
+ ["latest", "최신순"],
+ ["likes", "좋아요순"],
+ ["clones", "복제순"],
+ ] as const).map(([value, label]) => (
+ setSort(value)}
+ >
+ {label}
+
+ ))}
+
+
+
+ {communityUnavailable && publications.length === 0 && (
+
+ 커뮤니티에 연결할 수 없어 함께 제공되는 기본 템플릿을 표시합니다.
+
+ )}
+
+ {loading ? (
+
+
+
+ ) : publications.length > 0 ? (
+
+ {publications.map((publication) => {
+ const preview = previews[publication.templateId];
+ return preview ? (
+
void handleClone(publication)}
+ onLike={() => void handleLike(publication)}
+ />
+ ) : null;
+ })}
+
+ ) : !communityUnavailable ? (
+
+ {query ? "검색 결과가 없습니다." : "아직 게시된 템플릿이 없습니다."}
+
+ ) : null}
+
+ {hasMore && (
+
+ void load(publications.length)}
+ >
+ {loadingMore && }
+ 더 보기
+
+
+ )}
+
+ {communityUnavailable && publications.length === 0 && (
+
+
+ void handleBundledImport()}>
+
+ 기본 템플릿 가져오기
+
+
+ )}
+
);
};
diff --git a/src/pages/TemplateListPage.tsx b/src/pages/TemplateListPage.tsx
index 81e7ad29..959ee194 100644
--- a/src/pages/TemplateListPage.tsx
+++ b/src/pages/TemplateListPage.tsx
@@ -2,11 +2,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useNavigate } from 'react-router';
import {
AlertTriangle,
+ Cloud,
+ CloudOff,
DatabaseBackup,
FileText,
- FileUp,
LayoutTemplate,
Plus,
+ RefreshCw,
Sparkles,
} from 'lucide-react';
import { TemplateCard } from '@/components/Editor/TemplatePreview/TemplateCard';
@@ -25,21 +27,13 @@ import {
createTemplateBackup,
countQuarantinedRecords,
deleteLocalTemplate,
- importSharedTemplate,
isTemplateBackupValidationError,
- getLocalTemplate,
listQuarantinedRecords,
listLocalTemplates,
MAX_TEMPLATE_BACKUP_BYTES,
+ PublishedTemplateDeleteError,
restoreTemplateBackup,
-} from '@/utils/templateStorage';
-import {
- createTemplateShareUrl,
- downloadTemplatePayload,
- isTemplateShareValidationError,
- MAX_SHARE_FILE_BYTES,
- validateTemplateSharePayload,
-} from '@/utils/templateShare';
+} from '@/storage/templates/repository';
import { createBundledDefaultTemplate } from '@/utils/defaultTemplate';
import {
resolveLatestBulletin,
@@ -50,12 +44,34 @@ import { downloadJson } from '@/utils/download';
import { captureErrorLog, warnLog } from '@/utils/logger';
import { UserFacingError } from '@/errors/userFacingError';
import { recordBreadcrumb } from '@/monitoring';
+import {
+ isPublicationOutdated,
+ publishLocalTemplate,
+ refreshPublicationMetadata,
+ unpublishLocalTemplate,
+} from '@/apis/supabase/community';
+import { SupabaseConfigurationError } from '@/apis/supabase/client';
+import {
+ getTemplateAccountStates,
+} from '@/storage/account/syncRepository';
+import type { AccountSyncStatus } from '@/types/account';
+import { syncAccount } from '@/utils/accountSync';
+import { getAccountSyncFeedback } from '@/utils/accountSyncResult';
+import { isExpectedNetworkFailure } from '@/utils/networkFailure';
+import { isLoggedIn, startGoogleLogin } from '@/utils/oauth';
import {
sendTemplateApply,
sendTemplateCreateStart,
sendTemplateDelete,
} from '@/utils/analytics';
+interface TemplateListItem extends TemplateSummary {
+ syncId?: string;
+ accountSyncStatus: AccountSyncStatus;
+ published: boolean;
+ publicationOutdated: boolean;
+}
+
function toSummary(template: Template): TemplateSummary {
return {
templateId: template.templateId,
@@ -73,12 +89,24 @@ function toSummary(template: Template): TemplateSummary {
function reportTemplateOperationFailure(message: string, error: unknown) {
if (
isTemplateBackupValidationError(error) ||
- isTemplateShareValidationError(error)
+ error instanceof UserFacingError ||
+ error instanceof PublishedTemplateDeleteError ||
+ error instanceof SupabaseConfigurationError ||
+ isExpectedNetworkFailure(error)
) {
recordBreadcrumb(
- 'template.validation',
+ 'template.operation',
message,
- { validation_code: error.code },
+ {
+ reason:
+ error instanceof UserFacingError
+ ? error.code
+ : error instanceof SupabaseConfigurationError
+ ? 'not_configured'
+ : isExpectedNetworkFailure(error)
+ ? 'network'
+ : 'local_state',
+ },
'warning',
);
warnLog(message, error);
@@ -95,11 +123,12 @@ export const TemplateListPage = () => {
const [defaultTemplate, setDefaultTemplate] = useState(
createBundledDefaultTemplate,
);
- const [templates, setTemplates] = useState
([]);
+ const [templates, setTemplates] = useState([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState<'owned' | 'cloned'>('owned');
const [actionLoading, setActionLoading] = useState(null);
- const importInputRef = useRef(null);
+ const [syncing, setSyncing] = useState(false);
+ const [accountConnected, setAccountConnected] = useState(false);
const restoreInputRef = useRef(null);
const loadRequestIdRef = useRef(0);
const [quarantinedCount, setQuarantinedCount] = useState(0);
@@ -119,11 +148,46 @@ export const TemplateListPage = () => {
try {
const storedTemplates = await listLocalTemplates();
const nextQuarantinedCount = await countQuarantinedRecords();
+ let connected = false;
+ try {
+ connected = await isLoggedIn();
+ if (connected) {
+ await refreshPublicationMetadata();
+ }
+ } catch (error) {
+ reportTemplateOperationFailure(
+ 'Failed to refresh publication metadata',
+ error,
+ );
+ }
+ const accountStates = await getTemplateAccountStates(
+ storedTemplates.map((stored) => stored.template.id),
+ );
+ const nextTemplates = await Promise.all(
+ storedTemplates.map(async (stored): Promise => {
+ const syncId = stored.template.id;
+ const accountState = accountStates.get(syncId) ?? {
+ status: 'local' as const,
+ isPublished: false,
+ };
+ return {
+ ...toSummary(stored.template),
+ syncId,
+ accountSyncStatus: accountState.status,
+ published: accountState.isPublished,
+ publicationOutdated:
+ accountState.isPublished &&
+ (await isPublicationOutdated(
+ stored,
+ accountState.publishedContentHash,
+ )),
+ };
+ }),
+ );
if (requestId !== loadRequestIdRef.current) return;
- setTemplates(
- storedTemplates.map((stored) => toSummary(stored.template)),
- );
+ setTemplates(nextTemplates);
+ setAccountConnected(connected);
// Reading is what moves an unreadable record into quarantine, so the
// count is refreshed here rather than on mount.
setQuarantinedCount(nextQuarantinedCount);
@@ -156,7 +220,15 @@ export const TemplateListPage = () => {
}, [loadTemplates]);
const ownedTemplates = useMemo(
- () => [toSummary(defaultTemplate), ...templates.filter((template) => !template.cloned)],
+ (): TemplateListItem[] => [
+ {
+ ...toSummary(defaultTemplate),
+ accountSyncStatus: 'local',
+ published: false,
+ publicationOutdated: false,
+ },
+ ...templates.filter((template) => !template.cloned),
+ ],
[defaultTemplate, templates],
);
const clonedTemplates = useMemo(
@@ -203,7 +275,14 @@ export const TemplateListPage = () => {
});
};
- const handleDeleteTemplate = async (template: TemplateSummary) => {
+ const handleDeleteTemplate = async (template: TemplateListItem) => {
+ if (template.published) {
+ toast({
+ title: '게시 중인 템플릿',
+ description: '게시를 내린 뒤 삭제해 주세요.',
+ });
+ return;
+ }
if (!confirm(`“${template.name}” 템플릿을 삭제하시겠습니까?`)) return;
try {
if (
@@ -233,45 +312,73 @@ export const TemplateListPage = () => {
title: '삭제 완료',
description: '이 기기의 저장소에서 삭제했습니다.',
});
+ window.dispatchEvent(new Event('linku:templates-changed'));
} catch (error) {
- captureErrorLog('Failed to delete local template', error);
+ reportTemplateOperationFailure('Failed to delete local template', error);
toast({
title: '삭제 실패',
- description: '이 기기의 저장소에서 템플릿을 삭제하지 못했습니다.',
+ description:
+ error instanceof Error
+ ? error.message
+ : '이 기기의 저장소에서 템플릿을 삭제하지 못했습니다.',
variant: 'destructive',
});
}
};
- const handleShareTemplate = async (templateId: number) => {
- setActionLoading(templateId);
+ const ensureAccount = async (): Promise => {
+ if (await isLoggedIn()) return true;
+ const result = await startGoogleLogin();
+ if (!result.success) {
+ toast({ title: 'Google 로그인 필요', description: result.error });
+ return false;
+ }
+ setAccountConnected(true);
+ return true;
+ };
+
+ const handleSync = async () => {
+ setSyncing(true);
try {
- const stored =
- templateId === UNSAVED_TEMPLATE_ID
- ? { template: defaultTemplate }
- : await getLocalTemplate(templateId);
- if (!stored) throw new Error('이 기기에서 템플릿을 찾을 수 없습니다.');
+ if (!(await ensureAccount())) return;
+ const result = await syncAccount();
+ const feedback = getAccountSyncFeedback(result);
+ toast({
+ title: feedback.title,
+ description: feedback.description,
+ variant: feedback.destructive ? 'destructive' : 'default',
+ });
+ await loadTemplates();
+ } catch (error) {
+ reportTemplateOperationFailure('Failed to sync templates', error);
+ toast({
+ title: '동기화 실패',
+ description:
+ error instanceof Error ? error.message : '잠시 후 다시 시도해 주세요.',
+ variant: 'destructive',
+ });
+ } finally {
+ setSyncing(false);
+ }
+ };
- const share = await createTemplateShareUrl(stored.template);
- if (share.mode === 'url') {
- await navigator.clipboard.writeText(share.url);
- toast({
- title: '공유 링크 복사 완료',
- description: '템플릿 데이터는 링크의 fragment에만 들어 있습니다.',
- });
- } else {
- downloadTemplatePayload(share.payload);
- toast({
- title: '공유 파일 저장 완료',
- description: '링크에 담기 큰 템플릿이라 파일로 저장했습니다.',
- });
- }
+ const handlePublish = async (template: TemplateListItem) => {
+ if (!template.syncId) return;
+ setActionLoading(template.templateId);
+ try {
+ if (!(await ensureAccount())) return;
+ await publishLocalTemplate(template.syncId);
+ await loadTemplates();
+ toast({
+ title: template.published ? '게시물 업데이트 완료' : '템플릿 게시 완료',
+ description: '커뮤니티에 현재 저장본을 공개했습니다.',
+ });
} catch (error) {
- captureErrorLog('Failed to share template', error);
+ reportTemplateOperationFailure('Failed to publish template', error);
toast({
- title: '공유 실패',
+ title: '게시 실패',
description:
- error instanceof Error ? error.message : '공유 데이터를 만들지 못했습니다.',
+ error instanceof Error ? error.message : '잠시 후 다시 시도해 주세요.',
variant: 'destructive',
});
} finally {
@@ -279,48 +386,25 @@ export const TemplateListPage = () => {
}
};
- const handleImportFile = async (file: File | undefined) => {
- if (!file) return;
+ const handleUnpublish = async (template: TemplateListItem) => {
+ if (!template.syncId || !confirm(`“${template.name}” 게시를 내리시겠습니까?`)) {
+ return;
+ }
+ setActionLoading(template.templateId);
try {
- let value: unknown;
- try {
- if (file.size > MAX_SHARE_FILE_BYTES) {
- throw new Error('템플릿 가져오기 파일은 256KB 이하여야 합니다.');
- }
- value = JSON.parse(await file.text()) as unknown;
- validateTemplateSharePayload(value);
- } catch (error) {
- toast({
- title: '가져오기 실패',
- description:
- error instanceof Error ? error.message : '템플릿 파일을 읽지 못했습니다.',
- variant: 'destructive',
- });
- return;
- }
-
- try {
- const imported = await importSharedTemplate(value);
- await loadTemplates();
- setActiveTab('cloned');
- toast({
- title: '템플릿 가져오기 완료',
- description: `“${imported.template.name}”을 이 기기에 저장했습니다.`,
- });
- } catch (error) {
- reportTemplateOperationFailure(
- 'Failed to store an imported template',
- error,
- );
- toast({
- title: '가져오기 실패',
- description:
- error instanceof Error ? error.message : '템플릿을 저장하지 못했습니다.',
- variant: 'destructive',
- });
- }
+ await unpublishLocalTemplate(template.syncId);
+ await loadTemplates();
+ toast({ title: '게시를 내렸습니다' });
+ } catch (error) {
+ reportTemplateOperationFailure('Failed to unpublish template', error);
+ toast({
+ title: '게시 내리기 실패',
+ description:
+ error instanceof Error ? error.message : '잠시 후 다시 시도해 주세요.',
+ variant: 'destructive',
+ });
} finally {
- if (importInputRef.current) importInputRef.current.value = '';
+ setActionLoading(null);
}
};
@@ -368,6 +452,7 @@ export const TemplateListPage = () => {
parsedBackup,
);
await loadTemplates();
+ window.dispatchEvent(new Event('linku:templates-changed'));
const hasRestoreWarnings =
result.skipped > 0 || result.failedAssets > 0;
toast({
@@ -429,56 +514,116 @@ export const TemplateListPage = () => {
return (
- {visibleTemplates.map((template) => (
-
navigate(`/editor/${template.templateId}`)
- }
- isSelected={
- selectedTemplateId === null
- ? template.templateId === UNSAVED_TEMPLATE_ID
- : selectedTemplateId === template.templateId
- }
- onApply={(event) => {
- event.stopPropagation();
- void handleApplyTemplate(template);
- }}
- onDelete={(event) => {
- event.stopPropagation();
- void handleDeleteTemplate(template);
- }}
- onShare={(event) => {
- event.stopPropagation();
- void handleShareTemplate(template.templateId);
- }}
- showDelete={template.templateId !== UNSAVED_TEMPLATE_ID}
- isActionLoading={actionLoading === template.templateId}
- />
- ))}
+ {visibleTemplates.map((template) => {
+ const isStored = template.templateId !== UNSAVED_TEMPLATE_ID;
+ const isBusy = actionLoading === template.templateId;
+ const syncLabel = {
+ error: '동기화 지연',
+ local: accountConnected ? '동기화 전' : '이 기기에 저장',
+ pending: '동기화 대기',
+ synced: '동기화됨',
+ }[template.accountSyncStatus];
+
+ return (
+
+ navigate(`/editor/${template.templateId}`)
+ : undefined
+ }
+ isSelected={
+ selectedTemplateId === null
+ ? !isStored
+ : selectedTemplateId === template.templateId
+ }
+ onApply={(event) => {
+ event.stopPropagation();
+ void handleApplyTemplate(template);
+ }}
+ onDelete={(event) => {
+ event.stopPropagation();
+ void handleDeleteTemplate(template);
+ }}
+ showDelete={isStored}
+ isActionLoading={isBusy}
+ />
+
+ {isStored && (
+
+
+ {template.accountSyncStatus === 'synced' ? (
+
+ ) : (
+
+ )}
+ {syncLabel}
+ {template.published && (
+
+ {template.publicationOutdated ? '업데이트 필요' : '게시됨'}
+
+ )}
+
+
+
+ {(!template.published || template.publicationOutdated) && (
+ void handlePublish(template)}
+ >
+ {template.published ? '게시물 업데이트' : '게시'}
+
+ )}
+ {template.published && (
+ void handleUnpublish(template)}
+ >
+ 게시 내리기
+
+ )}
+
+
+ )}
+
+ );
+ })}
);
};
return (
-
+
내 템플릿
- 로그인이나 서버 연결 없이 이 기기에 바로 저장합니다.
+ 먼저 이 기기에 저장하고, 로그인하면 여러 기기와 동기화합니다.
-
-
navigate('/gallery')}>
+
+
void handleSync()}
+ >
+
+ {accountConnected ? '지금 동기화' : '로그인하고 동기화'}
+
+
navigate('/gallery')}>
둘러보기
- 새 템플릿
+
+ 새 템플릿
+
@@ -487,9 +632,6 @@ export const TemplateListPage = () => {
빈 템플릿에서 시작
- importInputRef.current?.click()}>
- 파일에서 가져오기
-
void handleDownloadBackup()}>
전체 백업 내려받기
@@ -498,13 +640,6 @@ export const TemplateListPage = () => {
-
void handleImportFile(event.target.files?.[0])}
- />
{
+ const database = await getLinkuDb();
+ const entries = await database.getAllFromIndex("outbox", "by-queued-at");
+ return entries.map((entry) => ({
+ ...entry,
+ generation:
+ entry.generation ??
+ `${entry.key}:${entry.queuedAt}:${entry.operation}`,
+ resource: entry.resource ?? "template",
+ }));
+}
+
+export async function isSyncOutboxEntryCurrent(
+ expected: SyncOutboxEntry,
+): Promise
{
+ const database = await getLinkuDb();
+ const current = await database.get("outbox", expected.key);
+ return current ? isCurrentOperation(current, expected) : false;
+}
+
+function isCurrentOperation(
+ current: SyncOutboxEntry,
+ expected: SyncOutboxEntry,
+): boolean {
+ const currentGeneration =
+ current.generation ??
+ `${current.key}:${current.queuedAt}:${current.operation}`;
+ return currentGeneration === expected.generation;
+}
+
+export async function removeSyncOutboxEntry(
+ expected: SyncOutboxEntry,
+): Promise {
+ const database = await getLinkuDb();
+ const transaction = database.transaction("outbox", "readwrite");
+ const store = transaction.objectStore("outbox");
+ const current = await store.get(expected.key);
+ if (current && isCurrentOperation(current, expected)) {
+ await store.delete(expected.key);
+ }
+ await transaction.done;
+}
+
+export async function markSyncAttempt(
+ expected: SyncOutboxEntry,
+ metadataKey: string,
+ message: string,
+): Promise {
+ const database = await getLinkuDb();
+ const transaction = database.transaction(["outbox", "syncMeta"], "readwrite");
+ const outbox = transaction.objectStore("outbox");
+ const current = await outbox.get(expected.key);
+ if (current && isCurrentOperation(current, expected)) {
+ await outbox.put({ ...current, attempts: current.attempts + 1 });
+ const metadataStore = transaction.objectStore("syncMeta");
+ const metadata = await metadataStore.get(metadataKey);
+ await metadataStore.put({
+ ...metadata,
+ key: metadataKey,
+ lastError: message,
+ });
+ }
+ await transaction.done;
+}
+
+export async function getSyncMetadata(
+ key: string,
+): Promise {
+ const database = await getLinkuDb();
+ return database.get("syncMeta", key);
+}
+
+export async function setSyncMetadata(metadata: SyncMetadata): Promise {
+ const database = await getLinkuDb();
+ await database.put("syncMeta", metadata);
+}
+
+export interface PublicationMetadataState {
+ templateId: string;
+ revision: number;
+ contentHash?: string;
+ isPublished: boolean;
+}
+
+export async function replacePublicationMetadata(
+ accountId: string,
+ publications: PublicationMetadataState[],
+): Promise {
+ const database = await getLinkuDb();
+ const transaction = database.transaction("syncMeta", "readwrite");
+ const store = transaction.objectStore("syncMeta");
+ const prefix = `${accountId}:template:`;
+ const metadataEntries = await store.getAll();
+ const metadataByKey = new Map(
+ metadataEntries.map((metadata) => [metadata.key, metadata]),
+ );
+ const publicationIds = new Set(
+ publications.map((publication) => publication.templateId),
+ );
+
+ for (const publication of publications) {
+ const key = `${prefix}${publication.templateId}`;
+ await store.put({
+ ...metadataByKey.get(key),
+ key,
+ publicationRevision: publication.revision,
+ publishedContentHash: publication.contentHash,
+ isPublished: publication.isPublished,
+ });
+ }
+
+ for (const metadata of metadataEntries) {
+ if (!metadata.key.startsWith(prefix)) continue;
+ const templateId = metadata.key.slice(prefix.length);
+ if (publicationIds.has(templateId)) continue;
+ if (
+ metadata.publicationRevision === undefined &&
+ metadata.publishedContentHash === undefined &&
+ metadata.isPublished === undefined
+ ) {
+ continue;
+ }
+ await store.put({
+ ...metadata,
+ publicationRevision: undefined,
+ publishedContentHash: undefined,
+ isPublished: false,
+ });
+ }
+ await transaction.done;
+}
+
+export async function completeSyncOperation(
+ expected: SyncOutboxEntry,
+ metadata: SyncMetadata,
+): Promise {
+ const database = await getLinkuDb();
+ const transaction = database.transaction(["outbox", "syncMeta"], "readwrite");
+ const outbox = transaction.objectStore("outbox");
+ const current = await outbox.get(expected.key);
+ if (current && isCurrentOperation(current, expected)) {
+ await outbox.delete(expected.key);
+ }
+ await transaction.objectStore("syncMeta").put(metadata);
+ await transaction.done;
+}
+
+export async function activateSyncAccount(accountId: string): Promise {
+ const database = await getLinkuDb();
+ const current = await database.get("settings", ACTIVE_ACCOUNT_KEY);
+ if (current?.value === accountId) return false;
+ if (typeof current?.value === "string") {
+ throw new SyncAccountMismatchError();
+ }
+
+ const transaction = database.transaction(
+ ["assets", "templates", "settings", "outbox"],
+ "readwrite",
+ );
+ const [assets, templates] = await Promise.all([
+ transaction.objectStore("assets").getAll(),
+ transaction.objectStore("templates").getAll(),
+ ]);
+ const outbox = transaction.objectStore("outbox");
+ await outbox.clear();
+ const queuedAt = Date.now();
+ for (const stored of templates) {
+ await outbox.put(
+ createSyncOutboxEntry("template", stored.template.id, "put", queuedAt),
+ );
+ }
+ for (const asset of assets) {
+ await outbox.put(createSyncOutboxEntry("asset", asset.id, "put", queuedAt));
+ }
+ await transaction.objectStore("settings").put({
+ key: ACTIVE_ACCOUNT_KEY,
+ value: accountId,
+ });
+ await transaction.done;
+ return true;
+}
+
+export async function clearCloudSyncState(): Promise {
+ const database = await getLinkuDb();
+ const transaction = database.transaction(["outbox", "syncMeta"], "readwrite");
+ await Promise.all([
+ transaction.objectStore("outbox").clear(),
+ transaction.objectStore("syncMeta").clear(),
+ ]);
+ await transaction.done;
+}
+
+export async function resetSyncConnection(): Promise {
+ const database = await getLinkuDb();
+ const transaction = database.transaction(
+ ["settings", "outbox", "syncMeta"],
+ "readwrite",
+ );
+ await Promise.all([
+ transaction.objectStore("settings").delete(ACTIVE_ACCOUNT_KEY),
+ transaction.objectStore("outbox").clear(),
+ transaction.objectStore("syncMeta").clear(),
+ ]);
+ await transaction.done;
+}
+
+export async function getActiveSyncAccountId(): Promise {
+ const database = await getLinkuDb();
+ const account = await database.get("settings", ACTIVE_ACCOUNT_KEY);
+ return typeof account?.value === "string" ? account.value : null;
+}
+
+export interface TemplateAccountState {
+ status: AccountSyncStatus;
+ isPublished: boolean;
+ publishedContentHash?: string;
+}
+
+export async function getTemplateAccountStates(
+ resourceIds: string[],
+): Promise