mirror of
				https://github.com/DerTyp7/f1r3wave-website.git
				synced 2025-10-31 14:07:06 +01:00 
			
		
		
		
	first commit
This commit is contained in:
		
							
								
								
									
										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; | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user