165 lines
4.8 KiB
TypeScript
165 lines
4.8 KiB
TypeScript
"use client";
|
||
|
||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||
import Image from "next/image";
|
||
|
||
type ShamePhoto = {
|
||
name: string;
|
||
path: string;
|
||
url: string;
|
||
htmlUrl: string;
|
||
};
|
||
|
||
type PhotoResponse = {
|
||
photos: ShamePhoto[];
|
||
repo: string | null;
|
||
path: string;
|
||
branch?: string | null;
|
||
message: string | null;
|
||
};
|
||
|
||
const initialRepo = process.env.NEXT_PUBLIC_GITHUB_REPO ?? "";
|
||
const initialPath = process.env.NEXT_PUBLIC_GITHUB_PHOTOS_PATH ?? "";
|
||
const initialBranch = process.env.NEXT_PUBLIC_GITHUB_BRANCH ?? "";
|
||
|
||
function buildQuery(repo: string, path: string, branch: string) {
|
||
const query = new URLSearchParams();
|
||
|
||
if (repo.trim()) {
|
||
query.set("repo", repo.trim());
|
||
}
|
||
|
||
if (path.trim()) {
|
||
query.set("path", path.trim().replace(/^\/+/, ""));
|
||
}
|
||
|
||
if (branch.trim()) {
|
||
query.set("branch", branch.trim());
|
||
}
|
||
|
||
return query.toString();
|
||
}
|
||
|
||
export default function Home() {
|
||
const [repo, setRepo] = useState(initialRepo);
|
||
const [path, setPath] = useState(initialPath);
|
||
const [branch, setBranch] = useState(initialBranch);
|
||
const [activeQuery, setActiveQuery] = useState(() => buildQuery(initialRepo, initialPath, initialBranch));
|
||
const [data, setData] = useState<PhotoResponse | null>(null);
|
||
const [isLoading, setIsLoading] = useState(false);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController();
|
||
|
||
async function loadPhotos() {
|
||
setIsLoading(true);
|
||
setError(null);
|
||
|
||
try {
|
||
const response = await fetch(`/api/github-photos?${activeQuery}`, {
|
||
signal: controller.signal
|
||
});
|
||
const nextData = (await response.json()) as PhotoResponse;
|
||
|
||
setData(nextData);
|
||
|
||
if (!response.ok) {
|
||
setError(nextData.message || "Could not load photos from GitHub.");
|
||
}
|
||
} catch (nextError) {
|
||
if (nextError instanceof DOMException && nextError.name === "AbortError") {
|
||
return;
|
||
}
|
||
|
||
setError("Could not reach the photo source.");
|
||
} finally {
|
||
setIsLoading(false);
|
||
}
|
||
}
|
||
|
||
loadPhotos();
|
||
|
||
return () => controller.abort();
|
||
}, [activeQuery]);
|
||
|
||
const repoLabel = data?.repo ?? "GitHub source not set";
|
||
const boardStamp = useMemo(() => {
|
||
const count = data?.photos.length ?? 0;
|
||
|
||
if (count === 0) {
|
||
return "Empty";
|
||
}
|
||
|
||
return `${count} filed`;
|
||
}, [data?.photos.length]);
|
||
|
||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
setActiveQuery(buildQuery(repo, path, branch));
|
||
}
|
||
|
||
return (
|
||
<main className="shell">
|
||
<section className="hero" aria-labelledby="page-title">
|
||
<div className="heroText">
|
||
<h1 id="page-title">Доска позора</h1>
|
||
<p className="summary">
|
||
тут только самое позорное.
|
||
</p>
|
||
</div>
|
||
<div className="caseTag" aria-label="Board status">
|
||
<span>{boardStamp}</span>
|
||
<strong>{repoLabel}</strong>
|
||
</div>
|
||
</section>
|
||
|
||
<form className="sourceBar" onSubmit={handleSubmit}>
|
||
<label>
|
||
<span>repo</span>
|
||
<input
|
||
value={repo}
|
||
onChange={(event) => setRepo(event.target.value)}
|
||
spellCheck={false}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>folder</span>
|
||
<input
|
||
value={path}
|
||
onChange={(event) => setPath(event.target.value)}
|
||
spellCheck={false}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<span>branch</span>
|
||
<input
|
||
value={branch}
|
||
onChange={(event) => setBranch(event.target.value)}
|
||
spellCheck={false}
|
||
/>
|
||
</label>
|
||
<button type="submit">Обновить</button>
|
||
</form>
|
||
|
||
<section className="board" aria-live="polite" aria-busy={isLoading}>
|
||
{isLoading ? <div className="notice">Загружаю фотографии с GitHub...</div> : null}
|
||
{!isLoading && error ? <div className="notice danger">{error}</div> : null}
|
||
{!isLoading && !error && data?.message ? <div className="notice">{data.message}</div> : null}
|
||
|
||
{data?.photos.map((photo, index) => (
|
||
<article className="photoCard" key={photo.path} style={{ "--tilt": `${(index % 5) - 2}deg` } as React.CSSProperties}>
|
||
<a href={photo.htmlUrl} target="_blank" rel="noreferrer" aria-label={`Open ${photo.name} on GitHub`}>
|
||
<Image src={photo.url} alt={photo.name} fill sizes="(max-width: 520px) 100vw, (max-width: 1200px) 33vw, 260px" />
|
||
</a>
|
||
<div className="caption">
|
||
<span>#{String(index + 1).padStart(2, "0")}</span>
|
||
<strong>{photo.name}</strong>
|
||
</div>
|
||
</article>
|
||
))}
|
||
</section>
|
||
</main>
|
||
);
|
||
}
|