Initial Next.js photo gallery application

- Set up Next.js 15 with TypeScript and Tailwind CSS v4
- Configured responsive layout with header, sidebar, and main content area
- Implemented directory scan modal with real-time validation
- Added reusable Button component with primary/secondary variants
- Created API endpoint for server-side directory validation
- Integrated Tabler icons for UI feedback
- Configured PostCSS with @tailwindcss/postcss for proper styling

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Michael Mainguy 2025-08-26 13:24:38 -05:00
commit c44c820239
20 changed files with 2461 additions and 0 deletions

36
.gitignore vendored Normal file
View File

@ -0,0 +1,36 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
.yarn/install-state.gz
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env*.local
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

8
.idea/.gitignore generated vendored Normal file
View File

@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

8
.idea/modules.xml generated Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/photos.iml" filepath="$PROJECT_DIR$/.idea/photos.iml" />
</modules>
</component>
</project>

12
.idea/photos.iml generated Normal file
View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

6
.idea/vcs.xml generated Normal file
View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

22
CLAUDE.md Normal file
View File

@ -0,0 +1,22 @@
# nextjs project to display and organize photos
- uses tailwindcss for styling
- uses next/image for image optimization
- uses next/font for font optimization
- uses notejs 22
# Roadmap
[x] Set up Next.js project with typescript (https://nextjs.org/docs/app/getting-started/installation)
[x] Install Tailwind CSS
[x] Configure next/image
[x] Configure next/font
[x] Set up TypeScript
[x] initialize git repo with appropriate .gitignore
[ ] Create responsive layout with Tailwind CSS
[ ] Integrate localdb for backend photo index data
[ ] create service to index photos from local filesystem
[ ] Create photo gallery page
[ ] Implement photo organization features (albums, tags, moving files)
[ ] Optimize for performance and SEO
- I'll run dev myself

10
next.config.js Normal file
View File

@ -0,0 +1,10 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
images: {
formats: ['image/webp', 'image/avif'],
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
},
}
module.exports = nextConfig

1923
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "photos",
"version": "1.0.0",
"main": "index.js",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"keywords": [],
"author": "",
"license": "ISC",
"description": "",
"dependencies": {
"@tabler/icons-react": "^3.34.1",
"next": "^15.5.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.12",
"@types/node": "^24.3.0",
"@types/react": "^19.1.11",
"@types/react-dom": "^19.1.8",
"autoprefixer": "^10.4.21",
"postcss": "^8.5.6",
"tailwindcss": "^4.1.12",
"typescript": "^5.9.2"
}
}

6
postcss.config.mjs Normal file
View File

@ -0,0 +1,6 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server'
import { existsSync, statSync } from 'fs'
import path from 'path'
export async function POST(request: NextRequest) {
try {
const { directory } = await request.json()
if (!directory || typeof directory !== 'string') {
return NextResponse.json({
valid: false,
error: 'Directory path is required'
})
}
const normalizedPath = path.resolve(directory.trim())
const exists = existsSync(normalizedPath)
if (!exists) {
return NextResponse.json({
valid: false,
error: 'Directory does not exist'
})
}
const stats = statSync(normalizedPath)
if (!stats.isDirectory()) {
return NextResponse.json({
valid: false,
error: 'Path is not a directory'
})
}
return NextResponse.json({
valid: true,
path: normalizedPath
})
} catch (error) {
return NextResponse.json({
valid: false,
error: 'Invalid directory path'
}, { status: 400 })
}
}

19
src/app/globals.css Normal file
View File

@ -0,0 +1,19 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
}

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

@ -0,0 +1,25 @@
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import MainLayout from '@/components/MainLayout'
const inter = Inter({ subsets: ['latin'] })
export const metadata: Metadata = {
title: 'Photos App',
description: 'A Next.js app to display and organize photos',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body className={inter.className}>
<MainLayout>{children}</MainLayout>
</body>
</html>
)
}

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

@ -0,0 +1,19 @@
export default function Home() {
return (
<div className="p-6">
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
All Photos
</h1>
<p className="text-gray-600 dark:text-gray-400">
Your photo collection
</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6 gap-4">
</div>
</div>
)
}

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

@ -0,0 +1,28 @@
import { ButtonHTMLAttributes, ReactNode } from 'react'
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary'
children: ReactNode
}
export default function Button({
variant = 'primary',
children,
className = '',
...props
}: ButtonProps) {
const baseClasses = 'px-12 py-2 rounded font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2'
const variantClasses = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed focus:ring-blue-500',
secondary: 'border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700 focus:ring-gray-500'
}
const finalClasses = `${baseClasses} ${variantClasses[variant]} ${className}`
return (
<button className={finalClasses} {...props}>
{children}
</button>
)
}

View File

