first commit

This commit is contained in:
2025-10-06 19:22:47 +02:00
commit aecc4cf549
75 changed files with 9303 additions and 0 deletions

7
.dockerignore Normal file
View File

@@ -0,0 +1,7 @@
.next
.vscode
public/images
node_modules
data
.env
.env.local

70
.eslintrc.js Normal file
View File

@@ -0,0 +1,70 @@
module.exports = {
parser: "@typescript-eslint/parser",
parserOptions: {
ecmaVersion: 2020,
sourceType: "module",
ecmaFeatures: {
jsx: true,
},
},
plugins: ["@typescript-eslint", "simple-import-sort", "import"],
extends: ["eslint:recommended", "@typescript-eslint/recommended", "prettier"],
rules: {
// Prettier rules (these will be formatted by Prettier, but ESLint can warn)
"prettier/prettier": "error",
// Import sorting and organization
"simple-import-sort/imports": [
"error",
{
groups: [
// Node.js builtins
["^node:"],
// External packages (react, lodash, etc.)
["^@?\\w"],
// Internal aliases (if you use path aliases)
["^@/"],
// Parent imports
["^\\.\\.(?!/?$)", "^\\.\\./?$"],
// Other relative imports
["^\\./(?=.*/)(?!/?$)", "^\\.(?!/?$)", "^\\./?$"],
// Style imports
["^.+\\.s?css$"],
],
},
],
"simple-import-sort/exports": "error",
"import/first": "error",
"import/newline-after-import": "error",
"import/no-duplicates": "error",
// Disallow relative imports starting with dot
"no-restricted-imports": [
"error",
{
patterns: [
{
group: ["./*"],
message: "Relative imports starting with dot are not allowed.",
},
],
},
],
// TypeScript specific rules
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-explicit-any": "warn",
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/explicit-module-boundary-types": "off",
},
settings: {
"import/parsers": {
"@typescript-eslint/parser": [".ts", ".tsx"],
},
"import/resolver": {
typescript: {
alwaysTryTypes: true,
},
},
},
};

43
.gitignore vendored Normal file
View File

@@ -0,0 +1,43 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
/data
# misc
.DS_Store
*.pem
notes
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"cSpell.words": ["authjs", "topbar"]
}

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["npm", "start"]

12
README.md Normal file
View File

@@ -0,0 +1,12 @@
# F1r3wave Gallery
Easy-To-Use Gallery Website to host your photos.
## Environment Variables
To run this project, you will need to set up a few environment variables. Create a `.env` file in the root of the project and add the following variables:
```bash
AUTH_SECRET=your-super-secret-key-here-generate-with-openssl-rand-base64-32
ADMIN_TOKEN=your-admin-secret-token-123
```

11
docker-compose.yml Normal file
View File

@@ -0,0 +1,11 @@
services:
website:
container_name: f1r3wave-website
build:
context: .
dockerfile: Dockerfile
environment:
- AUTH_SECRET="your-super-secret-key-here-generate-with-openssl-rand-base64-32"
- ADMIN_TOKEN="your-admin-secret-token-123"
ports:
- 3000:3000

25
eslint.config.mjs Normal file
View File

@@ -0,0 +1,25 @@
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FlatCompat } from "@eslint/eslintrc";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
});
const eslintConfig = [
...compat.extends("next/core-web-vitals", "next/typescript"),
{
ignores: [
"node_modules/**",
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
],
},
];
export default eslintConfig;

27
next.config.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
experimental: {
serverActions: {
bodySizeLimit: '70mb',
},
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'upload.wikimedia.org',
port: '',
pathname: '/wikipedia/commons/**',
},
{
protocol: 'https',
hostname: 'static.vecteezy.com',
port: '',
pathname: '/**',
},
],
},
};
export default nextConfig;

14
nginx.conf Normal file
View File

@@ -0,0 +1,14 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html index.htm;
location / {
try_files $uri $uri/ /index.html;
}
error_page 404 /index.html;
location = /index.html {
internal;
}
}

6959
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

43
package.json Normal file
View File

@@ -0,0 +1,43 @@
{
"name": "f1r3wave-gallery",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build --turbopack",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@fortawesome/free-brands-svg-icons": "^7.0.1",
"@fortawesome/free-regular-svg-icons": "^7.0.1",
"@fortawesome/free-solid-svg-icons": "^7.0.1",
"@fortawesome/react-fontawesome": "^3.0.2",
"bcryptjs": "^3.0.2",
"next": "^15.5.4",
"next-auth": "^5.0.0-beta.29",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-google-recaptcha": "^3.1.0",
"sass": "^1.91.0",
"uuid": "^11.1.0",
"zod": "^4.1.5"
},
"devDependencies": {
"@eslint/eslintrc": "^3",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/react-google-recaptcha": "^2.1.9",
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^8.45.0",
"@typescript-eslint/parser": "^8.45.0",
"eslint": "^9.37.0",
"eslint-config-next": "15.5.2",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-simple-import-sort": "^12.1.1",
"prettier": "^3.6.2",
"prettier-eslint": "^16.4.2",
"typescript": "^5"
}
}

View File

@@ -0,0 +1,36 @@
{
"home": {
"headline": "Portfolio",
"text": "As a passionate <b>hobby photographer</b> , I capture the unique beauty and atmosphere of various places. Join me on a visual journey through my lens.",
"buttonText": "My Gallery"
},
"contact": {
"headline": "Contact Me",
"links": [
{
"url": "https://www.instagram.com/f1r3wave",
"hoverColor": "rgba(211, 122, 238, 0.25)",
"image": {
"src": "https://upload.wikimedia.org/wikipedia/commons/a/a5/Instagram_icon.png",
"alt": "Instagram Logo"
}
},
{
"url": "mailto:mail@mail.com",
"hoverColor": "rgba(78, 172, 248, 0.25)",
"image": {
"src": "https://static.vecteezy.com/system/resources/thumbnails/014/440/980/small_2x/email-message-icon-design-in-blue-circle-png.png",
"alt": "Email Icon"
}
}
],
"imprint": {
"enable": false,
"headline": "Imprint / Legal Notice",
"name": "[Your Full Name]",
"address": "[Your Full Address: Street and House Number, Postcode City]",
"country": "[YourCountry (e.g., Germany)]",
"email": "[Your E-Mail Address]"
}
}
}

36
public/config.json Normal file
View File

