This commit is contained in:
암냥 2026-04-15 00:56:08 +09:00
commit 6786cb6148
No known key found for this signature in database
31 changed files with 2352 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 128 128"><path d="M60.23 50.47s-10.56 2.98-19.38 8.8c-7.26 4.78-13.83 12.04-13.83 12.04l1.88 17.76L34.1 93l21.81-19.63s3.1-8.1 4.21-13.65c.58-2.86.88-5.66.88-5.66zM69.19 52.69s2.21 8.35 3.02 11.66c.92 3.74 2.69 9.82 2.69 9.82l21.69 15.42 8.2-16.05s-4.61-5.72-7.09-8.71-16.57-12.72-16.57-12.72z" style="fill:#af0c1a"/><path d="M13.32 98.51s.75-6.87 6.39-16.69 8.41-11.53 8.41-11.53 3.26-.76 6.15-1.11c3.12-.38 5.72-.09 5.72-.09s-3.93 4.78-6.15 9.39-3.59 8.97-1.96 9.82c1.62.85 3.43-5 6.92-10.33 3.67-5.62 6.47-8.38 6.47-8.38s2.8.09 5.72.96c3.23.96 5.02 2.6 5.02 2.6s-5.76 15.49-8.92 24.46-7.72 20.87-8.83 21.3-3.04-3.02-4.24-5.75-5.29-17.25-5.55-16.99-15.15 2.34-15.15 2.34M74.89 74.1s2.51-1.69 4.56-2.38c2.05-.68 5.68-1.26 5.68-1.26s2.18 4.26 3.88 7.17c1.71 2.9 4.49 8.88 6.28 8.02 1.79-.85-.51-6.89-1.84-9.56-1.21-2.43-3.33-5.72-3.33-5.72s2.65-.34 7.51.6c4.87.94 7.17 2.56 7.17 2.56s4.5 6.68 6.92 10.76c3.22 5.44 7.26 14.68 7.26 14.68l-17.85 1.46-8.97 19.64s-1.24.08-1.93-.34c-.68-.43-3.91-9.3-5.66-14.5-2.75-8.21-9.68-31.13-9.68-31.13" style="fill:#ff605e"/><path d="M100.1 98.73c-.69.64-7.36 17.21-7.56 17.7-1.03 2.54-2.44 3.12-2.44 3.12s1.21 1.74 3.26 1.14c1.58-.46 9.1-18.27 9.1-18.27s13.59.13 15.57-.73c2.22-.96.92-2.77.92-2.77s-17.93-1.05-18.85-.19M13.35 98.13s4.02-.73 7.72-1.71 8.44-2.37 8.44-2.37 3.74 10.22 4.86 13.65 4.04 11.1 4.04 11.1-1.27 1.37-3.1.24c-1.71-1.06-4.98-10.1-5.84-12.5-1.1-3.04-2.53-7.55-2.53-7.55s-3.88 1.29-7.11 1.88-5.42 1.02-6.28.16c-.86-.85-.2-2.9-.2-2.9M46.36 28.97l-18.55-4.76L14.7 34.87l-5.44 26 2.97 5s3.22 2.46 12.28.44c9.06-2.03 29.52-9.91 29.52-9.91s2.61 1.08 4.95 1.4 6.33-.52 6.33-.52 5.02.82 8.01.61c2.98-.21 4.26-.53 4.26-.53s9.81 5.22 19.61 7.99 17.37 3.4 19.08 3.2c2.66-.32 3.94-4.8 3.94-4.8l-1.07-9.81-14.28-30.7-29.74 8.53-1.81 2.03-7.79-2.12-10.57 2.08-1.33-1.56z" style="fill:#dc0d28"/><path d="M54.59 33.37s1.18-3.66 10.07-3.66c8.29 0 9.41 3.28 9.41 3.28s1.08 7.2.1 13.44c-.87 5.58-3.96 11.41-3.96 11.41s-2.05.51-5.22.6c-2.99.08-6.17-.67-6.17-.67S56 52.8 54.81 46.56c-1.14-6.04-.22-13.19-.22-13.19" style="fill:#ff605e"/><path d="M59.9 45.07c1.7.34 2.67-4.23 3.74-5.48 1.68-1.95 5.66-1.57 5.59-4.18-.05-2.06-7.63-3.01-10.36.81-1.9 2.67-1.68 8.31 1.03 8.85" style="fill:#fcc4bf"/><path d="M75.11 31.77s1.13 1.38 2.13 3.86c.74 1.82 1.54 4.48 1.54 4.48s3.68-3.21 6.83-4.95c3.14-1.74 18.15-7.07 18.54.2.31 5.91-10.75 5.79-15.94 7.9-3.65 1.48-8.62 4.28-8.62 4.28v6.02s2.4 2.56 8.02 3.03 20.03.3 22.87.42c5.91.25 7.98 2.9 7.98 6.05 0 3.14-1.11 4.43-.74 4.84.48.54 2.95-1.43 4.29-6.52s2.37-14.6 1.97-22.75c-.47-9.63-2.37-22.52-6.67-24.92s-16.84.8-24.2 3.95c-12.38 5.3-18 14.11-18 14.11M50.37 39.74s.46-2.37 1.33-4.25c.68-1.47 1.93-3.28 1.93-3.28s-2.36-6.15-11.05-11.14-26.87-8.89-32.66-6.69c-2.88 1.09-5.67 8.48-6.52 19.7-.73 9.74.23 21.4 3.3 27.15 2.31 4.33 8.17 7.55 9.95 5.77.87-.87-5.77-3.89.56-7.87 3.67-2.3 10.69-2.1 16.78-2.5s8.69-.1 11.89-.9 5.19-2 5.19-2-.75-2.5-.9-3.7c-.21-1.64-.34-2.98-.34-2.98s-2.11-1.19-7.65-2.11c-5.43-.91-14.35-.71-14.48-6.69-.17-7.59 8.59-5.69 12.68-4s9.49 4.99 9.99 5.49" style="fill:#ff605e"/></svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View file

