mirror of
https://github.com/danielvici/tool-website.git
synced 2026-01-16 19:41:26 +00:00
Initial commit: Tool Website with Password Generator, Image Converter and Bookmarks
This commit is contained in:
34
app/about/page.tsx
Normal file
34
app/about/page.tsx
Normal 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's local storage.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
64
app/api/convert/route.ts
Normal file
64
app/api/convert/route.ts
Normal 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
134
app/bildconverter/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
98
app/components/AddWebsite.tsx
Normal file
98
app/components/AddWebsite.tsx
Normal 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
23
app/components/Navbar.tsx
Normal 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
14
app/globals.css
Normal 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
25
app/layout.tsx
Normal 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
100
app/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
103
app/passwortgenerator/page.tsx
Normal file
103
app/passwortgenerator/page.tsx
Normal 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
7
app/types/types.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type PinnedWebsite = {
|
||||
id: string;
|
||||
title: string;
|
||||
url: string;
|
||||
icon?: string;
|
||||
addedAt: number;
|
||||
}
|
||||
Reference in New Issue
Block a user