@@ -0,0 +1,36 @@
{
"home": {
"headline": "Portfolio",
"text": "As a passionate <b>hobby photographer</b>, I capture the unique beauty and atmosphere of various places. Join me on a visual journey through my lens.",
"buttonText": "Visit Gallery"
},
"contact": {
"headline": "Contact Me",
"links": [
{
"url": "https://www.instagram.com/f1r3wave",
"hoverColor": "rgba(211, 122, 238, 0.25)",
"image": {
"src": "https://upload.wikimedia.org/wikipedia/commons/a/a5/Instagram_icon.png",
"alt": "Instagram Logo"
}
},
{
"url": "mailto:mail@mail.com",
"hoverColor": "rgba(78, 172, 248, 0.25)",
"image": {
"src": "https://static.vecteezy.com/system/resources/thumbnails/014/440/980/small_2x/email-message-icon-design-in-blue-circle-png.png",
"alt": "Email Icon"
}
}
],
"imprint": {
"enable": true,
"headline": "Imprint / Legal Notice",
"name": "[Your Full Name]",
"address": "[Your Full Address: Street and House Number, Postcode City]",
"country": "[YourCountry (e.g., Germany)]",
"email": "[Your E-Mail Address]"
}
}
}

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

3
public/images/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
*.jpg
*.png
*.jpeg

BIN
public/landing-page.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

3
public/robots.txt Normal file
View File

@@ -0,0 +1,3 @@
User-agent: *
Allow: /
Sitemap: https://f1r3wave.photos/sitemap.xml

15
public/sitemap.xml Normal file
View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<url>
<loc>https://f1r3wave.photos/</loc>
<priority>1.0</priority>
</url>
<url>
<loc>https://f1r3wave.photos/gallery</loc>
<priority>1</priority>
</url>
<url>
<loc>https://f1r3wave.photos/contact</loc>
<priority>0.8</priority>
</url>
</urlset>

12
src/.prettierrc Normal file
View File

@@ -0,0 +1,12 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 120,
"tabWidth": 2,
"useTabs": false,
"jsxSingleQuote": false,
"arrowParens": "always",
"htmlWhitespaceSensitivity": "css",
"bracketSameLine": true
}