@ -0,0 +1,107 @@
@import "tailwindcss";
@font-face {
font-family: "Orbit";
src: url("/fonts/Orbit-Regular.woff2") format("truetype");
}
@theme inline {
--color-background: hsl(340 40% 98%);
--color-foreground: hsl(315 21% 8%);
--color-card: hsl(340 40% 98%);
--color-card-foreground: hsl(315 21% 8%);
--color-popover: hsl(340 40% 98%);
--color-popover-foreground: hsl(315 21% 8%);
--color-primary: hsl(340 25% 15%);
--color-primary-foreground: hsl(0 0% 98%);
--color-secondary: hsl(340 25% 95%);
--color-secondary-foreground: hsl(240 5.9% 10%);
--color-muted: hsl(340 20% 95%);
--color-muted-foreground: hsl(340 10% 60%);
--color-accent: hsl(340 25% 94%);
--color-accent-foreground: hsl(240 5.9% 10%);
--color-destructive: hsl(0 84.2% 60.2%);
--color-destructive-foreground: hsl(0 0% 98%);
--color-border: hsl(340 25% 90%);
--color-input: hsl(340 25% 90%);
--color-ring: hsl(315 21% 8%);
--color-chart-1: hsl(12 76% 61%);
--color-chart-2: hsl(173 58% 39%);
--color-chart-3: hsl(197 37% 24%);
--color-chart-4: hsl(43 74% 66%);
--color-chart-5: hsl(27 87% 67%);
--color-sidebar: hsl(0 0% 98%);
--color-sidebar-foreground: hsl(240 5.3% 26.1%);
--color-sidebar-primary: hsl(240 5.9% 10%);
--color-sidebar-primary-foreground: hsl(0 0% 98%);
--color-sidebar-accent: hsl(340 20% 95%);
--color-sidebar-accent-foreground: hsl(240 5.9% 10%);
--color-sidebar-border: hsl(340 20% 90%);
--color-sidebar-ring: hsl(217.2 91.2% 59.8%);
--radius: 0.625rem;
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--scrollbar: hsla(340 10% 60% / 0.5);
--scrollbar-hover: hsla(340 10% 60% / 0.8);
--ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
}
.image-scale {
transition-property: scale, border-radius, box-shadow;
transition-duration: 0.7s;
transition-timing-function: var(--ease-out-expo);
}
@media (hover: hover) {
.image-scale:hover {
--tw-scale-x: 105%;
--tw-scale-y: 105%;
--tw-scale-z: 105%;
scale: var(--tw-scale-x) var(--tw-scale-y);
border-radius: var(--radius-lg);
}
}
.image-scale:hover {
z-index: 10;
box-shadow: 0 15px 45px #0006;
}
/* from reset */
::-webkit-scrollbar-track {
background: 0 0;
}
::-webkit-scrollbar-thumb {
background: var(--scrollbar);
border: 5px solid var(--background);
border-radius: 16px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--scrollbar-hover);
}
/* ::-webkit-scrollbar:not(.highlighttable, .highlight table, .gist .highlight) {
background: var(--theme);
}
*/
/* reset */
::-webkit-scrollbar {
width: 19px;
height: 11px;
}
/* from PaperMod https://github.com/adityatelange/hugo-PaperMod/blob/c98a924842fc7ee0c14212c316c69ede3ad76ca3/assets/css/includes/scroll-bar.css */
@layer base {
body {
@apply bg-background text-foreground min-h-screen font-sans antialiased;
font-family: "Orbit", sans-serif;
}
}

