Initial commit: Tool Website with Password Generator, Image Converter and Bookmarks

This commit is contained in:
danielvici123
2025-05-16 00:40:51 +02:00
commit d478acc6e3
19 changed files with 4049 additions and 0 deletions

33
.gitignore vendored Normal file
View File

@@ -0,0 +1,33 @@
# dependencies
/node_modules
/.pnp
.pnp.js
# 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

49
README.md Normal file
View File

@@ -0,0 +1,49 @@
# Tool Website
A collection of useful web-based tools built with Next.js, TypeScript, and Tailwind CSS.
## Features
- Password Generator
- Image Format Converter
- Website Bookmarks
- More tools coming soon!
## Tech Stack
- Next.js 14
- TypeScript
- Tailwind CSS
- React
- Sharp (for image processing)
## Getting Started
1. Clone the repository:
```bash
git clone https://github.com/yourusername/tool-website.git
```
2. Install dependencies:
```bash
cd tool-website
npm install
```
3. Run the development server:
```bash
npm run dev
```
4. Open [http://localhost:3000](http://localhost:3000) in your browser.
## Development
- `npm run dev` - Start development server
- `npm run build` - Build for production
- `npm start` - Start production server
- `npm run lint` - Run ESLint
## Privacy
All tools operate entirely in the browser. No data is sent to external servers (except for explicitly shared URLs in bookmarks). Your data remains on your device and is stored only in your browser's local storage.

34
app/about/page.tsx Normal file
View File

@@ -0,0 +1,34 @@
export default function About() {
return (
<main className="min-h-screen p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-4xl font-bold mb-8 text-white">About</h1>
<div className="bg-zinc-800 rounded-lg shadow-md p-6">
<div className="prose prose-invert max-w-none">
<p className="text-gray-300 mb-4">
Welcome to our Tool Website! This platform provides a collection of useful web-based tools
designed to help you with various tasks. All tools run directly in your browser, ensuring
your data stays private and secure.
</p>
<h2 className="text-2xl font-bold text-white mt-6 mb-4">Features</h2>
<ul className="list-disc pl-6 text-gray-300 space-y-2">
<li>Password Generator - Create secure passwords with customizable options</li>
<li>Image Converter - Convert images between different formats</li>
<li>Website Bookmarks - Save and organize your favorite websites</li>
<li>More tools coming soon!</li>
</ul>
<h2 className="text-2xl font-bold text-white mt-6 mb-4">Privacy</h2>
<p className="text-gray-300">
All tools operate entirely in your browser. No data is sent to external servers
(except for explicitly shared URLs in bookmarks). Your data remains on your device
and is stored only in your browser&apos;s local storage.
</p>
</div>
</div>
</div>
</main>
)
}

64
app/api/convert/route.ts Normal file
View File

@@ -0,0 +1,64 @@
import { NextRequest, NextResponse } from 'next/server'
import sharp from 'sharp'
export async function POST(req: NextRequest) {
try {
const formData = await req.formData()
const file = formData.get('file') as File
const format = formData.get('format') as string
if (!file || !format) {
return NextResponse.json(
{ error: 'File and format are required' },
{ status: 400 }
)
}
const buffer = Buffer.from(await file.arrayBuffer())
let convertedBuffer: Buffer
try {
const sharpInstance = sharp(buffer)
switch (format) {
case 'png':
convertedBuffer = await sharpInstance.png().toBuffer()
break
case 'jpg':
case 'jpeg':
convertedBuffer = await sharpInstance.jpeg().toBuffer()
break
case 'webp':
convertedBuffer = await sharpInstance.webp().toBuffer()
break
case 'gif':
convertedBuffer = await sharpInstance.gif().toBuffer()
break
case 'bmp':
convertedBuffer = await sharpInstance.bmp().toBuffer()
break
default:
throw new Error('Unsupported format')
}
return new NextResponse(convertedBuffer, {
headers: {
'Content-Type': `image/${format}`,
'Content-Disposition': `attachment; filename="converted.${format}"`,
},
})
} catch (error) {
console.error('Conversion error:', error)
return NextResponse.json(
{ error: 'Error converting image' },
{ status: 500 }
)
}
} catch (error) {
console.error('Server error:', error)
return NextResponse.json(
{ error: 'Server error' },
{ status: 500 }
)
}
}

134
app/bildconverter/page.tsx Normal file
View File

@@ -0,0 +1,134 @@
'use client'
import { useState, useCallback } from 'react'
import { useDropzone } from 'react-dropzone'
export default function ImageConverter() {
const [selectedFile, setSelectedFile] = useState<File | null>(null)
const [convertTo, setConvertTo] = useState('png')
const [isConverting, setIsConverting] = useState(false)
const [error, setError] = useState<string | null>(null)
const onDrop = useCallback((acceptedFiles: File[]) => {
setError(null)
if (acceptedFiles[0]) {
setSelectedFile(acceptedFiles[0])
}
}, [])
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: {
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.bmp']
},
multiple: false
})
const handleConvert = async () => {
if (!selectedFile) return
setError(null)
setIsConverting(true)
try {
const formData = new FormData()
formData.append('file', selectedFile)
formData.append('format', convertTo)
const response = await fetch('/api/convert', {
method: 'POST',
body: formData,
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Konvertierung fehlgeschlagen')
}
// Blob aus der Antwort erstellen
const blob = await response.blob()
// Download-Link erstellen und klicken
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `picture_converted_to.${convertTo}`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
} catch (err) {
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten')
} finally {
setIsConverting(false)
}
}
return (
<main className="min-h-screen p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-4xl font-bold mb-8 text-white">Bild Converter</h1>
<div className="bg-zinc-800 rounded-lg shadow-md p-6">
<div className="space-y-6">
{/* Drag & Drop Zone */}
<div
{...getRootProps()}
className={`border-2 border-dashed rounded-lg p-8 text-center cursor-pointer transition-colors
${isDragActive ? 'border-blue-500 bg-blue-500/10' : 'border-gray-600 hover:border-blue-500'}
`}
>
<input {...getInputProps()} />
<p className="text-gray-300">
{isDragActive
? 'Lass das Bild hier fallen...'
: 'Ziehe ein Bild hierher oder klicke zum Auswählen'}
</p>
{selectedFile && (
<p className="mt-2 text-blue-400">
Ausgewählt: {selectedFile.name}
</p>
)}
</div>
{/* Format Auswahl */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
Zielformat
</label>
<select
value={convertTo}
onChange={(e) => setConvertTo(e.target.value)}
className="w-full bg-zinc-700 text-white rounded-md border border-gray-600 p-2"
>
<option value="png">PNG</option>
<option value="jpg">JPG</option>
<option value="webp">WebP</option>
<option value="gif">GIF</option>
<option value="bmp">BMP</option>
</select>
</div>
{/* Error Message */}
{error && (
<div className="text-red-500 text-sm p-3 bg-red-500/10 rounded-md border border-red-500/20">
{error}
</div>
)}
{/* Konvertieren Button */}
<button
onClick={handleConvert}
disabled={!selectedFile || isConverting}
className={`w-full py-2 px-4 rounded-md text-white transition-colors
${!selectedFile || isConverting
? 'bg-blue-500/50 cursor-not-allowed'
: 'bg-blue-500 hover:bg-blue-600'}
`}
>
{isConverting ? 'Konvertiere...' : 'Konvertieren'}
</button>
</div>
</div>
</div>
</main>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import { useState } from 'react'
import { PinnedWebsite } from '../types/types'
export default function AddWebsite() {
const [url, setUrl] = useState('')
const [title, setTitle] = useState('')
const [isAdding, setIsAdding] = useState(false)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
// Validiere URL
try {
new URL(url)
} catch {
alert('Please enter a valid URL')
return
}
const newWebsite: PinnedWebsite = {
id: Date.now().toString(),
title: title || url,
url: url.startsWith('http') ? url : `https://${url}`,
addedAt: Date.now()
}
// Lade bestehende Websites
const existingWebsites = JSON.parse(localStorage.getItem('pinnedWebsites') || '[]')
// Füge neue Website hinzu
localStorage.setItem('pinnedWebsites', JSON.stringify([...existingWebsites, newWebsite]))
// Setze Formular zurück
setUrl('')
setTitle('')
setIsAdding(false)
// Lade die Seite neu um die neue Website anzuzeigen
window.location.reload()
}
return (
<div className="mb-6">
{!isAdding ? (
<button
onClick={() => setIsAdding(true)}
className="w-full p-4 border border-dashed border-gray-700 rounded-lg text-gray-400 hover:border-blue-500 hover:text-blue-500 transition-colors"
>
+ Add New Website
</button>
) : (
<form onSubmit={handleSubmit} className="bg-zinc-800 p-4 rounded-lg space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
URL
</label>
<input
type="text"
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com"
className="w-full bg-zinc-700 text-white rounded-md border border-gray-600 p-2"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Title (optional)
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="My Website"
className="w-full bg-zinc-700 text-white rounded-md border border-gray-600 p-2"
/>
</div>
<div className="flex space-x-3">
<button
type="submit"
className="flex-1 bg-blue-500 text-white py-2 px-4 rounded-md hover:bg-blue-600 transition-colors"
>
Add
</button>
<button
type="button"
onClick={() => setIsAdding(false)}
className="flex-1 bg-zinc-700 text-white py-2 px-4 rounded-md hover:bg-zinc-600 transition-colors"
>
Cancel
</button>
</div>
</form>
)}
</div>
)
}

23
app/components/Navbar.tsx Normal file
View File

@@ -0,0 +1,23 @@
'use client'
import React from 'react'
import Link from 'next/link'
export default function Navbar() {
return (
<nav className="bg-blue-900 p-4 shadow-lg">
<div className="container mx-auto">
<div className="flex justify-between items-center">
<div className="flex-1" /> {/* Spacer */}
<Link href="/" className="text-white font-bold text-xl hover:text-blue-100">
START
</Link>
<div className="flex-1 flex justify-end">
<Link href="/about" className="text-white hover:text-blue-100">
About
</Link>
</div>
</div>
</div>
</nav>
)
}

14
app/globals.css Normal file
View File

@@ -0,0 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
html, body {
@apply bg-zinc-900;
height: 100%;
overflow: hidden;
}
main {
height: calc(100vh - 64px); /* 64px ist die Höhe der Navbar */
overflow-y: auto;
}

25
app/layout.tsx Normal file
View File

@@ -0,0 +1,25 @@
import './globals.css'
import { Inter } from 'next/font/google'
import Navbar from './components/Navbar'
const inter = Inter({ subsets: ['latin'] })
export const metadata = {
title: 'Tool Website',
description: 'Eine Sammlung nützlicher Tools',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="de">
<body className={inter.className}>
<Navbar />
{children}
</body>
</html>
)
}

100
app/page.tsx Normal file
View File

@@ -0,0 +1,100 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import { PinnedWebsite } from './types/types'
import AddWebsite from './components/AddWebsite'
export default function Home() {
const [pinnedWebsites, setPinnedWebsites] = useState<PinnedWebsite[]>([])
useEffect(() => {
const saved = localStorage.getItem('pinnedWebsites')
if (saved) {
setPinnedWebsites(JSON.parse(saved))
}
}, [])
const handleDelete = (id: string) => {
const newWebsites = pinnedWebsites.filter(site => site.id !== id)
localStorage.setItem('pinnedWebsites', JSON.stringify(newWebsites))
setPinnedWebsites(newWebsites)
}
const tools = [
{
name: 'Password Generator',
file: '/passwortgenerator',
description: 'Create secure passwords with custom options',
},
{
name: 'Image Converter',
file: '/bildconverter',
description: 'Convert images between different formats',
},
{
name: 'More Coming Soon',
file: '/about',
description: 'Stay tuned for new tools!',
isComingSoon: true
}
]
return (
<main className="min-h-screen p-8 text-white">
<div className="max-w-7xl mx-auto space-y-12">
{/* Tools Section */}
<section>
<h2 className="text-2xl font-bold mb-6">Tools</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{tools.map((tool, index) => (
<Link
key={index}
href={tool.file}
className={`p-6 rounded-lg border border-gray-700 bg-zinc-800 transition-colors duration-200 cursor-pointer
${tool.isComingSoon
? 'opacity-50 hover:opacity-75'
: 'hover:border-blue-500'}`}
>
<h2 className="text-xl font-semibold mb-2">{tool.name}</h2>
<p className="text-gray-400">{tool.description}</p>
</Link>
))}
</div>
</section>
{/* Pinned Websites Section */}
<section>
<h2 className="text-2xl font-bold mb-6">Bookmarked Websites</h2>
<AddWebsite />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{pinnedWebsites.map((website) => (
<div
key={website.id}
className="group relative p-6 rounded-lg border border-gray-700 bg-zinc-800 hover:border-blue-500 transition-colors"
>
<a
href={website.url}
target="_blank"
rel="noopener noreferrer"
className="block"
>
<h3 className="text-xl font-semibold mb-2">{website.title}</h3>
<p className="text-gray-400 truncate">{website.url}</p>
</a>
<button
onClick={(e) => {
e.preventDefault()
handleDelete(website.id)
}}
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-500 transition-opacity"
>
</button>
</div>
))}
</div>
</section>
</div>
</main>
)
}

View File

@@ -0,0 +1,103 @@
'use client'
import { useState } from 'react'
export default function PasswordGenerator() {
const [password, setPassword] = useState('')
const [length, setLength] = useState(12)
const [includeNumbers, setIncludeNumbers] = useState(true)
const [includeSymbols, setIncludeSymbols] = useState(true)
const generatePassword = () => {
const numbers = '0123456789'
const symbols = '!@#$%^&*()_+-=[]{}|;:,.<>?'
const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'
let chars = letters
if (includeNumbers) chars += numbers
if (includeSymbols) chars += symbols
let newPassword = ''
for (let i = 0; i < length; i++) {
newPassword += chars[Math.floor(Math.random() * chars.length)]
}
setPassword(newPassword)
}
return (
<main className="min-h-screen p-8">
<div className="max-w-2xl mx-auto">
<h1 className="text-4xl font-bold mb-8">Passwort Generator</h1>
<div className="bg-white rounded-lg shadow-md p-6">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700">
Passwortlänge: {length}
</label>
<input
type="range"
min="8"
max="32"
value={length}
onChange={(e) => setLength(Number(e.target.value))}
className="w-full mt-2"
/>
</div>
<div className="flex space-x-4">
<label className="flex items-center">
<input
type="checkbox"
checked={includeNumbers}
onChange={(e) => setIncludeNumbers(e.target.checked)}
className="rounded border-gray-300 text-blue-600 mr-2"
/>
Zahlen
</label>
<label className="flex items-center">
<input
type="checkbox"
checked={includeSymbols}
onChange={(e) => setIncludeSymbols(e.target.checked)}
className="rounded border-gray-300 text-blue-600 mr-2"
/>
Sonderzeichen
</label>
</div>
<button
onClick={generatePassword}
className="w-full bg-blue-600 text-white py-2 px-4 rounded-md hover:bg-blue-700 transition-colors"
>
Passwort generieren
</button>
{password && (
<div className="mt-4">
<label className="block text-sm font-medium text-gray-700 mb-2">
Generiertes Passwort:
</label>
<div className="flex">
<input
type="text"
readOnly
value={password}
className="flex-1 border rounded-l-md p-2"
/>
<button
onClick={() => navigator.clipboard.writeText(password)}
className="bg-gray-100 px-4 rounded-r-md border-t border-r border-b hover:bg-gray-200"
>
Kopieren
</button>
</div>
</div>
)}
</div>
</div>
</div>
</main>
)
}

7
app/types/types.ts Normal file
View File

@@ -0,0 +1,7 @@
export type PinnedWebsite = {
id: string;
title: string;
url: string;
icon?: string;
addedAt: number;
}

3257
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

28
package.json Normal file
View File

@@ -0,0 +1,28 @@
{
"name": "tool-website",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@tailwindcss/postcss": "^4.1.7",
"next": "^14.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-dropzone": "^14.3.8",
"sharp": "^0.34.1"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.0.0"
}
}

6
postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

6
postcss.config.mjs Normal file
View File

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

28
start.js Normal file
View File

@@ -0,0 +1,28 @@
const { exec } = require('child_process');
const os = require('os');
// Starte den Entwicklungsserver
exec('npm run dev', (error, stdout, stderr) => {
if (error) {
console.error(`Fehler beim Starten des Servers: ${error}`);
return;
}
});
// Warte kurz und öffne dann den Browser
setTimeout(() => {
const platform = os.platform();
const url = 'http://localhost:3000';
switch (platform) {
case 'win32':
exec(`start ${url}`);
break;
case 'darwin':
exec(`open ${url}`);
break;
default:
exec(`xdg-open ${url}`);
break;
}
}, 3000); // Warte 3 Sekunden, bis der Server gestartet ist

12
tailwind.config.js Normal file
View File

@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
}

28
tsconfig.json Normal file
View File

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