44
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,44 @@
import ImageManager from '@/components/ImageManager';
import ImageUpload from '@/components/ImageUpload';
import { ImagesResponse, TagsResponse } from '@/interfaces/api';
import { getAuthStatus } from '@/lib/auth-utils';
import styles from '@/styles/AdminPage.module.scss';
import { redirect } from 'next/navigation';
export default async function AdminPage() {
const { isAuthenticated } = await getAuthStatus();
if (!isAuthenticated) {
redirect('/login');
}
const fetchImages = async () => {
const apiUrl = new URL(
'/api/images?imagesPerPage=-1}',
process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
);
const response = await fetch(apiUrl.toString());
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return (await response.json()) as ImagesResponse;
};
const fetchTags = async () => {
const apiUrl = new URL('/api/tags', process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000');
const response = await fetch(apiUrl.toString());
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return (await response.json()) as TagsResponse;
};
return (
<div className={styles.container}>
<ImageUpload />
<ImageManager tags={await fetchTags()} images={(await fetchImages()).images} />
</div>
);
}

View File

@@ -0,0 +1,18 @@
import { isAuthenticated } from '@/lib/auth-utils';
import { deleteImageById } from '@/lib/data';
import { NextRequest, NextResponse } from 'next/server';
export async function DELETE(request: NextRequest, context: { params: Promise<{ id: string }> }) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { id } = await context.params;
const status = await deleteImageById(id);
if (status === 0) {
return NextResponse.json({ message: 'File deleted successfully' }, { status: 201 });
} else {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

101
src/app/api/images/route.ts Normal file
View File

@@ -0,0 +1,101 @@
import { imagesDir } from '@/const/api';
import { ImagesResponse } from '@/interfaces/api';
import { ImageMeta } from '@/interfaces/image';
import { isAuthenticated } from '@/lib/auth-utils';
import { addImage, getImageData, stringToTags } from '@/lib/data';
import { promises as fs } from 'fs';
import { NextRequest, NextResponse } from 'next/server';
import sharp from 'sharp';
import { v4 as uuidv4 } from 'uuid';
export async function GET(request: NextRequest) {
try {
const tag = request.nextUrl.searchParams.get('tag');
const page = request.nextUrl.searchParams.get('page');
const imagesPerPageParam = request.nextUrl.searchParams.get('imagesPerPage');
let imagesPerPage = imagesPerPageParam !== null ? +imagesPerPageParam : 20;
const images: ImageMeta[] = await getImageData();
let responseImages = images;
if (tag) {
responseImages = responseImages.filter((image: ImageMeta) => image.tags.includes(tag));
}
if (imagesPerPage === -1) {
imagesPerPage = responseImages.length;
}
const currentPage = page ? parseInt(page, 10) : 1;
const startIndex = (currentPage - 1) * imagesPerPage;
const endIndex = startIndex + imagesPerPage;
const totalPages = Math.ceil(responseImages.length / imagesPerPage);
responseImages = responseImages.slice(
startIndex,
endIndex < responseImages.length ? endIndex : responseImages.length,
);
return NextResponse.json(
{
images: responseImages,
page: currentPage,
totalPages: totalPages,
} as ImagesResponse,
{ status: 200 },
);
} catch (error) {
console.error('Error reading images data:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
export async function POST(request: NextRequest) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const formData = await request.formData();
const file = formData.get('file') as File;
const tags = formData.get('tags') as string;
if (!file) {
return NextResponse.json({ error: 'No files received.' }, { status: 400 });
}
if (!file.type.startsWith('image/')) {
return NextResponse.json({ error: 'Only image files are allowed.' }, { status: 400 });
}
const uuid = uuidv4();
const fileExtension = file.name.split('.').pop();
const uuidFilename = `${uuid}.${fileExtension}`;
const relativePath = `${uuidFilename}`;
const buffer = Buffer.from(await file.arrayBuffer());
try {
await fs.access(imagesDir);
} catch {
await fs.mkdir(imagesDir, { recursive: true });
}
const imageInfo = await sharp(buffer).metadata();
const newImage: ImageMeta = {
id: uuid,
relative_path: relativePath,
tags: stringToTags(tags),
aspect_ratio: imageInfo.width && imageInfo.height ? imageInfo.width / imageInfo.height : 1,
width: imageInfo.width || 0,
height: imageInfo.height || 0,
};
await addImage(newImage);
return NextResponse.json({ message: 'File deleted successfully' }, { status: 201 });
} catch (error) {
console.error(error);
}
}

View File

@@ -0,0 +1,21 @@
import { isAuthenticated } from '@/lib/auth-utils';
import { stringToTags, updateTagsOfImageId } from '@/lib/data';
import { NextRequest, NextResponse } from 'next/server';
export async function PUT(request: NextRequest, context: { params: Promise<{ imageId: string }> }) {
if (!(await isAuthenticated(request))) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const { imageId } = await context.params;
const formData = await request.formData();
const tags = stringToTags(formData.get('tags')?.toString() ?? '');
const status = await updateTagsOfImageId(imageId, tags ?? []);
if (status === 0) {
return NextResponse.json({ message: 'Tags updated successfully', tags: tags }, { status: 201 });
} else {
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}

View File

@@ -0,0 +1,8 @@
import { ImageMeta } from '@/interfaces/image';
import { getImageData } from '@/lib/data';
import { NextResponse } from 'next/server';
export async function GET() {
const data: ImageMeta[] = await getImageData();
return NextResponse.json(Array.from(new Set(data.flatMap((image) => image.tags))));
}

View File

@@ -0,0 +1,52 @@
import Gallery from '@/components/Gallery';
import Topbar from '@/components/Topbar';
import { ImagesResponse, TagsResponse } from '@/interfaces/api';
import { redirect } from 'next/navigation';
import { Suspense } from 'react';
export default async function GalleryPage({ params }: { params: Promise<{ page: number; tag: string }> }) {
const { tag, page } = await params;
const fetchImages = async () => {
const apiUrl = new URL(
`/api/images?page=${page}&tag=${tag === 'all' ? '' : tag}`,
process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000',
);
const response = await fetch(apiUrl.toString());
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return (await response.json()) as ImagesResponse;
};
const fetchTags = async () => {
const apiUrl = new URL('/api/tags', process.env.NEXT_PUBLIC_SITE_URL || 'http://localhost:3000');
const response = await fetch(apiUrl.toString());
if (!response.ok) {
throw new Error(`Network response was not ok: ${response.statusText}`);
}
return (await response.json()) as TagsResponse;
};
const images = await fetchImages();
const tags = await fetchTags();
if (images.totalPages < page && images.totalPages > 0) {
redirect(`/gallery/${tag}/${images.totalPages}`);
}
if (page <= 0) {
redirect(`/gallery/${tag}/1`);
}
return (
<>
<Suspense fallback={<div>Loading gallery...</div>}>
<Topbar activeTag={tag} tags={tags} page={page} totalPages={images.totalPages} />
<Gallery initialImages={images.images} />
</Suspense>
</>
);
}

View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default async function Page({ params }: { params: Promise<{ page: number; tag: string }> }) {
redirect(`gallery/${(await params).tag}/1`);
}

5
src/app/gallery/page.tsx Normal file
View File

@@ -0,0 +1,5 @@
import { redirect } from 'next/navigation';
export default async function Page() {
redirect(`gallery/all/1`);
}

10
src/app/imprint/page.tsx Normal file
View File

@@ -0,0 +1,10 @@
import Imprint from '@/components/Imprint';
import styles from './styles.module.scss';
export default function ImprintPage() {
return (
<div className={styles.container}>
<Imprint />
</div>
);
}

View File

@@ -0,0 +1,5 @@
.container {
display: flex;
justify-content: center;
flex: 1;
}

39
src/app/index.scss Normal file
View File

@@ -0,0 +1,39 @@
* {
--header-height: 50px;
--color-accent: #3cdbc0;
}
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: rgb(27, 27, 27);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body,
#root {
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
font-size: 16px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
letter-spacing: 1px;
min-height: 100vh;
scrollbar-gutter: stable;
background: linear-gradient(-120deg, rgba(0, 0, 0, 0), #06565a49);
}
a {
text-decoration: none;
color: inherit;
}

22
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,22 @@
import Footer from '@/components/Footer';
import Header from '@/components/Header';
import { ConfigProvider } from '@/contexts/ConfigContext';
import './index.scss';
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<ConfigProvider>
<body>
<Header />
{children}
<Footer currentYear={new Date().getFullYear()} />
</body>
</ConfigProvider>
</html>
);
}

13
src/app/login/page.tsx Normal file
View File

@@ -0,0 +1,13 @@
import LoginForm from '@/components/LoginForm';
import { getAuthStatus } from '@/lib/auth-utils';
import { redirect } from 'next/navigation';
export default async function LoginPage() {
const { isAuthenticated } = await getAuthStatus();
if (isAuthenticated) {
redirect('/admin');
}
return <LoginForm />;
}

26
src/app/page.tsx Normal file
View File

@@ -0,0 +1,26 @@
'use client';
import { useConfig } from '@/contexts/configExports';
import Link from 'next/link';
import styles from './styles.module.scss';
export default function HomePage() {
const { config } = useConfig();
return (
<>
<div className={styles.home}>
<div className={styles.homeText}>
<h1
className={styles.homeTextHeadline}
dangerouslySetInnerHTML={{ __html: config?.home.headline || '' }}></h1>
<p className={styles.homeTextParagraph} dangerouslySetInnerHTML={{ __html: config?.home.text || '' }}></p>
<Link
href="/gallery"
className={styles.homeButton}
dangerouslySetInnerHTML={{ __html: config?.home.buttonText || '' }}></Link>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,63 @@
.home {
display: flex;
flex: 1;
padding: 0 40px;
overflow: hidden;
background-image: url('/landing-page.jpg');
background-size: cover;
@media (max-width: 1200px) {
justify-content: center;
}
}
.homeText {
max-width: 750px;
min-width: 400px;
height: 100%;
margin-top: 80px;
padding: 0 10px;
z-index: 1;
@media (min-width: 1200px) {
margin-left: 100px;
}
&Headline {
letter-spacing: 5px;
text-shadow: 0px 0px #fff;
color: #fff;
}
&Paragraph {
letter-spacing: 2px;
text-shadow: 0px 0px #fff;
color: #fff;
margin-bottom: 50px;
b {
color: var(--color-accent);
}
}
}
.homeButton {
display: inline-block;
padding: 15px 30px;
background-color: rgba(165, 165, 165, 0.062);
border: 3px solid #afafaf;
border-radius: 5px;
font-size: 11pt;
font-weight: bold;
letter-spacing: 1.5px;
cursor: pointer;
transition: all 50ms linear;
text-decoration: none;
color: inherit;
&:hover {
border-color: var(--color-accent);
color: var(--color-accent);
background-color: rgba(165, 165, 165, 0.15);
}
}

BIN
src/assets/logo_text.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

21
src/auth.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import type { NextAuthConfig } from 'next-auth';
export const authConfig = {
pages: {
signIn: '/login',
},
callbacks: {
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user;
const isOnAdminRoute = nextUrl.pathname.startsWith('/admin');
if (isOnAdminRoute) {
if (isLoggedIn) return true;
return false;
}
return true;
},
},
providers: [],
} satisfies NextAuthConfig;

30
src/auth.ts Normal file
View File

@@ -0,0 +1,30 @@
import { validateAdminToken } from '@/lib/auth-utils';
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { z } from 'zod';
import { authConfig } from './auth.config';
export const { auth, signIn } = NextAuth({
...authConfig,
providers: [
Credentials({
async authorize(credentials) {
const parsedCredentials = z.object({ token: z.string().min(1) }).safeParse(credentials);
if (parsedCredentials.success) {
const { token } = parsedCredentials.data;
const isValidToken = await validateAdminToken(token);
if (isValidToken) {
return {
id: 'admin-id',
name: 'Administrator',
role: 'admin',
};
}
}
return null;
},
}),
],
});

22
src/components/Button.tsx Normal file
View File

@@ -0,0 +1,22 @@
'use client';
import styles from '@/styles/Button.module.scss';
interface ButtonProps {
label: string;
type?: 'button' | 'submit' | 'reset';
disabled?: boolean;
onClickCallback?: () => void;
}
export default function Button({ label, type, disabled, onClickCallback }: ButtonProps) {
return (
<button
className={`${styles.button} ${disabled ? styles.buttonDisabled : ''}`}
type={type}
disabled={disabled}
onClick={onClickCallback}>
{label}
</button>
);
}

40
src/components/Footer.tsx Normal file
View File

@@ -0,0 +1,40 @@
'use client';
import styles from '@/styles/Footer.module.scss';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
interface FooterProps {
currentYear: number;
}
export default function Footer({ currentYear }: FooterProps) {
const [isInLandingPage, setIsInLandingPage] = useState<boolean>(false);
const pathname = usePathname();
useEffect(() => {
setIsInLandingPage(pathname === '/');
}, [pathname]);
return (
<footer className={`${styles.footer} ${isInLandingPage ? styles.footerLandingPage : ''}`}>
<div className={styles.links}>
<Link href="/imprint" className={styles.linksLink}>
Imprint
</Link>
<Link
href="https://github.com/DerTyp7/f1r3wave-website"
className={styles.linksLink}
target="_blank"
rel="noopener noreferrer">
View Source Code on GitHub
</Link>
</div>
<span>
&#169; {currentYear} <Link href="https://github.com/DerTyp7">DerTyp7</Link>. All rights reserved.
</span>
</footer>
);
}

188
src/components/Gallery.tsx Normal file
View File

@@ -0,0 +1,188 @@
'use client';
import { ImageMeta } from '@/interfaces/image';
import styles from '@/styles/Gallery.module.scss';
import Image from 'next/image';
import { useRouter as useNavigationRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { v4 as uuidv4 } from 'uuid';
interface GalleryProps {
initialImages: ImageMeta[];
}
export default function Gallery({ initialImages }: GalleryProps) {
const HORIZONTAL_ASPECT_RATIO = 1.5;
const VERTICAL_ASPECT_RATIO = 0.9;
const navigationRouter = useNavigationRouter();
const searchParams = useSearchParams();
const [images, setImages] = useState<ImageMeta[]>([]);
const [columnCount, setColumnCount] = useState(0);
const [fullScreenImage, setFullScreenImage] = useState<string | null>(null);
useEffect(() => {
const updateColumns = () => {
const newImages = [...initialImages];
if (newImages.length > 0) {
let usedColumnsInRow = 0;
let usedRowsInColumn = 0;
let index = 0;
for (const image of newImages) {
usedColumnsInRow += image.aspect_ratio > HORIZONTAL_ASPECT_RATIO ? 2 : 1;
usedRowsInColumn += image.aspect_ratio < VERTICAL_ASPECT_RATIO ? 2 : 1;
if (usedColumnsInRow > columnCount) {
const nextViableImage: ImageMeta | undefined = newImages.slice(index).find((img) => {
const imgAspectRatio = img.aspect_ratio;
return imgAspectRatio <= HORIZONTAL_ASPECT_RATIO && image.aspect_ratio >= VERTICAL_ASPECT_RATIO;
});
if (nextViableImage) {
newImages.splice(index, 1);
newImages.splice(index - 1, 0, nextViableImage);
usedColumnsInRow -= nextViableImage.aspect_ratio > HORIZONTAL_ASPECT_RATIO ? 2 : 1;
usedRowsInColumn -= nextViableImage.aspect_ratio < VERTICAL_ASPECT_RATIO ? 2 : 1;
}
} else if (usedColumnsInRow === columnCount) {
usedColumnsInRow = usedRowsInColumn;
usedRowsInColumn = 0;
}
index++;
}
setImages(newImages);
}
};
const updateColumnCount = () => {
const container = document.querySelector(`.${styles.images}`);
if (!container) return;
const newColumnCount = window
.getComputedStyle(container)
.getPropertyValue('grid-template-columns')
.split(' ').length;
if (newColumnCount !== columnCount) {
setColumnCount(newColumnCount);
}
};
setImages([...initialImages]);
updateColumns();
updateColumnCount();
window.addEventListener('resize', updateColumnCount);
return () => window.removeEventListener('resize', updateColumnCount);
}, [columnCount, initialImages]);
useEffect(() => {
const imagePath = searchParams.get('image');
if (imagePath) {
setFullScreenImage(`/images/${imagePath}`);
}
}, [searchParams]);
useEffect(() => {
if (fullScreenImage) {
const params = new URLSearchParams(searchParams.toString());
const relativePath = fullScreenImage.replace('/images/', '');
params.set('image', relativePath);
navigationRouter.replace(`?${params.toString()}`, { scroll: false });
document.body.style.overflow = 'hidden';
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('image');
navigationRouter.replace(`?${params.toString()}`, { scroll: false });
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [fullScreenImage, searchParams, navigationRouter]);
useEffect(() => {
const params = new URLSearchParams(searchParams.toString());
navigationRouter.replace(`?${params.toString()}`, { scroll: false });
}, [searchParams, navigationRouter]);
return (
<div className={styles.gallery}>
<div className={styles.images}>
{images.map((image: ImageMeta) => (
<div
key={uuidv4()}
className={`${styles.imagesContainer} ${
image.aspect_ratio > HORIZONTAL_ASPECT_RATIO
? styles.horizontal
: image.aspect_ratio < VERTICAL_ASPECT_RATIO
? styles.vertical
: ''
}`}>
<Image
width={image.aspect_ratio > HORIZONTAL_ASPECT_RATIO ? image.width : 700}
height={image.aspect_ratio < VERTICAL_ASPECT_RATIO ? image.height : 700}
loading="lazy"
src={`/images/${image.relative_path}`}
alt={image.aspect_ratio?.toString()}
onClick={() => setFullScreenImage(`/images/${image.relative_path}`)}
/>
</div>
))}
</div>
{fullScreenImage && (
<div className={styles.fullscreenModal} onClick={() => setFullScreenImage(null)}>
<Image
src={fullScreenImage}
alt="Full Screen"
width={1920}
height={1080}
style={{ objectFit: 'contain' }}
onClick={(e) => e.stopPropagation()}
/>
<button
className={styles.closeButton}
onClick={(e) => {
e.stopPropagation();
setFullScreenImage(null);
}}>
&times;
</button>
<button
className={`${styles.arrowButton} ${styles.arrowButtonLeft}`}
onClick={(e) => {
e.stopPropagation();
const currentIndex = images.findIndex((image) => `/images/${image.relative_path}` === fullScreenImage);
if (currentIndex > 0) {
setFullScreenImage(`/images/${images[currentIndex - 1].relative_path}`);
}
}}>
&#8249;
</button>
<button
className={`${styles.arrowButton} ${styles.arrowButtonRight}`}
onClick={(e) => {
e.stopPropagation();
const currentIndex = images.findIndex((image) => `/images/${image.relative_path}` === fullScreenImage);
if (currentIndex < images.length - 1) {
setFullScreenImage(`/images/${images[currentIndex + 1].relative_path}`);
}
}}>
&#8250;
</button>
</div>
)}
</div>
);
}

65
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,65 @@
'use client';
import logo from '@/assets/logo_text.png';
import { logout } from '@/lib/actions';
import { getAuthStatus } from '@/lib/auth-utils';
import styles from '@/styles/Header.module.scss';
import { faInstagram } from '@fortawesome/free-brands-svg-icons';
import { faEnvelope } from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import Image from 'next/image';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
export default function Header() {
const pathname = usePathname();
const [isAuth, setIsAuth] = useState<boolean>(false);
const isActive = (path: string) => pathname.split('/')[1] === path.split('/')[1];
useEffect(() => {
const auth = async () => {
const { isAuthenticated } = await getAuthStatus();
setIsAuth(isAuthenticated);
};
auth();
}, [pathname]);
return (
<header className={`${styles.header}`}>
<Link href="/" className={styles.logo}>
<Image src={logo} alt="Logo F1r3wave" priority />
</Link>
<nav className={styles.nav}>
<Link href="/" className={`${styles.navLink} ${isActive('/') ? styles.navLinkActive : ''}`}>
Home
</Link>
<Link href="/gallery" className={`${styles.navLink} ${isActive('/gallery') ? styles.navLinkActive : ''}`}>
Gallery
</Link>
{isAuth ? (
<p
onClick={async () => {
await logout();
window.location.reload();
}}
className={`${styles.navLink}`}>
Logout
</p>
) : (
''
)}
<div className={styles.navSocials}>
<Link className={styles.navLink} href="https://www.instagram.com/f1r3wave" target="_blank">
<FontAwesomeIcon icon={faInstagram} />
</Link>
<Link className={styles.navLink} href="mailto:f1r3wave@tealfire.de" target="_blank">
<FontAwesomeIcon icon={faEnvelope} />
</Link>
</div>
</nav>
</header>
);
}

View File

@@ -0,0 +1,143 @@
'use client';
import Button from '@/components/Button';
import InputField from '@/components/InputField';
import { ImageMeta } from '@/interfaces/image';
import styles from '@/styles/ImageManager.module.scss';
import Image from 'next/image';
import { redirect, useSearchParams } from 'next/navigation';
import React, { useEffect, useState } from 'react';
import Tags from './Tags';
interface ImageManagerProps {
images: ImageMeta[];
tags: string[];
}
export default function ImageManager({ images: initialImages, tags: initialTags }: ImageManagerProps) {
const [images, setImages] = useState<ImageMeta[]>(initialImages);
const [error, setError] = useState<string>();
const [tags, setTags] = useState<string[]>(initialTags);
const [activeTag, setActiveTag] = useState<string>('all');
const searchParams = useSearchParams();
const fetchTags = () => {
fetch(`/api/tags`, {
method: 'GET',
})
.then((response) => response.json())
.then((newTags) => {
setTags(newTags);
});
};
const onDelete = (id: string) => {
fetch(`/api/images/${id}`, {
method: 'DELETE',
credentials: 'include',
})
.then((response) => response.json())
.then((response) => {
if (response.error) {
setError(response.error);
} else {
setImages(images.filter((image) => image.id !== id));
fetchTags();
}
});
};
const onSubmitTags = (id: string, e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
fetch(`/api/tags/${id}`, {
method: 'PUT',
body: formData,
credentials: 'include',
})
.then((response) => response.json())
.then((response) => {
if (!response.error && response.tags) {
const indexOfImage = images.findIndex((i) => i.id === id);
if (indexOfImage !== -1) {
const newImages = [...images];
newImages[indexOfImage] = {
...newImages[indexOfImage],
tags: response.allTags,
};
setImages(newImages);
fetchTags();
}
} else if (response.error) {
setError(response.error);
}
});
};
useEffect(() => {
setActiveTag(searchParams.get('tag') ?? 'all');
}, [searchParams, tags]);
useEffect(() => {
const filterImages = () => {
if (activeTag === 'all') {
setImages(initialImages);
return;
} else if (activeTag && !tags.includes(activeTag)) {
redirect('/admin');
} else {
setImages(initialImages.filter((image) => image.tags.includes(activeTag)));
}
};
filterImages();
}, [activeTag, initialImages, tags]);
return (
<div className={styles.container}>
<h2>Manage Images</h2>
<span className={styles.error}>{error}</span>
<Tags tags={tags} activeTag={activeTag} redirectUrlWithPlaceholder="admin?tag=${tag}" />
<div className={styles.tableContainer}>
<table className={styles.table}>
<thead>
<tr>
<th>Image</th>
<th>ID</th>
<th>Tags</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{images.map((image) => (
<tr key={image.id}>
<td>
<Image src={`/images/${image.relative_path}`} height={50} width={50} alt={image.id} />
</td>
<td>{image.id}</td>
<td>
<form className={styles.tagForm} onSubmit={(e) => onSubmitTags(image.id, e)}>
<InputField
name="tags"
placeholder="Nature, Landscape, Sunset"
defaultValue={image.tags?.join(', ') || ''}
/>
<Button label="Save" type="submit" />
</form>
</td>
<td>
<Button label="Delete" onClickCallback={() => onDelete(image.id)} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}

View File

@@ -0,0 +1,92 @@
'use client';
import Button from '@/components/Button';
import InputField from '@/components/InputField';
import styles from '@/styles/ImageUpload.module.scss';
import { useState } from 'react';
export default function ImageUpload() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [tags, setTags] = useState('');
const [isUploading, setIsUploading] = useState(false);
const [message, setMessage] = useState('');
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setSelectedFile(e.target.files[0]);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!selectedFile) {
setMessage('Please select a file first.');
return;
}
setIsUploading(true);
setMessage('');
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('tags', tags);
try {
const response = await fetch('/api/images', {
method: 'POST',
body: formData,
credentials: 'include',
});
const data = await response.json();
if (response.ok) {
setSelectedFile(null);
setTags('');
window.location.reload();
} else {
setMessage(data.error || 'Failed to upload file.');
}
} catch (error) {
console.error('Error uploading file:', error);
setMessage('An error occurred while uploading the file.');
}
};
return (
<div className={styles.container}>
<h2>Upload Image</h2>
<span className={styles.message}>{message}</span>
<form className={styles.form} onSubmit={handleSubmit}>
<div>
<label>Select Image</label>
<InputField
name="image"
type="file"
accept="image/*"
onValueChange={handleFileChange}
disabled={isUploading}
/>
</div>
<InputField
label="Tags (comma-separated)"
type="text"
onValueChange={(e) => setTags(e.target?.value ?? '')}
placeholder="nature, landscape, sunset"
disabled={isUploading}
name={'image'}
/>
<Button
label={isUploading ? 'Uploading...' : 'Upload Image'}
type="submit"
disabled={!selectedFile || isUploading}
/>
</form>
</div>
);
}

View File

@@ -0,0 +1,18 @@
'use client';
import { useConfig } from '@/contexts/configExports';
import styles from '@/styles/Imprint.module.scss';
export default function Imprint() {
const { config } = useConfig();
return (
<div className={styles.imprint}>
<h2 className={styles.imprintHeadline}>{config?.contact.imprint.headline}</h2>
<span>{config?.contact.imprint.name}</span>
<span>{config?.contact.imprint.address}</span>
<span>{config?.contact.imprint.country}</span>
<span>{config?.contact.imprint.email}</span>
</div>
);
}

View File

@@ -0,0 +1,59 @@
"use client";
import React from "react";
import styles from "@/styles/InputField.module.scss";
export interface InputFieldProps {
defaultValue?: string;
name?: string;
type?: "text" | "password" | "email" | "file";
label?: string;
required?: boolean;
placeholder?: string;
disabled?: boolean;
invalid?: boolean;
accept?: string;
onValueChange?: (value: React.ChangeEvent<HTMLInputElement>) => void;
}
export default function InputField({
defaultValue,
name,
type,
label,
required,
placeholder,
disabled,
invalid,
accept,
onValueChange,
}: InputFieldProps) {
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
if (onValueChange) {
onValueChange(event);
}
};
return (
<div className={styles.container}>
{label ? (
<label htmlFor={name} className={styles.label}>
{label}
</label>
) : (
""
)}
<input
className={`${styles.input} ${invalid ? styles.inputInvalid : ""} ${disabled ? styles.inputDisabled : ""}`}
type={type}
defaultValue={defaultValue}
name={name}
required={required}
placeholder={placeholder}
disabled={disabled}
accept={accept}
onChange={handleChange}
/>
</div>
);
}

View File

@@ -0,0 +1,29 @@
'use client';
import Button from '@/components/Button';
import InputField from '@/components/InputField';
import { authenticate } from '@/lib/actions';
import styles from '@/styles/LoginForm.module.scss';
import { useActionState } from 'react';
export default function LoginForm() {
const [errorMessage, dispatch, isPending] = useActionState(authenticate, undefined);
return (
<div className={styles.container}>
<form action={dispatch} className={styles.form}>
<span className={styles.errorMessage}>{errorMessage}</span>
<InputField
label="Token"
name="token"
type="password"
required={true}
placeholder="Enter your token"
disabled={isPending}
invalid={errorMessage?.length ? true : false}
/>
<Button label={isPending ? 'Verifying...' : 'Log in'} type="submit" disabled={isPending} />
</form>
</div>
);
}

View File

@@ -0,0 +1,44 @@
"use client";
import styles from "@/styles/Paginator.module.scss";
import { PaginatorPosition } from "@/interfaces/paginator";
import { useRouter } from "next/navigation";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faArrowLeft, faArrowRight } from "@fortawesome/free-solid-svg-icons";
interface PaginatorProps {
page: number;
totalPages: number;
position?: PaginatorPosition | undefined;
}
export default function Paginator({ page, totalPages, position }: PaginatorProps) {
const router = useRouter();
if (totalPages <= 1) return null;
const setPage = (newPage: number) => {
const pathParts = window.location.pathname.split("/");
pathParts[pathParts.length - 1] = newPage.toString();
const newPath = pathParts.join("/");
router.push(newPath);
};
return (
<div className={`${styles.paginator} ${position === PaginatorPosition.TOP ? styles.paginatorTop : position === PaginatorPosition.BOTTOM ? styles.paginatorBottom : ""}`}>
<button disabled={page === 1} onClick={() => setPage(page - 1)}>
<FontAwesomeIcon icon={faArrowLeft} />
</button>
{Array.from({ length: totalPages }, (_, index) => (
<button key={index + 1} className={+page === index + 1 ? styles.active : ""} onClick={() => setPage(index + 1)}>
{index + 1}
</button>
))}
<button disabled={page === totalPages} onClick={() => setPage(+page + 1)}>
<FontAwesomeIcon icon={faArrowRight} />
</button>
</div>
);
}

36
src/components/Tags.tsx Normal file
View File

@@ -0,0 +1,36 @@
import styles from '@/styles/Tags.module.scss';
import { redirect } from 'next/navigation';
/**
* @param {string} redirectUrlWithPlaceholder Use `${tag}` as a placeholder in the redirect URL. e.g. `/gallery/${tag}/1`
*/
export default function Tags({
activeTag,
tags,
redirectUrlWithPlaceholder = '/gallery/${tag}/1',
}: {
activeTag: string;
tags: string[];
redirectUrlWithPlaceholder?: string;
}) {
return (
<div className={styles.tags}>
<span
key="all"
className={`${styles.tag} ${activeTag === 'all' ? styles.tagActive : ''}`}
onClick={() => redirect(redirectUrlWithPlaceholder.replace('${tag}', 'all'))}>
All
</span>
{tags.map((tag, index) => {
return (
<span
key={index}
className={`${styles.tag} ${activeTag === tag ? styles.tagActive : ''}`}
onClick={() => redirect(redirectUrlWithPlaceholder.replace('${tag}', tag))}>
{tag}
</span>
);
})}
</div>
);
}

34
src/components/Topbar.tsx Normal file
View File

@@ -0,0 +1,34 @@
'use client';
import Paginator from '@/components/Paginator';
import Tags from '@/components/Tags';
import { PaginatorPosition } from '@/interfaces/paginator';
import styles from '@/styles/Topbar.module.scss';
import { useEffect, useState } from 'react';
export default function Topbar({
activeTag,
tags,
page,
totalPages,
}: {
activeTag: string;
tags: string[];
page: number;
totalPages: number;
}) {
const [isScrolling, setIsScrolling] = useState<boolean>(false);
useEffect(() => {
window.addEventListener('scroll', () => {
setIsScrolling(window.scrollY > 0);
});
}, []);
return (
<div className={`${styles.topbar} ${isScrolling ? styles.topbarScroll : ''}`}>
<Tags activeTag={activeTag} tags={tags} />
<Paginator page={page} totalPages={totalPages} position={PaginatorPosition.TOP} />
</div>
);
}

4
src/const/api.ts Normal file
View File

@@ -0,0 +1,4 @@
import path from "path";
export const imagesDir = path.join(process.cwd(), 'public', 'images');
export const jsonPath = path.join(process.cwd(), 'data', 'images.json');

View File

@@ -0,0 +1,36 @@
"use client";
import { ConfigContext } from "@/contexts/configExports";
import { AppConfig, ConfigContextType } from "@/interfaces/config";
import React, { useState, useEffect, ReactNode } from "react";
export const ConfigProvider: React.FC<{ children: ReactNode }> = ({
children,
}) => {
const [config, setConfig] = useState<AppConfig | null>(null);
useEffect(() => {
const fetchConfig = async () => {
try {
const response = await fetch("/config.json");
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: AppConfig = await response.json();
setConfig(data);
} catch (e: unknown) {
console.error("Failed to fetch config.json:", e);
}
};
fetchConfig();
}, []);
const value: ConfigContextType = {
config,
};
return (
<ConfigContext.Provider value={value}>{children}</ConfigContext.Provider>
);
};

View File

@@ -0,0 +1,14 @@
"use client";
import { ConfigContextType } from '@/interfaces/config';
import { createContext, useContext } from 'react';
export const ConfigContext = createContext<ConfigContextType | null>(null);
export const useConfig = (): ConfigContextType => {
const context = useContext(ConfigContext);
if (context === null) {
throw new Error('useConfig must be used within a ConfigProvider');
}
return context;
};

9
src/interfaces/api.ts Normal file
View File

@@ -0,0 +1,9 @@
import { ImageMeta } from "@/interfaces/image";
export interface ImagesResponse {
images: ImageMeta[];
page: number;
totalPages: number;
}
export type TagsResponse = string[]

36
src/interfaces/config.ts Normal file
View File

@@ -0,0 +1,36 @@
export interface HomeConfig {
headline: string;
text: string;
buttonText: string;
}
export interface ContactLink {
url: string;
hoverColor: string;
image: {
src: string;
alt: string;
};
}
export interface ContactConfig {
headline: string;
links: ContactLink[];
imprint: {
enable: boolean;
headline: string;
name: string;
address: string;
country: string;
email: string;
}
}
export interface AppConfig {
home: HomeConfig;
contact: ContactConfig;
}
export interface ConfigContextType {
config: AppConfig | null;
}

8
src/interfaces/image.ts Normal file
View File

@@ -0,0 +1,8 @@
export interface ImageMeta {
id: string;
relative_path: string;
tags: string[];
aspect_ratio: number;
width: number;
height: number
}

View File

@@ -0,0 +1,4 @@
export enum PaginatorPosition {
TOP,
BOTTOM,
}

30
src/lib/actions.ts Normal file
View File

@@ -0,0 +1,30 @@
'use server';
import { signIn } from '@/auth';
import { AuthError } from 'next-auth';
import { cookies } from 'next/headers';
export async function authenticate(_prevState: string | undefined, formData: FormData): Promise<string> {
try {
await signIn('credentials', formData);
return 'success';
} catch (error) {
if (error instanceof AuthError) {
switch (error.type) {
case 'CredentialsSignin':
return 'Invalid token';
default:
return 'Something went wrong';
}
}
throw error;
}
}
export async function logout() {
const c = await cookies();
c.delete('authjs.session-token');
c.delete('authjs.csrf-token');
c.delete('authjs.callback-url');
}

28
src/lib/auth-utils.ts Normal file
View File

@@ -0,0 +1,28 @@
'use server';
import { getToken } from 'next-auth/jwt';
import { NextRequest } from 'next/server';
export async function validateAdminToken(token: string): Promise<boolean> {
return token === process.env.ADMIN_TOKEN;
}
export async function getAuthStatus() {
const { auth } = await import('@/auth');
const session = await auth();
return {
isAuthenticated: !!session?.user,
user: session?.user,
};
}
export async function isAuthenticated(request: NextRequest): Promise<boolean> {
const token = await getToken({ req: request, secret: process.env.AUTH_SECRET });
if (!token || (token.exp && Date.now() / 1000 > token.exp)) {
return false;
}
return true;
}

72
src/lib/data.ts Normal file
View File

@@ -0,0 +1,72 @@
import { imagesDir, jsonPath } from '@/const/api';
import { ImageMeta } from '@/interfaces/image';
import fs from 'fs/promises';
import path from 'path';
async function ensureDataDirectoryExists(): Promise<void> {
try {
await fs.access(path.dirname(jsonPath));
} catch {
await fs.mkdir(path.dirname(jsonPath), { recursive: true });
}
}
export async function getImageData(): Promise<ImageMeta[]> {
await ensureDataDirectoryExists();
try {
const data = await fs.readFile(jsonPath, 'utf8');
return JSON.parse(data);
} catch {
await updateImageData([]);
return [];
}
}
export function stringToTags(string: string): string[] {
return string
? string
.split(',')
.map((tag) => tag.trim())
.filter((t) => t !== 'All' && t.length > 0)
: [];
}
export async function updateTagsOfImageId(id: string, tags: string[]): Promise<number> {
const imagesData: ImageMeta[] = await getImageData();
const indexOfImage = imagesData.findIndex((i) => i.id === id);
if (indexOfImage === -1) {
return -1;
}
imagesData[indexOfImage].tags = tags;
updateImageData(imagesData);
return 0;
}
export async function deleteImageById(id: string): Promise<number> {
const imagesData: ImageMeta[] = await getImageData();
try {
const imagePath = imagesData.find((i) => i.id === id)?.relative_path;
if (imagePath) {
fs.rm(path.join(imagesDir, imagePath));
await updateImageData(imagesData.filter((i) => i.id !== id));
return 0;
}
} catch (e) {
console.log('Could not delete image', e);
}
return -1;
}
export async function addImage(newImage: ImageMeta): Promise<void> {
updateImageData([newImage].concat(await getImageData()));
}
async function updateImageData(newData: ImageMeta[]): Promise<void> {
await ensureDataDirectoryExists();
await fs.writeFile(jsonPath, JSON.stringify(newData, null, 2));
}

8
src/middleware.ts Normal file
View File

@@ -0,0 +1,8 @@
import NextAuth from 'next-auth';
import { authConfig } from './auth.config';
export default NextAuth(authConfig).auth;
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.png$).*)'],
};

View File

@@ -0,0 +1,11 @@
.container {
padding: 50px;
flex: 1;
display: flex;
justify-content: center;
gap: 100px;
@media (max-width: 1100px) {
flex-direction: column;
}
}

View File

@@ -0,0 +1,28 @@
.button {
height: 30px;
outline: 2px solid #24ceb1a1;
border-radius: 3px;
background-color: #ffffff0e;
border: 0;
cursor: pointer;
font-size: 16px;
font-weight: bold;
&:focus,
&:hover {
outline: 2px solid var(--color-accent);
background-color: #ffffff1a;
}
&Disabled {
outline: 2px solid #746d6d;
background-color: #ffffff15;
cursor: not-allowed;
&:focus,
&:hover {
outline: 2px solid #746d6d;
background-color: #ffffff15;
}
}
}

View File

@@ -0,0 +1,25 @@
.footer {
display: flex;
flex-direction: column;
gap: 10px;
justify-content: center;
align-items: center;
padding: 10px 0;
border-top: 1px solid rgba(128, 128, 128, 0.308);
font-size: 10pt;
}
.footerLandingPage {
margin-top: -70px;
border: 0;
}
.links {
display: flex;
gap: 40px;
&Link {
text-decoration: underline;
cursor: pointer;
}
}

View File

@@ -0,0 +1,115 @@
.gallery {
display: flex;
padding: 0 10px;
flex-direction: column;
flex: 1;
margin-bottom: 20px;
}
.images {
display: grid;
grid-template-columns: repeat(5, 1fr);
max-width: 2300px;
width: 100%;
gap: 5px;
@media (max-width: 1920px) {
grid-template-columns: repeat(4, 1fr);
}
@media (max-width: 1200px) {
grid-template-columns: repeat(3, 1fr);
}
@media (max-width: 600px) {
grid-template-columns: 1fr;
}
&Container {
--height: 220px;
--gap: 5px;
display: flex;
flex-direction: column;
gap: var(--gap);
height: var(--height);
cursor: pointer;
img {
width: 100%;
height: 100%;
object-fit: cover;
}
}
.horizontal {
grid-column: span 2;
}
.vertical {
grid-row: span 2;
height: calc(var(--height) * 2 + var(--gap));
}
}
.fullscreenModal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 1);
display: flex;
justify-content: center;
align-items: center;
z-index: 1000;
cursor: pointer;
img {
max-width: 90%;
max-height: 90%;
object-fit: contain;
}
}
.closeButton {
position: absolute;
top: 20px;
right: 20px;
background: none;
border: none;
color: white;
font-size: 2rem;
cursor: pointer;
transition: all 50ms ease-in-out;
z-index: 1001;
&:hover {
color: #ca2929;
}
}
.arrowButton {
position: absolute;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: white;
font-size: 3rem;
cursor: pointer;
transition: all 50ms ease-in-out;
z-index: 1001;
&:hover {
color: #2a9d6d;
}
&Left {
left: 20px;
}
&Right {
right: 20px;
}
}