View file

@ -0,0 +1,27 @@
import type { Metadata } from "next";
import "./globals.css";
import "react-photo-album/masonry.css";
export const metadata: Metadata = {
title: "Akiyama Mizuki",
description: "Gallery",
icons: {
icon: "/favicon.svg",
},
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

View file

@ -0,0 +1,352 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { MasonryPhotoAlbum } from "react-photo-album";
import Header from "../components/header";
type Upload = {
_id: string;
tweetId: string;
mediaIndex: number;
mediaUrl: string;
s3Key: string;
tweet: {
url: string;
};
};
type TagItem = {
_id: string;
name: string;
usageCount: number;
};
type GalleryPhoto = {
src: string;
width: number;
height: number;
key: string;
href: string;
alt: string;
};
const PAGE_SIZE = 20;
export default function App() {
const [uploads, setUploads] = useState<Upload[]>([]);
const [tags, setTags] = useState<TagItem[]>([]);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [total, setTotal] = useState<number>(0);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [photos, setPhotos] = useState<GalleryPhoto[]>([]);
const sentinelRef = useRef<HTMLDivElement | null>(null);
const isFetchingMoreRef = useRef(false);
function buildQuery(page: number, queryTags: string[]) {
const params = new URLSearchParams();
params.set("page", String(page));
for (const tag of queryTags) {
params.append("tags", tag);
}
return params.toString();
}
function toggleTag(tagName: string) {
setSelectedTags((current) => {
if (current.includes(tagName)) {
return current.filter((tag) => tag !== tagName);
}
return [...current, tagName];
});
}
useEffect(() => {
let active = true;
async function loadTags() {
try {
const tagsResponse = await fetch("/api/tags", { cache: "no-store" });
if (!tagsResponse.ok) {
throw new Error(`Failed to load tags: ${tagsResponse.status}`);
}
const tagsData = (await tagsResponse.json()) as TagItem[];
if (active) {
setTags(tagsData);
}
} catch (err) {
if (active) {
setError(err instanceof Error ? err.message : "Failed to load tags");
}
}
}
loadTags();
return () => {
active = false;
};
}, []);
const loadMore = useCallback(async () => {
if (isFetchingMoreRef.current || loading || loadingMore || !hasMore) {
return;
}
isFetchingMoreRef.current = true;
setLoadingMore(true);
try {
const nextPage = page + 1;
const query = buildQuery(nextPage, selectedTags);
const response = await fetch(`/api/list?${query}`, { cache: "no-store" });
if (!response.ok) {
throw new Error(`Failed to load more gallery: ${response.status}`);
}
const data = (await response.json()) as Upload[];
if (data.length === 0) {
setHasMore(false);
return;
}
setUploads((current) => {
const existingIds = new Set(current.map((item) => item._id));
const appended = data.filter((item) => !existingIds.has(item._id));
const merged = [...current, ...appended];
setHasMore(merged.length < total && data.length >= PAGE_SIZE);
return merged;
});
setPage(nextPage);
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load more gallery");
setHasMore(false);
} finally {
isFetchingMoreRef.current = false;
setLoadingMore(false);
}
}, [hasMore, loading, loadingMore, page, selectedTags, total]);
useEffect(() => {
if (!hasMore || loading) {
return;
}
const target = sentinelRef.current;
if (!target) {
return;
}
const observer = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting) {
void loadMore();
}
},
{ rootMargin: "800px 0px" },
);
observer.observe(target);
return () => observer.disconnect();
}, [hasMore, loadMore, loading]);
useEffect(() => {
let active = true;
async function loadUploads() {
try {
setLoading(true);
setLoadingMore(false);
setPage(1);
setHasMore(true);
isFetchingMoreRef.current = false;
setError(null);
const query = buildQuery(1, selectedTags);
const [listResponse, totalResponse] = await Promise.all([
fetch(`/api/list?${query}`, { cache: "no-store" }),
fetch(`/api/total?${query}`, { cache: "no-store" }),
]);
if (!listResponse.ok) {
throw new Error(`Failed to load gallery: ${listResponse.status}`);
}
if (!totalResponse.ok) {
throw new Error(`Failed to load total: ${totalResponse.status}`);
}
const [data, totalData] = await Promise.all([
listResponse.json() as Promise<Upload[]>,
totalResponse.json() as Promise<number | { total?: number; count?: number }>,
]);
const resolvedTotal = typeof totalData === "number"
? totalData
: (totalData.total ?? totalData.count ?? 0);
if (active) {
setUploads(data);
setTotal(resolvedTotal);
setHasMore(data.length > 0 && data.length < resolvedTotal);
}
} catch (err) {
if (active) {
setError(err instanceof Error ? err.message : "Failed to load gallery");
}
} finally {
if (active) {
setLoading(false);
}
}
}
loadUploads();
return () => {
active = false;
};
}, [selectedTags]);
const items = useMemo(() => uploads.filter((upload) => Boolean(upload.mediaUrl)), [uploads]);
useEffect(() => {
let cancelled = false;
async function loadPhotoSizes() {
if (items.length === 0) {
setPhotos([]);
return;
}
const nextPhotos = await Promise.all(
items.map(
(upload) =>
new Promise<GalleryPhoto | null>((resolve) => {
const image = new Image();
image.src = upload.mediaUrl;
image.onload = () => {
resolve({
src: upload.mediaUrl,
width: image.naturalWidth,
height: image.naturalHeight,
key: upload._id,
href: upload.tweet.url,
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
});
};
image.onerror = () => {
resolve({
src: upload.mediaUrl,
width: 1,
height: 1,
key: upload._id,
href: upload.mediaUrl,
alt: `tweet ${upload.tweetId} media ${upload.mediaIndex + 1}`,
});
};
}),
),
);
if (!cancelled) {
setPhotos(nextPhotos.filter((photo): photo is GalleryPhoto => photo !== null));
}
}
loadPhotoSizes();
return () => {
cancelled = true;
};
}, [items]);
return (
<div className="min-h-screen bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.9),rgba(247,229,236,0.85)_45%,rgba(244,240,243,1)_100%)] text-foreground">
<div className="sticky top-0 z-50">
<Header />
<div className="border-b border-border bg-background/85 backdrop-blur px-6 py-2">
<div className="flex w-full items-center justify-between overflow-x-auto text-sm text-foreground/70">
<div id="filter" className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => setSelectedTags([])}
className={selectedTags.length === 0 ? "text-foreground" : "text-foreground/50"}
>
all
</button>
{tags.map((tag) => (
<button
key={tag._id}
type="button"
onClick={() => toggleTag(tag.name)}
className={selectedTags.includes(tag.name) ? "text-foreground" : "text-foreground/50"}
>
{tag.name}
</button>
))}
</div>
<span className="shrink-0 text-xs uppercase tracking-[0.3em] text-foreground/40">
{total} items
</span>
</div>
</div>
</div>
<main className="w-full">
{error ? <p className="text-red-500">{error}</p> : null}
{loading ? (
<div className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 12 }).map((_, index) => (
<div
key={index}
className="aspect-4/5 animate-pulse bg-black/8"
/>
))}
</div>
) : (
<div className="w-full">
<MasonryPhotoAlbum
photos={photos}
spacing={0}
columns={(containerWidth) => {
if (containerWidth < 520) return 2;
if (containerWidth < 900) return 3;
if (containerWidth < 1280) return 4;
return 5;
}}
componentsProps={{
container: { className: "!w-full" },
image: {
loading: "lazy",
decoding: "async",
className: "block w-full",
},
link: {
className: "block overflow-hidden image-scale",
},
}}
/>
</div>
)}
</main>
{!loading && hasMore ? <div ref={sentinelRef} className="h-1 w-full" /> : null}
{loadingMore ? (
<div className="grid w-full grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{Array.from({ length: 5 }).map((_, index) => (
<div
key={`loading-more-${index}`}
className="aspect-4/5 animate-pulse bg-black/8"
/>
))}
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,11 @@
export default function Header() {
return (
<header className="flex h-16 items-center justify-between border-b border-border bg-background/90 backdrop-blur px-6">
<a href="/" className="text-2xl">🎀</a>
<div className="flex items-center" id="menu">
<a href="/add" className="text-[16px] text-foreground/50">[ <span className="text-foreground">+</span> ]</a>
<a href="/login" className="text-[16px] text-foreground/50">[ <span className="text-foreground">Login</span> ]</a>
</div>
</header>
);
}

2
apps/frontend/src/global.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare module "*.css";
declare module "react-photo-album/masonry.css";