@ -0,0 +1,141 @@
'use client'
import { useState, useCallback, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { IconCheck, IconX } from '@tabler/icons-react'
import Button from './Button'
interface DirectoryModalProps {
isOpen: boolean
onClose: () => void
onSave: (directory: string) => void
}
export default function DirectoryModal({ isOpen, onClose, onSave }: DirectoryModalProps) {
const [directory, setDirectory] = useState('')
const [isValidating, setIsValidating] = useState(false)
const [isValid, setIsValid] = useState<boolean | null>(null)
const [validationTimeout, setValidationTimeout] = useState<NodeJS.Timeout | null>(null)
const [mounted, setMounted] = useState(false)
useEffect(() => {
setMounted(true)
return () => setMounted(false)
}, [])
const validateDirectory = useCallback(async (path: string) => {
if (!path.trim()) {
setIsValid(null)
return
}
setIsValidating(true)
try {
const response = await fetch('/api/validate-directory', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ directory: path }),
})
const result = await response.json()
setIsValid(result.valid)
} catch (error) {
setIsValid(false)
} finally {
setIsValidating(false)
}
}, [])
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value
setDirectory(value)
if (validationTimeout) {
clearTimeout(validationTimeout)
}
const timeout = setTimeout(() => {
validateDirectory(value)
}, 300)
setValidationTimeout(timeout)
}
if (!isOpen || !mounted) return null
console.log('Modal is rendering with isOpen:', isOpen)
const handleSave = () => {
if (directory.trim() && isValid) {
onSave(directory.trim())
setDirectory('')
setIsValid(null)
onClose()
}
}
const handleClose = () => {
setDirectory('')
setIsValid(null)
if (validationTimeout) {
clearTimeout(validationTimeout)
}
onClose()
}
const modalContent = (
<div className="absolute inset-0 bg-black bg-opacity-50 z-[9999]" style={{ display: 'block', position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, height: '100vh', width: '100vw' }}>
<div
className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 bg-white dark:bg-gray-800 p-6 rounded-lg w-96 shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-lg font-semibold mb-4 text-gray-900 dark:text-white">
Select Directory to Scan
</h2>
<div className="relative">
<input
type="text"
value={directory}
onChange={handleInputChange}
placeholder="/path/to/photos"
className="w-full p-2 pr-10 border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-white"
autoFocus
/>
<div className="absolute right-3 top-1/2 transform -translate-y-1/2">
{isValidating && (
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-600 border-t-transparent" />
)}
{!isValidating && isValid === true && (
<IconCheck className="h-4 w-4 text-green-600" />
)}
{!isValidating && (isValid === false || (directory && isValid === null)) && (
<IconX className="h-4 w-4 text-red-600" />
)}
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
onClick={handleSave}
disabled={!isValid || isValidating}
variant="primary"
>
Save
</Button>
<Button
onClick={handleClose}
variant="secondary"
>
Cancel
</Button>
</div>
</div>
</div>
)
return createPortal(modalContent, document.body)
}

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

@ -0,0 +1,33 @@
'use client'
import Button from './Button'
interface HeaderProps {
onScanDirectory: () => void
}
export default function Header({ onScanDirectory }: HeaderProps) {
return (
<header className="bg-white dark:bg-gray-900 shadow-sm border-b border-gray-200 dark:border-gray-700">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div className="flex justify-between items-center h-16">
<div className="flex items-center">
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
Photos
</h1>
</div>
<nav className="md:flex space-x-8">
<Button
onClick={onScanDirectory}
variant="secondary"
className="text-sm"
>
Scan Directory
</Button>
</nav>
</div>
</div>
</header>
)
}

View File

@ -0,0 +1,39 @@
'use client'
import { useState } from 'react'
import Header from './Header'
import Sidebar from './Sidebar'
import DirectoryModal from './DirectoryModal'
interface MainLayoutProps {
children: React.ReactNode
}
export default function MainLayout({ children }: MainLayoutProps) {
const [isModalOpen, setIsModalOpen] = useState(false)
const handleDirectorySave = (directory: string) => {
console.log('Directory to scan:', directory)
}
console.log('Modal state:', isModalOpen)
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-900">
<Header onScanDirectory={() => setIsModalOpen(true)} />
<div className="flex">
<Sidebar />
<main className="flex-1 min-h-screen">
{children}
</main>
</div>
{isModalOpen && (
<DirectoryModal
isOpen={isModalOpen}
onClose={() => setIsModalOpen(false)}
onSave={handleDirectorySave}
/>
)}
</div>
)
}

View File

@ -0,0 +1,8 @@
'use client'
export default function Sidebar() {
return (
<aside className={`w-1/4 bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 transition-all duration-300 lg:block`}>
<h2>Sidebar</h2>
</aside>
)
}

40
tsconfig.json Normal file
View File

@ -0,0 +1,40 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"es6"
],
"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/*"
]
},
"target": "ES2017"
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}