View File

@@ -0,0 +1,47 @@
.header {
display: flex;
justify-content: space-between;
height: var(--header-height);
}
.logo {
display: flex;
justify-content: center;
align-items: center;
margin-left: 30px;
img {
height: 18px;
width: auto;
}
}
.nav {
display: flex;
justify-content: space-between;
align-items: center;
gap: 30px;
padding: 0 20px;
&Link {
transition: all 50ms ease-in-out;
border-bottom: 2px solid transparent;
text-decoration: none;
color: inherit;
cursor: pointer;
&:hover {
color: var(--color-accent);
}
&Active {
color: var(--color-accent);
border-color: var(--color-accent);
}
}
&Socials {
display: flex;
gap: 5px;
}
}

View File

@@ -0,0 +1,33 @@
.container {
display: flex;
flex-direction: column;
align-items: center;
}
.tableContainer {
overflow: auto;
max-height: 600px;
display: block;
height: 100%;
margin-top: 40px;
}
.table {
td {
padding: 0 10px;
font-size: 12px;
height: 80px;
}
}
.tagForm {
display: flex;
align-items: center;
gap: 10px;
}
.error {
font-size: 15px;
font-weight: bold;
color: rgb(255, 64, 64);
}

View File

@@ -0,0 +1,17 @@
.container {
display: flex;
align-items: center;
flex-direction: column;
}
.form {
gap: 10px;
display: flex;
flex-direction: column;
}
.message {
font-weight: bold;
font-size: 15px;
height: 30px;
}

View File

@@ -0,0 +1,19 @@
.imprint {
display: flex;
flex-direction: column;
padding-top: 20px;
margin-top: 20px;
width: 600px;
gap: 8px;
@media (max-width: 600px) {
width: 100%;
padding: 20px;
box-sizing: border-box;
}
}
.imprintHeadline {
text-align: center;
margin-bottom: 15px;
}

View File

@@ -0,0 +1,48 @@
.container {
display: flex;
flex-direction: column;
}
.label {
font-weight: bold;
font-size: 14px;
}
.input {
height: 30px;
border-radius: 3px;
background-color: #ffffff0e;
border: 0;
outline: 2px solid #24ceb1a1;
transition: all 50ms linear;
font-size: 15px;
font-weight: bold;
padding: 0 10px;
&:focus,
&:hover {
outline: 2px solid var(--color-accent);
background-color: #ffffff1a;
}
&Invalid {
outline: 2px solid #ce2424a1;
&:focus,
&:hover {
outline: 2px solid #ce2424;
}
}
&Disabled {
outline: 2px solid #746d6d;
background-color: #ffffff15;
cursor: not-allowed;
&:focus,
&:hover {
outline: 2px solid #746d6d;
background-color: #ffffff15;
}
}
}

View File

@@ -0,0 +1,20 @@
.container {
display: flex;
margin-top: 50px;
justify-content: center;
flex: 1;
}
.form {
display: flex;
gap: 30px;
flex-direction: column;
width: 500px;
}
.errorMessage {
text-align: center;
color: rgb(255, 76, 76);
font-weight: bold;
height: 20px;
}

View File

@@ -0,0 +1,36 @@
.paginator {
display: flex;
justify-content: end;
align-items: start;
gap: 5px;
flex: 1;
button {
padding: 5px 10px;
border: none;
background-color: transparent;
color: white;
cursor: pointer;
border-bottom: 2px solid transparent;
font-size: 12pt;
transition: all 30ms ease-in-out;
&:hover {
border-bottom-color: var(--color-accent);
}
&.active {
border-bottom-color: var(--color-accent);
font-weight: bold;
}
&:disabled {
background-color: #0f0f0f;
cursor: not-allowed;
&:hover {
border-bottom-color: transparent;
}
}
}
}

View File

@@ -0,0 +1,25 @@
.tags {
display: flex;
justify-content: start;
align-items: start;
gap: 5px 20px;
flex-wrap: wrap;
flex: 1;
}
.tag {
cursor: pointer;
padding: 0 5px;
transition: all 30ms ease-in-out;
border-bottom: 2px solid transparent;
font-size: 12pt;
&:hover {
border-bottom-color: var(--color-accent);
}
&Active {
border-bottom-color: var(--color-accent);
font-weight: bold;
}
}

View File

@@ -0,0 +1,18 @@
.topbar {
display: flex;
flex-direction: row;
padding: 10px 30px;
gap: 20px;
top: 0px;
position: sticky;
&Scroll {
background-color: rgba(7, 7, 7, 0.7);
}
@media (max-width: 800px) {
align-items: center;
flex-direction: column;
gap: 10px;
}
}

27
tsconfig.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}