[feat] solution/frontend: 내 사이트 · 내 정보 — 로그인 후에 갈 곳이 생겼다
로그인해도 갈 곳이 없었다. 사업장 목록은 내부 운영 앱(admin)으로 나갔고 사장님 앱에는 그 경로가 없다. 아임웹도 같은 자리를 계정 레벨(내사이트 · 마이페이지)로 두고, 사이트 레벨(관리자 페이지)과 가른다 — 우리는 그 사이트 레벨이 에디터다. - pages/SitesPage: 줄을 누르면 에디터로 간다(목록에 온 용건은 열에 아홉 "내 사이트 고치기"). [사이트 열기] 는 PUBLISHED 일 때만 — 주소는 발행 전에 예약돼서, 주소만 보고 열면 404 다. ⋯ 메뉴에는 [발행 내리기] 하나. ★ 삭제는 두지 않았다 — 색인된 페이지를 404 로 만들면 그 자리를 다시 OTA 가 가져가고 되돌릴 방법이 사장님에게 없다(sites.status 주석) - pages/AccountPage: PATCH /v1/auth/me 가 받는 것만 그린다. 구글 계정은 비밀번호 칸을 접는다 (서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다). 상호는 읽기 전용 — Req_UpdateMe 에 없다 - router: `/` 가 로그인 여부로 갈린다. 복구(isRestoring) 전에는 판단하지 않는다 — 아니면 새로고침마다 위저드가 번쩍이고 목록으로 튄다 - AppShell: 메뉴에 [내 사이트], 계정 이름 자리가 [내 정보] 입구 검증 — tsc·eslint·vite build 통과(frontend·admin)
This commit is contained in:
parent
479edf9403
commit
282427e10b
@ -1,20 +1,63 @@
|
||||
import {createBrowserRouter, Navigate} from 'react-router';
|
||||
import {Loader2} from 'lucide-react';
|
||||
import {AccountPage} from '@/pages/AccountPage';
|
||||
import {BuilderPage} from '@/pages/BuilderPage';
|
||||
import {DevShowcasePage} from '@/pages/DevShowcasePage';
|
||||
import {LoginPage} from '@/pages/LoginPage';
|
||||
import {NotFoundPage} from '@/pages/NotFoundPage';
|
||||
import {SignupPage} from '@/pages/SignupPage';
|
||||
import {SitesPage} from '@/pages/SitesPage';
|
||||
import {RequireAuth} from '@/components/layout/RequireAuth';
|
||||
import {useAuthStore} from '@/stores/auth';
|
||||
|
||||
/**
|
||||
* 첫 화면은 로그인 여부로 갈린다 — 이미 사이트를 가진 사장님의 용건은 "새로 만들기"가 아니라
|
||||
* "내 것 고치기"다. 비로그인은 그대로 위저드로 보낸다(관문은 에디터 진입이다).
|
||||
*
|
||||
* ★ 복구가 끝나기 전에 판단하면 새로고침할 때마다 위저드가 한 번 번쩍이고 목록으로 튄다.
|
||||
*/
|
||||
function Home() {
|
||||
const isRestoring = useAuthStore((s) => s.isRestoring);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
|
||||
if (isRestoring) {
|
||||
return (
|
||||
<div className="flex h-screen items-center justify-center text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <Navigate to={user ? '/sites' : '/builder?new=1'} replace />;
|
||||
}
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{path: '/login', element: <LoginPage />},
|
||||
// 로그인 화면의 [회원가입] 이 여기로 온다. 이 줄이 없으면 링크는 있고 목적지만 404 다.
|
||||
{path: '/signup', element: <SignupPage />},
|
||||
|
||||
// ★ 첫 화면은 업종 선택(위저드 1단계)이다.
|
||||
// ★ 비로그인의 첫 화면은 업종 선택(위저드 1단계)이다.
|
||||
// `?new=1` 을 붙이는 이유: 위저드 상태는 새로고침을 넘기려고 저장돼 있어서(stores/builder persist),
|
||||
// 그냥 /builder 로 보내면 지난번에 만들다 만 **에디터**가 복원돼 뜬다. 처음 들어오는 사람에게는
|
||||
// 그게 "왜 자꾸 빌더로 튀냐"로 보인다. 그래서 진입 경로에서 한 번 비우고 시작한다.
|
||||
{path: '/', element: <Navigate to="/builder?new=1" replace />},
|
||||
{path: '/', element: <Home />},
|
||||
|
||||
// 로그인한 사장님의 홈. 만든 사이트를 열고 고치는 자리다.
|
||||
{
|
||||
path: '/sites',
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<SitesPage />
|
||||
</RequireAuth>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/account',
|
||||
element: (
|
||||
<RequireAuth>
|
||||
<AccountPage />
|
||||
</RequireAuth>
|
||||
),
|
||||
},
|
||||
|
||||
/**
|
||||
* 빌더는 로그인 화면을 앞에 세우지 않는다 — 위저드를 열자마자 로그인부터 만나면
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import type {ComponentType, ReactNode} from 'react';
|
||||
import {Link, NavLink, useLocation, useNavigate} from 'react-router';
|
||||
import {LayoutGrid, LogIn, LogOut, Search, Wand2} from 'lucide-react';
|
||||
import {LayoutGrid, LogIn, LogOut, Search, Store, Wand2} from 'lucide-react';
|
||||
import {cn} from '@/lib/utils';
|
||||
import {userLabel, useAuthStore} from '@/stores/auth';
|
||||
|
||||
@ -23,6 +23,7 @@ export type NavItem = {
|
||||
* 남의 화면이다.
|
||||
*/
|
||||
const OWNER_NAV: NavItem[] = [
|
||||
{to: '/sites', match: '/sites', label: '내 사이트', icon: Store},
|
||||
{to: '/builder?new=1', match: '/builder', label: '새 사이트', icon: Wand2},
|
||||
];
|
||||
|
||||
@ -63,16 +64,20 @@ export function AppShell({children, nav = OWNER_NAV}: {children: ReactNode; nav?
|
||||
**비로그인 상태를 반드시 그려야 한다.** 예전엔 이름이 빈 줄로 나오고 [로그아웃]만
|
||||
남아서, 로그인한 적 없는 사람이 눌러도 아무 일이 안 일어났다(지울 세션이 없다). */}
|
||||
<div className="border-t border-sidebar-border p-3">
|
||||
<div className="mb-2 truncate text-[11px] text-sidebar-foreground">
|
||||
{user ? (
|
||||
<>
|
||||
{userLabel(user)}
|
||||
{user.companyName ? ` · ${user.companyName}` : ''}
|
||||
</>
|
||||
) : (
|
||||
{/* 이름 자리가 곧 [내 정보] 입구다 — 메뉴를 한 줄 더 늘리지 않는다(아임웹의 프로필과 같은 자리). */}
|
||||
{user ? (
|
||||
<Link
|
||||
to="/account"
|
||||
className="mb-2 block truncate rounded-md px-2 py-1 text-[11px] text-sidebar-foreground transition-colors hover:bg-sidebar-accent/60"
|
||||
>
|
||||
{userLabel(user)}
|
||||
{user.companyName ? ` · ${user.companyName}` : ''}
|
||||
</Link>
|
||||
) : (
|
||||
<div className="mb-2 truncate px-2 py-1 text-[11px] text-sidebar-foreground">
|
||||
<span className="opacity-60">로그인하지 않았습니다</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{user ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
144
solution/frontend/src/pages/AccountPage.tsx
Normal file
144
solution/frontend/src/pages/AccountPage.tsx
Normal file
@ -0,0 +1,144 @@
|
||||
import {useEffect, useState, type FormEvent} from 'react';
|
||||
import {KeyRound, Loader2} from 'lucide-react';
|
||||
import {AuthProvider, updateMe, useMe} from '@/api';
|
||||
import {AppShell, PageContainer} from '@/components/layout/AppShell';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {notify, notifyApiError} from '@/lib/notify';
|
||||
import {toAuthUser, useAuthStore} from '@/stores/auth';
|
||||
|
||||
/**
|
||||
* 내 정보 — `PATCH /v1/auth/me` 한 곳이 받는 것만 그린다.
|
||||
*
|
||||
* ★ 상호(company)는 읽기 전용이다. Req_UpdateMe 에 없다 — 자기 소속을 스스로 바꾸지 못하게
|
||||
* 일부러 뺀 필드라, 입력칸을 두면 저장을 눌러도 아무 일이 안 일어난다.
|
||||
* ★ 구글 계정에는 바꿀 비밀번호가 없다(서버가 ACCOUNT_PROVIDER_CONFLICT 로 막는다) —
|
||||
* 입력칸 자체를 그리지 않는다.
|
||||
*/
|
||||
export function AccountPage() {
|
||||
const {data, isLoading, refetch} = useMe();
|
||||
const setUser = useAuthStore((s) => s.setUser);
|
||||
|
||||
const [name, setName] = useState('');
|
||||
const [email, setEmail] = useState('');
|
||||
const [contact, setContact] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// 서버 값이 도착하면 한 번 채운다. 타이핑 중에 덮어쓰지 않게 응답이 바뀔 때만 돈다.
|
||||
useEffect(() => {
|
||||
if (!data) return;
|
||||
setName(data.name ?? '');
|
||||
setEmail(data.email ?? '');
|
||||
setContact(data.contact_number ?? '');
|
||||
}, [data]);
|
||||
|
||||
const isGoogle = data?.provider === AuthProvider.GOOGLE;
|
||||
|
||||
const handleSubmit = async (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setIsSaving(true);
|
||||
try {
|
||||
const res = await updateMe({
|
||||
name,
|
||||
email,
|
||||
contact_number: contact,
|
||||
...(password ? {password} : {}),
|
||||
});
|
||||
if (!res.result?.success) {
|
||||
notifyApiError({data: res}, '저장하지 못했습니다.');
|
||||
return;
|
||||
}
|
||||
// 사이드바가 이름을 들고 있다 — 저장하고 스토어를 안 갱신하면 새로고침 전까지 옛 이름이다.
|
||||
if (res.user_id && res.id) setUser(toAuthUser(res));
|
||||
setPassword('');
|
||||
notify.success('저장했습니다.');
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
notifyApiError(error, '저장하지 못했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<PageContainer title="내 정보" description="이름·연락처와 로그인 정보를 관리합니다.">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="max-w-lg space-y-5">
|
||||
<section className="space-y-3 rounded-xl border border-border bg-card p-5">
|
||||
<Field label="로그인 아이디">
|
||||
<p className="text-sm">{isGoogle ? (data?.email ?? '구글 계정') : (data?.id ?? '')}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{isGoogle ? '구글 계정으로 로그인합니다.' : '아이디는 바꿀 수 없습니다.'}
|
||||
</p>
|
||||
</Field>
|
||||
|
||||
<Field label="상호">
|
||||
<p className="text-sm">{data?.company?.name ?? '-'}</p>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
상호 변경은 고객센터로 문의해 주세요.
|
||||
</p>
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
|
||||
<Field label="이름">
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="홍길동" />
|
||||
</Field>
|
||||
<Field label="이메일">
|
||||
<Input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="owner@example.com"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="연락처">
|
||||
<Input value={contact} onChange={(e) => setContact(e.target.value)} placeholder="010-0000-0000" />
|
||||
</Field>
|
||||
</section>
|
||||
|
||||
{!isGoogle && (
|
||||
<section className="space-y-4 rounded-xl border border-border bg-card p-5">
|
||||
<Field label="새 비밀번호">
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="비우면 지금 비밀번호를 그대로 씁니다"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</Field>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{isGoogle && (
|
||||
<p className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<KeyRound className="size-3.5" />
|
||||
구글 계정이라 비밀번호가 없습니다 — 비밀번호는 구글에서 관리합니다.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" variant="primary" isLoading={isSaving}>
|
||||
저장
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</PageContainer>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({label, children}: {label: string; children: React.ReactNode}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-xs font-medium text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
213
solution/frontend/src/pages/SitesPage.tsx
Normal file
213
solution/frontend/src/pages/SitesPage.tsx
Normal file
@ -0,0 +1,213 @@
|
||||
import {useState} from 'react';
|
||||
import {Link, useNavigate} from 'react-router';
|
||||
import {
|
||||
Building2,
|
||||
Coffee,
|
||||
ExternalLink,
|
||||
Loader2,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Plus,
|
||||
Stethoscope,
|
||||
UtensilsCrossed,
|
||||
Wand2,
|
||||
} from 'lucide-react';
|
||||
import {PlaceCategory, publishUrlString, SiteStatus} from '@o2o/shared';
|
||||
import {changeStatus, PublishAction, useListMySites, type MySiteData} from '@/api';
|
||||
import {AppShell, EmptyState, PageContainer} from '@/components/layout/AppShell';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {notify, notifyApiError} from '@/lib/notify';
|
||||
|
||||
// 발행본 주소는 PublishModal·CanvasView 와 같은 규칙이다 — 세 곳이 다른 주소를 말하면 안 된다.
|
||||
const PUBLISH_HOST = import.meta.env.VITE_PUBLISH_HOST ?? window.location.host;
|
||||
|
||||
const CATEGORY_ICON: Record<number, typeof Building2> = {
|
||||
[PlaceCategory.LODGING]: Building2,
|
||||
[PlaceCategory.CAFE]: Coffee,
|
||||
[PlaceCategory.RESTAURANT]: UtensilsCrossed,
|
||||
[PlaceCategory.CLINIC]: Stethoscope,
|
||||
};
|
||||
|
||||
/**
|
||||
* 줄의 상태 배지. **사이트 상태(sites.status)만 본다** — 사업장 상태(places.status)는
|
||||
* 수집 단계를 말하는 값이라 사장님이 궁금한 "지금 나가 있나"와 다르다.
|
||||
*/
|
||||
function statusBadge(row: MySiteData) {
|
||||
if (!row.site_id) return {label: '만드는 중', variant: 'outline' as const};
|
||||
switch (row.status) {
|
||||
case SiteStatus.PUBLISHED:
|
||||
return row.needs_rebuild
|
||||
? {label: '수정됨 · 재발행 필요', variant: 'warning' as const}
|
||||
: {label: '발행됨', variant: 'success' as const};
|
||||
case SiteStatus.SUSPENDED:
|
||||
return {label: '중지', variant: 'outline' as const};
|
||||
case SiteStatus.UNPUBLISHED:
|
||||
return {label: '내림', variant: 'outline' as const};
|
||||
default:
|
||||
return {label: '발행 전', variant: 'default' as const};
|
||||
}
|
||||
}
|
||||
|
||||
/** 발행본이 실제로 열리는 주소. ★ 주소는 발행 전에 예약되므로 PUBLISHED 일 때만 연다 — 아니면 404 다. */
|
||||
function publishedUrl(row: MySiteData): string | null {
|
||||
if (row.status !== SiteStatus.PUBLISHED || !row.domain) return null;
|
||||
return publishUrlString(row.domain.split('.')[0], PUBLISH_HOST);
|
||||
}
|
||||
|
||||
/**
|
||||
* 내 사이트 — 로그인한 사장님의 홈이다.
|
||||
*
|
||||
* 흐름은 하나다: 위저드로 만든다 → 여기 생긴다 → 눌러서 에디터로 들어가 고친다 → 재발행한다.
|
||||
* ★ 그래서 줄을 누르면 에디터로 간다. 목록에 온 용건은 열에 아홉 "내 사이트 고치기"다.
|
||||
*/
|
||||
export function SitesPage() {
|
||||
const navigate = useNavigate();
|
||||
const {data, isLoading, isError, error, refetch} = useListMySites({size: 50});
|
||||
const [busyId, setBusyId] = useState<string | null>(null);
|
||||
const [menuId, setMenuId] = useState<string | null>(null);
|
||||
|
||||
const rows = data?.sites ?? [];
|
||||
|
||||
// 발행 내리기만 둔다. ★ 삭제 경로는 만들지 않는다 — 색인된 페이지를 404 로 만들면
|
||||
// 그 자리를 다시 OTA 가 가져가고, 되돌릴 방법이 사장님에게 없다(sites.status 주석).
|
||||
const handleUnpublish = async (row: MySiteData) => {
|
||||
if (!window.confirm(`'${row.name}' 사이트를 검색에서 내릴까요?\n주소는 그대로 두고 페이지만 내려갑니다.`)) return;
|
||||
setMenuId(null);
|
||||
setBusyId(row.place_id);
|
||||
try {
|
||||
const res = await changeStatus(row.place_id, {action: PublishAction.UNPUBLISH});
|
||||
if (!res.result?.success) {
|
||||
notifyApiError({data: res}, '사이트를 내리지 못했습니다.');
|
||||
return;
|
||||
}
|
||||
notify.success('사이트를 내렸습니다.');
|
||||
await refetch();
|
||||
} catch (unpublishError) {
|
||||
notifyApiError(unpublishError, '사이트를 내리지 못했습니다.');
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<PageContainer
|
||||
title="내 사이트"
|
||||
description="만든 사이트를 열어 고치고, 다시 발행합니다."
|
||||
actions={
|
||||
<Button variant="primary" size="sm" onClick={() => navigate('/builder?new=1')}>
|
||||
<Plus />새 사이트
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-20 text-muted-foreground">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<EmptyState
|
||||
title="목록을 불러오지 못했습니다"
|
||||
description={(error as Error)?.message ?? '잠시 후 다시 시도해 주세요.'}
|
||||
action={
|
||||
<Button size="sm" onClick={() => refetch()}>
|
||||
다시 시도
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!isLoading && !isError && rows.length === 0 && (
|
||||
<EmptyState
|
||||
icon={Wand2}
|
||||
title="아직 만든 사이트가 없습니다"
|
||||
description="상호명 하나로 시작하면 AI 가 정보를 모아 사이트를 만듭니다."
|
||||
action={
|
||||
<Button variant="primary" size="sm" onClick={() => navigate('/builder?new=1')}>
|
||||
<Plus />첫 사이트 만들기
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rows.length > 0 && (
|
||||
<ul className="divide-y divide-border overflow-hidden rounded-xl border border-border bg-card">
|
||||
{rows.map((row) => {
|
||||
const Icon = CATEGORY_ICON[row.category] ?? Building2;
|
||||
const badge = statusBadge(row);
|
||||
const url = publishedUrl(row);
|
||||
const editHref = `/builder?placeId=${row.place_id}`;
|
||||
|
||||
return (
|
||||
<li key={row.place_id} className="relative flex items-center gap-3 px-4 py-3.5 hover:bg-muted/40">
|
||||
<Icon className="size-4 shrink-0 text-muted-foreground" />
|
||||
|
||||
<Link to={editHref} className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-semibold">{row.name}</span>
|
||||
<Badge variant={badge.variant}>{badge.label}</Badge>
|
||||
</div>
|
||||
<p className="mt-0.5 truncate text-xs text-muted-foreground">
|
||||
{url ?? (row.domain ? `주소 예약됨 · ${row.domain}` : '주소를 아직 정하지 않았습니다')}
|
||||
</p>
|
||||
</Link>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{url && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-8 items-center gap-1.5 rounded-md border border-border px-2.5 text-xs font-medium transition-colors hover:bg-muted"
|
||||
>
|
||||
<ExternalLink className="size-3.5" />
|
||||
사이트 열기
|
||||
</a>
|
||||
)}
|
||||
<Button size="sm" onClick={() => navigate(editHref)}>
|
||||
<Pencil />
|
||||
{row.site_id ? '편집' : '이어서 만들기'}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
aria-label="더보기"
|
||||
isLoading={busyId === row.place_id}
|
||||
onClick={() => setMenuId(menuId === row.place_id ? null : row.place_id)}
|
||||
>
|
||||
{busyId === row.place_id ? null : <MoreHorizontal />}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{menuId === row.place_id && (
|
||||
<>
|
||||
{/* 바깥을 눌러 닫는다. 메뉴 하나짜리라 팝오버 라이브러리를 들이지 않는다. */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="닫기"
|
||||
className="fixed inset-0 z-10 cursor-default"
|
||||
onClick={() => setMenuId(null)}
|
||||
/>
|
||||
<div className="absolute right-4 top-12 z-20 w-44 rounded-md border border-border bg-card py-1 shadow-md">
|
||||
<button
|
||||
type="button"
|
||||
disabled={row.status !== SiteStatus.PUBLISHED}
|
||||
onClick={() => handleUnpublish(row)}
|
||||
className="w-full cursor-pointer px-3 py-2 text-left text-xs transition-colors hover:bg-muted disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
발행 내리기
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</PageContainer>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user