initial shame board
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
type GithubContent = {
|
||||
name: string;
|
||||
path: string;
|
||||
type: "file" | "dir";
|
||||
download_url: string | null;
|
||||
html_url: string;
|
||||
};
|
||||
|
||||
type ShamePhoto = {
|
||||
name: string;
|
||||
path: string;
|
||||
url: string;
|
||||
htmlUrl: string;
|
||||
};
|
||||
|
||||
const imageExtensionPattern = /\.(avif|gif|jpe?g|png|webp)$/i;
|
||||
|
||||
function parseRepo(value: string | null) {
|
||||
const clean = value?.trim().replace(/^https:\/\/github\.com\//, "") ?? "";
|
||||
const [owner, repo] = clean.split("/");
|
||||
|
||||
if (!owner || !repo) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
owner,
|
||||
repo: repo.replace(/\.git$/, "")
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchDirectory(owner: string, repo: string, path: string, branch?: string) {
|
||||
const encodedPath = path
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((part) => encodeURIComponent(part))
|
||||
.join("/");
|
||||
const url = new URL(`https://api.github.com/repos/${owner}/${repo}/contents/${encodedPath}`);
|
||||
|
||||
if (branch) {
|
||||
url.searchParams.set("ref", branch);
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28"
|
||||
},
|
||||
next: { revalidate: 60 }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
ok: false as const,
|
||||
status: response.status,
|
||||
message: response.status === 404 ? "Repository or folder was not found." : "GitHub did not return the photo list."
|
||||
};
|
||||
}
|
||||
|
||||
const content = (await response.json()) as GithubContent[] | GithubContent;
|
||||
const items = Array.isArray(content) ? content : [content];
|
||||
|
||||
return {
|
||||
ok: true as const,
|
||||
items
|
||||
};
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const repoValue = searchParams.get("repo") || process.env.NEXT_PUBLIC_GITHUB_REPO || null;
|
||||
const branch = searchParams.get("branch") || process.env.NEXT_PUBLIC_GITHUB_BRANCH || undefined;
|
||||
const path = searchParams.get("path") || process.env.NEXT_PUBLIC_GITHUB_PHOTOS_PATH || "";
|
||||
const parsedRepo = parseRepo(repoValue);
|
||||
|
||||
if (!parsedRepo) {
|
||||
return NextResponse.json({
|
||||
photos: [],
|
||||
repo: null,
|
||||
path,
|
||||
message: "Set a GitHub repository to load the board."
|
||||
});
|
||||
}
|
||||
|
||||
const directory = await fetchDirectory(parsedRepo.owner, parsedRepo.repo, path, branch);
|
||||
|
||||
if (!directory.ok) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
photos: [],
|
||||
repo: `${parsedRepo.owner}/${parsedRepo.repo}`,
|
||||
path,
|
||||
message: directory.message
|
||||
},
|
||||
{ status: directory.status }
|
||||
);
|
||||
}
|
||||
|
||||
const photos: ShamePhoto[] = directory.items
|
||||
.filter((item) => item.type === "file" && item.download_url && imageExtensionPattern.test(item.name))
|
||||
.map((item) => ({
|
||||
name: item.name.replace(imageExtensionPattern, ""),
|
||||
path: item.path,
|
||||
url: item.download_url as string,
|
||||
htmlUrl: item.html_url
|
||||
}));
|
||||
|
||||
return NextResponse.json({
|
||||
photos,
|
||||
repo: `${parsedRepo.owner}/${parsedRepo.repo}`,
|
||||
path,
|
||||
branch: branch ?? null,
|
||||
message: photos.length ? null : "No image files were found in that GitHub folder."
|
||||
});
|
||||
}
|
||||
+316
@@ -0,0 +1,316 @@
|
||||
:root {
|
||||
--paper: #080908;
|
||||
--ink: #f1eadc;
|
||||
--muted: #a69a87;
|
||||
--panel: #121511;
|
||||
--blood: #e13131;
|
||||
--acid: #d7ff3f;
|
||||
--steel: #c8d3c6;
|
||||
--line: #f1eadc;
|
||||
--shadow: rgba(0, 0, 0, 0.56);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
min-height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at 18% 10%, rgba(225, 49, 49, 0.16), transparent 28rem),
|
||||
linear-gradient(90deg, rgba(241, 234, 220, 0.055) 1px, transparent 1px) 0 0 / 34px 34px,
|
||||
linear-gradient(rgba(241, 234, 220, 0.04) 1px, transparent 1px) 0 0 / 34px 34px,
|
||||
var(--paper);
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
color: var(--ink);
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1440px, calc(100% - 32px));
|
||||
margin: 0 auto;
|
||||
padding: 26px 0 56px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(240px, 380px);
|
||||
gap: 24px;
|
||||
align-items: stretch;
|
||||
min-height: 270px;
|
||||
border: 2px solid var(--line);
|
||||
background:
|
||||
radial-gradient(circle at 88% 18%, rgba(215, 255, 63, 0.12), transparent 30%),
|
||||
linear-gradient(135deg, #191d16 0%, #101310 58%, #050605 100%);
|
||||
box-shadow: 10px 10px 0 var(--line);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 14px;
|
||||
border: 1px dashed rgba(241, 234, 220, 0.34);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.heroText {
|
||||
padding: 34px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.kicker {
|
||||
width: max-content;
|
||||
margin: 0 0 18px;
|
||||
padding: 7px 10px;
|
||||
background: var(--acid);
|
||||
border: 2px solid var(--line);
|
||||
color: #11160d;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 0.83rem;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 920px;
|
||||
margin: 0;
|
||||
font-size: clamp(4rem, 12vw, 10.5rem);
|
||||
line-height: 0.8;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.summary {
|
||||
max-width: 680px;
|
||||
margin: 28px 0 0;
|
||||
color: var(--muted);
|
||||
font-size: 1.12rem;
|
||||
line-height: 1.52;
|
||||
}
|
||||
|
||||
.caseTag {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
min-height: 100%;
|
||||
padding: 28px;
|
||||
background: #050605;
|
||||
color: var(--ink);
|
||||
font-family: "Courier New", monospace;
|
||||
text-transform: uppercase;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.caseTag span {
|
||||
color: var(--acid);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.caseTag strong {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 1.45rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.sourceBar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1.2fr) minmax(160px, 0.8fr) minmax(130px, 0.55fr) auto;
|
||||
gap: 12px;
|
||||
align-items: end;
|
||||
margin: 34px 0 28px;
|
||||
padding: 14px;
|
||||
border: 2px solid var(--line);
|
||||
background: rgba(12, 15, 12, 0.94);
|
||||
box-shadow: 6px 6px 0 var(--shadow);
|
||||
}
|
||||
|
||||
.sourceBar label {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sourceBar span {
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.sourceBar input {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 0;
|
||||
background: #050605;
|
||||
color: var(--ink);
|
||||
padding: 0 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sourceBar input:focus {
|
||||
box-shadow: 0 0 0 3px var(--acid);
|
||||
}
|
||||
|
||||
.sourceBar button {
|
||||
min-height: 46px;
|
||||
border: 2px solid var(--line);
|
||||
border-radius: 0;
|
||||
background: var(--blood);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
font-family: "Courier New", monospace;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
transition: transform 160ms ease, box-shadow 160ms ease;
|
||||
}
|
||||
|
||||
.sourceBar button:hover {
|
||||
box-shadow: 4px 4px 0 var(--line);
|
||||
transform: translate(-2px, -2px);
|
||||
}
|
||||
|
||||
.board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
gap: 26px;
|
||||
align-items: start;
|
||||
min-height: 240px;
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.notice {
|
||||
grid-column: 1 / -1;
|
||||
padding: 24px;
|
||||
border: 2px solid var(--line);
|
||||
background: var(--panel);
|
||||
box-shadow: 6px 6px 0 var(--line);
|
||||
color: var(--steel);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.notice.danger {
|
||||
background: #2a0808;
|
||||
color: #ffb7a8;
|
||||
}
|
||||
|
||||
.photoCard {
|
||||
--tilt: 0deg;
|
||||
border: 2px solid var(--line);
|
||||
background: #0d100d;
|
||||
box-shadow: 8px 9px 0 var(--line);
|
||||
transform: rotate(var(--tilt));
|
||||
transition: transform 180ms ease, box-shadow 180ms ease;
|
||||
}
|
||||
|
||||
.photoCard:hover {
|
||||
box-shadow: 12px 13px 0 var(--blood);
|
||||
transform: rotate(0deg) translate(-3px, -3px);
|
||||
}
|
||||
|
||||
.photoCard a {
|
||||
display: block;
|
||||
position: relative;
|
||||
aspect-ratio: 4 / 5;
|
||||
overflow: hidden;
|
||||
border-bottom: 2px solid var(--line);
|
||||
background: #1b2019;
|
||||
}
|
||||
|
||||
.photoCard img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: contrast(1.12) saturate(0.82) brightness(0.86);
|
||||
}
|
||||
|
||||
.caption {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
font-family: "Courier New", monospace;
|
||||
}
|
||||
|
||||
.caption span {
|
||||
padding: 4px 6px;
|
||||
background: var(--blood);
|
||||
color: white;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.caption strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
@media (max-width: 840px) {
|
||||
.shell {
|
||||
width: min(100% - 20px, 720px);
|
||||
padding-top: 14px;
|
||||
}
|
||||
|
||||
.hero,
|
||||
.sourceBar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.heroText {
|
||||
padding: 26px 22px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(3.4rem, 18vw, 6rem);
|
||||
}
|
||||
|
||||
.summary {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.caseTag {
|
||||
min-height: 130px;
|
||||
}
|
||||
|
||||
.sourceBar button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.board {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero {
|
||||
box-shadow: 6px 6px 0 var(--line);
|
||||
}
|
||||
|
||||
.kicker {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Доска позора",
|
||||
description: "A GitHub-powered public shame board."
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user