feat: add global 'a' keyboard shortcut for navigation and refactor backend routes for improved code structure and authentication logic
This commit is contained in:
parent
7d698786d0
commit
89c831cba4
5 changed files with 405 additions and 759 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -24,7 +24,7 @@ export default new Elysia({ prefix: "/proxy" })
|
|||
|
||||
const response = await fetch(targetUrl, {
|
||||
headers: {
|
||||
"User-Agent": "Mozilla/5.0 (compatible; bot/1.0)",
|
||||
"User-Agent": "Mozilla/5.0 (compatible; akiyama.mizuki.guru/1.0)",
|
||||
"Referer": "https://twitter.com/",
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,9 @@ import { useRouter } from "next/navigation";
|
|||
import Header from "../../components/header";
|
||||
import { proxyMediaUrl } from "../../lib/media";
|
||||
|
||||
// ==========================================
|
||||
// Types & Types Guards
|
||||
// ==========================================
|
||||
type SourceType = "twitter" | "pixiv" | "direct";
|
||||
|
||||
type PreviewItem = {
|
||||
|
|
@ -55,54 +58,65 @@ type ExistsApiResponse = {
|
|||
documentId: string | null;
|
||||
};
|
||||
|
||||
// ==========================================
|
||||
// Helper Functions
|
||||
// ==========================================
|
||||
function detectSource(url: string): SourceType | null {
|
||||
if (/^https?:\/\/(www\.)?(x\.com|twitter\.com|fxtwitter\.com|fixupx\.com|vxwitter\.com)\//.test(url)) {
|
||||
return "twitter";
|
||||
}
|
||||
|
||||
if (/^https?:\/\/(www\.)?pixiv\.net\//.test(url)) {
|
||||
return "pixiv";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function splitTags(text: string) {
|
||||
function splitTags(text: string): string[] {
|
||||
return text
|
||||
.split(/[\n,]/)
|
||||
.map((tag) => tag.trim().replace(/^#/, ""))
|
||||
.filter((tag) => tag.length > 0);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Main Component
|
||||
// ==========================================
|
||||
export default function AddPage() {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Form States
|
||||
const [url, setUrl] = useState("");
|
||||
const [author, setAuthor] = useState("");
|
||||
const [tagsText, setTagsText] = useState("");
|
||||
const [uploadMode, setUploadMode] = useState<"url" | "direct">("url");
|
||||
|
||||
// UI & API Response States
|
||||
const [previewItems, setPreviewItems] = useState<PreviewItem[]>([]);
|
||||
const [selected, setSelected] = useState<boolean[]>([]);
|
||||
const [sourceType, setSourceType] = useState<SourceType | null>(null);
|
||||
const [lastFetchedUrl, setLastFetchedUrl] = useState("");
|
||||
const [existingDetailId, setExistingDetailId] = useState<string | null>(null);
|
||||
|
||||
// Global Status States
|
||||
const [loadingPreview, setLoadingPreview] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
const [lastFetchedUrl, setLastFetchedUrl] = useState("");
|
||||
|
||||
// Auth States
|
||||
const [viewerRole, setViewerRole] = useState<Me["role"] | "guest">("guest");
|
||||
const [loadingRole, setLoadingRole] = useState(true);
|
||||
const [existingDetailId, setExistingDetailId] = useState<string | null>(null);
|
||||
const [uploadMode, setUploadMode] = useState<"url" | "direct">("url");
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const selectedCount = useMemo(
|
||||
() => selected.filter(Boolean).length,
|
||||
[selected],
|
||||
);
|
||||
const canPreview = url.trim().length > 0 && !loadingPreview;
|
||||
const canSubmit = !submitting && previewItems.length > 0 && selectedCount > 0;
|
||||
// Memoized Values
|
||||
const selectedCount = useMemo(() => selected.filter(Boolean).length, [selected]);
|
||||
const tags = useMemo(() => splitTags(tagsText), [tagsText]);
|
||||
const sourceLabel = sourceType === "twitter" ? "𝕏" : sourceType === "pixiv" ? "Pixiv" : "-";
|
||||
const canManagePost = viewerRole === "admin" || viewerRole === "writer";
|
||||
const canPreview = url.trim().length > 0 && !loadingPreview;
|
||||
const canSubmit = !submitting && previewItems.length > 0 && selectedCount > 0;
|
||||
|
||||
// 1. Auth Hook
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
|
|
@ -111,31 +125,52 @@ export default function AddPage() {
|
|||
const response = await fetch("/api/auth/me", { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
if (active) setViewerRole("guest");
|
||||
window.location.href = "/api/auth/discord/login";
|
||||
return;
|
||||
}
|
||||
|
||||
const me = (await response.json()) as Me;
|
||||
if (active) {
|
||||
setViewerRole(me.role);
|
||||
}
|
||||
if (active) setViewerRole(me.role);
|
||||
} catch {
|
||||
if (active) {
|
||||
setViewerRole("guest");
|
||||
}
|
||||
if (active) setViewerRole("guest");
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoadingRole(false);
|
||||
}
|
||||
if (active) setLoadingRole(false);
|
||||
}
|
||||
}
|
||||
|
||||
void loadMe();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
// 2. Clipboard Initialization
|
||||
useEffect(() => {
|
||||
navigator.clipboard.readText()
|
||||
.then((clipboardText) => {
|
||||
if (!clipboardText.trim()) return;
|
||||
setUrl(clipboardText);
|
||||
resetPreview();
|
||||
void fetchPreview(clipboardText);
|
||||
})
|
||||
.catch(() => {
|
||||
// 클립보드 거부 시 예외 처리 방지
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 3. Auto-Fetch Preview on URL Change (Debounce)
|
||||
useEffect(() => {
|
||||
if (loadingPreview) return;
|
||||
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed || trimmed === lastFetchedUrl) return;
|
||||
if (!detectSource(trimmed)) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
void fetchPreview(trimmed);
|
||||
}, 450);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [url, lastFetchedUrl, loadingPreview]);
|
||||
|
||||
// Actions
|
||||
function resetPreview() {
|
||||
previewItems.forEach((item) => {
|
||||
if (item.file) URL.revokeObjectURL(item.url);
|
||||
|
|
@ -186,10 +221,7 @@ export default function AddPage() {
|
|||
const response = await fetch(`/api/tweet/fetch?url=${encodeURIComponent(trimmedUrl)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`트위터 미리보기 요청 실패: ${response.status}`);
|
||||
}
|
||||
if (!response.ok) throw new Error(`트위터 미리보기 요청 실패: ${response.status}`);
|
||||
|
||||
const data = (await response.json()) as TweetApiResponse;
|
||||
const mediaItems = data.tweet?.media?.all || data.tweet?.media?.photos || [];
|
||||
|
|
@ -200,47 +232,36 @@ export default function AddPage() {
|
|||
}))
|
||||
.filter((item) => item.url.length > 0);
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new Error("이미지를 찾지 못했습니다.");
|
||||
}
|
||||
if (items.length === 0) throw new Error("이미지를 찾지 못했습니다.");
|
||||
|
||||
setPreviewItems(items);
|
||||
setSelected(items.map(() => true));
|
||||
if (!author.trim()) {
|
||||
setAuthor(data.tweet?.author?.name ?? "");
|
||||
}
|
||||
if (!author.trim()) setAuthor(data.tweet?.author?.name ?? "");
|
||||
setLastFetchedUrl(trimmedUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pixiv Source
|
||||
const response = await fetch(`/api/pixiv/fetch?url=${encodeURIComponent(trimmedUrl)}`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`픽시브 미리보기 요청 실패: ${response.status}`);
|
||||
}
|
||||
if (!response.ok) throw new Error(`픽시브 미리보기 요청 실패: ${response.status}`);
|
||||
|
||||
const data = (await response.json()) as PixivApiResponse;
|
||||
const items = (data.image_proxy_urls ?? [])
|
||||
.filter((imageUrl): imageUrl is string => typeof imageUrl === "string" && imageUrl.length > 0)
|
||||
.map((imageUrl) => ({ url: imageUrl }));
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new Error("이미지를 찾지 못했습니다.");
|
||||
}
|
||||
if (items.length === 0) throw new Error("이미지를 찾지 못했습니다.");
|
||||
|
||||
setPreviewItems(items);
|
||||
setSelected(items.map(() => true));
|
||||
if (!author.trim()) {
|
||||
setAuthor(data.author_name ?? "");
|
||||
}
|
||||
if (!author.trim()) setAuthor(data.author_name ?? "");
|
||||
if (!tagsText.trim()) {
|
||||
const pixivTags = (data.tags ?? []).map((tag) => tag.replace(/^#/, "")).join(", ");
|
||||
setTagsText(pixivTags);
|
||||
setTagsText((data.tags ?? []).map((tag) => tag.replace(/^#/, "")).join(", "));
|
||||
}
|
||||
|
||||
setLastFetchedUrl(trimmedUrl);
|
||||
|
||||
} catch (fetchError) {
|
||||
setPreviewItems([]);
|
||||
setSelected([]);
|
||||
|
|
@ -266,101 +287,50 @@ export default function AddPage() {
|
|||
setSelected((prev) => [...prev, ...newItems.map(() => true)]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (loadingPreview) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmed = url.trim();
|
||||
if (!trimmed || trimmed === lastFetchedUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!detectSource(trimmed)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = window.setTimeout(() => {
|
||||
void fetchPreview(trimmed);
|
||||
}, 450);
|
||||
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [lastFetchedUrl, loadingPreview, url]);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (!canManagePost) {
|
||||
setError("writer 또는 admin 권한이 필요합니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!sourceType) {
|
||||
setError("먼저 미리보기를 불러와 주세요.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (previewItems.length === 0) {
|
||||
setError("업로드할 이미지가 없습니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedCount === 0) {
|
||||
setError("최소 한 장 이상 선택해 주세요.");
|
||||
return;
|
||||
}
|
||||
if (!canManagePost) return setError("writer 또는 admin 권한이 필요합니다.");
|
||||
if (!sourceType) return setError("먼저 미리보기를 불러와 주세요.");
|
||||
if (previewItems.length === 0) return setError("업로드할 이미지가 없습니다.");
|
||||
if (selectedCount === 0) return setError("최소 한 장 이상 선택해 주세요.");
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const tags = splitTags(tagsText);
|
||||
const currentTags = splitTags(tagsText);
|
||||
let response: Response;
|
||||
|
||||
if (uploadMode === "direct") {
|
||||
const formData = new FormData();
|
||||
previewItems.forEach((item, index) => {
|
||||
if (selected[index] && item.file) {
|
||||
formData.append("files", item.file);
|
||||
}
|
||||
if (selected[index] && item.file) formData.append("files", item.file);
|
||||
});
|
||||
if (author.trim()) formData.append("author", author.trim());
|
||||
tags.forEach((t) => formData.append("tag", t));
|
||||
currentTags.forEach((t) => formData.append("tag", t));
|
||||
|
||||
response = await fetch("/api/post/upload/direct", {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
});
|
||||
response = await fetch("/api/post/upload/direct", { method: "POST", body: formData });
|
||||
} else {
|
||||
response = await fetch("/api/post/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: url.trim(),
|
||||
author: author.trim() || undefined,
|
||||
tag: tags.length > 0 ? tags : undefined,
|
||||
tag: currentTags.length > 0 ? currentTags : undefined,
|
||||
selected,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
let data: UploadApiResponse | null = null;
|
||||
try {
|
||||
data = (await response.json()) as UploadApiResponse;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
try { data = await response.json() as UploadApiResponse; } catch { data = null; }
|
||||
|
||||
const message = data?.message || `업로드 실패: ${response.status}`;
|
||||
if (!response.ok || !data?.success) throw new Error(message);
|
||||
|
||||
if (!response.ok || !data?.success) {
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const uploadedCount = data.savedCount ?? selectedCount;
|
||||
if (uploadedCount > 0) {
|
||||
if ((data.savedCount ?? selectedCount) > 0) {
|
||||
setUrl("");
|
||||
setAuthor("");
|
||||
setTagsText("");
|
||||
|
|
@ -368,10 +338,8 @@ export default function AddPage() {
|
|||
}
|
||||
|
||||
setSuccess(message);
|
||||
if (data?.ids?.[0]) router.push(`/detail/${data.ids[0]}`);
|
||||
|
||||
if (data?.ids && data.ids.length > 0) {
|
||||
router.push(`/detail/${data.ids[0]}`);
|
||||
}
|
||||
} catch (submitError) {
|
||||
setError(submitError instanceof Error ? submitError.message : "업로드에 실패했습니다.");
|
||||
} finally {
|
||||
|
|
@ -384,6 +352,7 @@ export default function AddPage() {
|
|||
<Header />
|
||||
|
||||
<main className="mx-auto w-full max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{/* Top Info Section */}
|
||||
<section className="border-b border-border/70 pb-6">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
|
|
@ -401,56 +370,40 @@ export default function AddPage() {
|
|||
</div>
|
||||
|
||||
<form className="mt-5 space-y-6" onSubmit={submit}>
|
||||
{!loadingRole && !canManagePost ? (
|
||||
{!loadingRole && !canManagePost && (
|
||||
<div className="border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
|
||||
업로드 권한이 없습니다. writer 또는 admin 권한이 필요합니다.
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex gap-6 border-b border-border/40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUploadMode("url");
|
||||
resetPreview();
|
||||
}}
|
||||
className={`pb-2 text-sm font-medium transition ${uploadMode === "url" ? "border-b-2 border-foreground text-foreground" : "text-foreground/40 hover:text-foreground/60"}`}
|
||||
>
|
||||
URL 가져오기
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setUploadMode("direct");
|
||||
resetPreview();
|
||||
}}
|
||||
className={`pb-2 text-sm font-medium transition ${uploadMode === "direct" ? "border-b-2 border-foreground text-foreground" : "text-foreground/40 hover:text-foreground/60"}`}
|
||||
>
|
||||
직접 업로드
|
||||
</button>
|
||||
{(["url", "direct"] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
type="button"
|
||||
onClick={() => { setUploadMode(mode); resetPreview(); }}
|
||||
className={`pb-2 text-sm font-medium transition ${uploadMode === mode ? "border-b-2 border-foreground text-foreground" : "text-foreground/40 hover:text-foreground/60"}`}
|
||||
>
|
||||
{mode === "url" ? "URL 가져오기" : "직접 업로드"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Input Area Group */}
|
||||
{uploadMode === "url" ? (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-foreground/80" htmlFor="url">URL</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(event) => {
|
||||
setUrl(event.target.value);
|
||||
|
||||
if (previewItems.length > 0 || sourceType) {
|
||||
resetPreview();
|
||||
}
|
||||
}}
|
||||
placeholder="https://x.com/... or https://www.pixiv.net/artworks/..."
|
||||
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none ring-0 transition focus:border-foreground/40"
|
||||
disabled={loadingPreview}
|
||||
required={uploadMode === "url"}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
id="url"
|
||||
type="url"
|
||||
value={url}
|
||||
onChange={(e) => { setUrl(e.target.value); resetPreview(); }}
|
||||
placeholder="https://x.com/... or https://www.pixiv.net/artworks/..."
|
||||
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none ring-0 transition focus:border-foreground/40"
|
||||
disabled={loadingPreview}
|
||||
required
|
||||
/>
|
||||
<p className="text-xs text-foreground/55">URL 변경 시 기존 미리보기는 자동으로 초기화됩니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -479,6 +432,7 @@ export default function AddPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Metadata Inputs */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-foreground/80" htmlFor="author">작가</label>
|
||||
|
|
@ -486,7 +440,7 @@ export default function AddPage() {
|
|||
id="author"
|
||||
type="text"
|
||||
value={author}
|
||||
onChange={(event) => setAuthor(event.target.value)}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40"
|
||||
/>
|
||||
<p className="text-xs text-red-600/50">* 자동으로 작가를 가져옵니다. 만약 잘못된 이름이라면 수동으로 수정할 수 있습니다.</p>
|
||||
|
|
@ -497,29 +451,31 @@ export default function AddPage() {
|
|||
id="tags"
|
||||
type="text"
|
||||
value={tagsText}
|
||||
onChange={(event) => setTagsText(event.target.value)}
|
||||
placeholder=""
|
||||
onChange={(e) => setTagsText(e.target.value)}
|
||||
className="w-full border-b border-border bg-transparent px-2 py-2 text-sm outline-none transition focus:border-foreground/40"
|
||||
/>
|
||||
<p className="text-xs text-foreground/50">* 자동으로 태그를 가져옵니다. 쉼표(,)나 줄바꿈으로 구분하여 여러 개 입력할 수 있습니다.</p>
|
||||
<p className="text-xs text-foreground/50">* 자동으로 태그를 가져옵니다. 쉼표(,)나 줄바꿈으로 구분하여 여러 개 입력할 수 있습니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
{success ? <p className="text-sm text-emerald-600">{success} {existingDetailId ? (
|
||||
<Link
|
||||
href={`/detail/${existingDetailId}`}
|
||||
className="underline"
|
||||
>
|
||||
상세 페이지로 이동
|
||||
</Link>
|
||||
) : null}</p> : null}
|
||||
{/* Response Messages */}
|
||||
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||
{success && (
|
||||
<p className="text-sm text-emerald-600">
|
||||
{success}{" "}
|
||||
{existingDetailId && (
|
||||
<Link href={`/detail/${existingDetailId}`} className="underline">
|
||||
상세 페이지로 이동
|
||||
</Link>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loadingPreview ? (
|
||||
{loadingPreview && (
|
||||
<div className="border-l-2 border-border/80 pl-3 text-xs text-foreground/65">
|
||||
미리보기를 불러오는 중입니다. 이미지 개수에 따라 몇 초 정도 소요될 수 있습니다.
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-foreground/60">
|
||||
<span>source: {sourceLabel}</span>
|
||||
|
|
@ -527,7 +483,8 @@ export default function AddPage() {
|
|||
<span>tags: {tags.length}</span>
|
||||
</div>
|
||||
|
||||
{previewItems.length > 0 ? (
|
||||
{/* Preview Asset Grid */}
|
||||
{previewItems.length > 0 && (
|
||||
<section className="mt-8 border-t border-border/70 pt-5">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm tracking-wide text-foreground/80">Preview</h2>
|
||||
|
|
@ -583,11 +540,11 @@ export default function AddPage() {
|
|||
}}
|
||||
/>
|
||||
</div>
|
||||
{selected[index] ? (
|
||||
{selected[index] && (
|
||||
<div className="pointer-events-none absolute right-2 top-2 border border-foreground bg-foreground px-2 py-0.5 text-[11px] text-background">
|
||||
selected
|
||||
</div>
|
||||
) : null}
|
||||
)}
|
||||
<div className="pointer-events-none absolute inset-x-2 bottom-2 truncate border border-white/20 bg-black/65 px-2 py-1 text-xs text-white opacity-0 transition group-hover:opacity-100">
|
||||
#{index + 1}
|
||||
</div>
|
||||
|
|
@ -595,8 +552,9 @@ export default function AddPage() {
|
|||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
)}
|
||||
|
||||
{/* Submit Action */}
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
|
|
@ -608,9 +566,7 @@ export default function AddPage() {
|
|||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -2,8 +2,8 @@ import type { Metadata } from "next";
|
|||
import "./globals.css";
|
||||
import "react-photo-album/masonry.css";
|
||||
|
||||
|
||||
import { ThemeProvider } from "../components/theme-provider";
|
||||
import KeyboardShortcuts from "../components/keyboard-shortcuts";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Akiyama Mizuki",
|
||||
|
|
@ -23,6 +23,7 @@ export default function RootLayout({
|
|||
>
|
||||
<body className="min-h-full flex flex-col bg-background text-foreground transition-colors duration-300">
|
||||
<ThemeProvider>
|
||||
<KeyboardShortcuts />
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
|
|
|
|||
31
apps/frontend/src/components/keyboard-shortcuts.tsx
Normal file
31
apps/frontend/src/components/keyboard-shortcuts.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
export default function KeyboardShortcuts() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
// input, textarea, contenteditable에서는 무시
|
||||
const target = event.target as HTMLElement;
|
||||
if (
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "a" || event.key === "A") {
|
||||
router.push("/add");
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", handleKeyDown);
|
||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue