mirror of
https://github.com/DerTyp7/f1r3wave-website.git
synced 2025-10-28 12:32:08 +01:00
first commit
This commit is contained in:
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.next
|
||||
.vscode
|
||||
public/images
|
||||
node_modules
|
||||
data
|
||||
.env
|
||||
.env.local
|
||||
70
.eslintrc.js
Normal file
70
.eslintrc.js
Normal 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
43
.gitignore
vendored
Normal 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
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"cSpell.words": ["authjs", "topbar"]
|
||||
}
|
||||
13
Dockerfile
Normal file
13
Dockerfile
Normal 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
12
README.md
Normal 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
11
docker-compose.yml
Normal 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
25
eslint.config.mjs
Normal 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
27
next.config.ts
Normal 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
14
nginx.conf
Normal 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
6959
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
43
package.json
Normal file
43
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
36
public/config-example.json
Normal file
36
public/config-example.json
Normal 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
36
public/config.json
Normal 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
BIN
public/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 54 KiB |
3
public/images/.gitignore
vendored
Normal file
3
public/images/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.jpg
|
||||
*.png
|
||||
*.jpeg
|
||||
BIN
public/landing-page.jpg
Executable file
BIN
public/landing-page.jpg
Executable file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
3
public/robots.txt
Normal file
3
public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
Sitemap: https://f1r3wave.photos/sitemap.xml
|
||||
15
public/sitemap.xml
Normal file
15
public/sitemap.xml
Normal 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
12
src/.prettierrc
Normal 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
44
src/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/app/api/images/[id]/route.ts
Normal file
18
src/app/api/images/[id]/route.ts
Normal 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
101
src/app/api/images/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
21
src/app/api/tags/[imageId]/route.ts
Normal file
21
src/app/api/tags/[imageId]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
8
src/app/api/tags/route.ts
Normal file
8
src/app/api/tags/route.ts
Normal 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))));
|
||||
}
|
||||
52
src/app/gallery/[tag]/[page]/page.tsx
Normal file
52
src/app/gallery/[tag]/[page]/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
src/app/gallery/[tag]/page.tsx
Normal file
5
src/app/gallery/[tag]/page.tsx
Normal 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
5
src/app/gallery/page.tsx
Normal 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
10
src/app/imprint/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/app/imprint/styles.module.scss
Normal file
5
src/app/imprint/styles.module.scss
Normal file
@@ -0,0 +1,5 @@
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
}
|
||||
39
src/app/index.scss
Normal file
39
src/app/index.scss
Normal 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
22
src/app/layout.tsx
Normal 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
13
src/app/login/page.tsx
Normal 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
26
src/app/page.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
63
src/app/styles.module.scss
Normal file
63
src/app/styles.module.scss
Normal 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
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
21
src/auth.config.ts
Normal 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
30
src/auth.ts
Normal 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
22
src/components/Button.tsx
Normal 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
40
src/components/Footer.tsx
Normal 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>
|
||||
© {currentYear} <Link href="https://github.com/DerTyp7">DerTyp7</Link>. All rights reserved.
|
||||
</span>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
188
src/components/Gallery.tsx
Normal file
188
src/components/Gallery.tsx
Normal 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);
|
||||
}}>
|
||||
×
|
||||
</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}`);
|
||||
}
|
||||
}}>
|
||||
‹
|
||||
</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}`);
|
||||
}
|
||||
}}>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
src/components/Header.tsx
Normal file
65
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
143
src/components/ImageManager.tsx
Normal file
143
src/components/ImageManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
src/components/ImageUpload.tsx
Normal file
92
src/components/ImageUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/components/Imprint.tsx
Normal file
18
src/components/Imprint.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
src/components/InputField.tsx
Normal file
59
src/components/InputField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
29
src/components/LoginForm.tsx
Normal file
29
src/components/LoginForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
44
src/components/Paginator.tsx
Normal file
44
src/components/Paginator.tsx
Normal 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
36
src/components/Tags.tsx
Normal 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
34
src/components/Topbar.tsx
Normal 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
4
src/const/api.ts
Normal 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');
|
||||
36
src/contexts/ConfigContext.tsx
Normal file
36
src/contexts/ConfigContext.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
14
src/contexts/configExports.ts
Normal file
14
src/contexts/configExports.ts
Normal 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
9
src/interfaces/api.ts
Normal 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
36
src/interfaces/config.ts
Normal 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
8
src/interfaces/image.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface ImageMeta {
|
||||
id: string;
|
||||
relative_path: string;
|
||||
tags: string[];
|
||||
aspect_ratio: number;
|
||||
width: number;
|
||||
height: number
|
||||
}
|
||||
4
src/interfaces/paginator.ts
Normal file
4
src/interfaces/paginator.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum PaginatorPosition {
|
||||
TOP,
|
||||
BOTTOM,
|
||||
}
|
||||
30
src/lib/actions.ts
Normal file
30
src/lib/actions.ts
Normal 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
28
src/lib/auth-utils.ts
Normal 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
72
src/lib/data.ts
Normal 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
8
src/middleware.ts
Normal 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$).*)'],
|
||||
};
|
||||
11
src/styles/AdminPage.module.scss
Normal file
11
src/styles/AdminPage.module.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.container {
|
||||
padding: 50px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 100px;
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
28
src/styles/Button.module.scss
Normal file
28
src/styles/Button.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/styles/Footer.module.scss
Normal file
25
src/styles/Footer.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
115
src/styles/Gallery.module.scss
Normal file
115
src/styles/Gallery.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
47
src/styles/Header.module.scss
Normal file
47
src/styles/Header.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
33
src/styles/ImageManager.module.scss
Normal file
33
src/styles/ImageManager.module.scss
Normal 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);
|
||||
}
|
||||
17
src/styles/ImageUpload.module.scss
Normal file
17
src/styles/ImageUpload.module.scss
Normal 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;
|
||||
}
|
||||
19
src/styles/Imprint.module.scss
Normal file
19
src/styles/Imprint.module.scss
Normal 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;
|
||||
}
|
||||
48
src/styles/InputField.module.scss
Normal file
48
src/styles/InputField.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
src/styles/LoginForm.module.scss
Normal file
20
src/styles/LoginForm.module.scss
Normal 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;
|
||||
}
|
||||
36
src/styles/Paginator.module.scss
Normal file
36
src/styles/Paginator.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/styles/Tags.module.scss
Normal file
25
src/styles/Tags.module.scss
Normal 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;
|
||||
}
|
||||
}
|
||||
18
src/styles/Topbar.module.scss
Normal file
18
src/styles/Topbar.module.scss
Normal 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
27
tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user