improvements
This commit is contained in:
parent
198bbd642c
commit
db195a4ae2
@ -80,9 +80,11 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ssvc-footer {
|
.ssvc-footer {
|
||||||
margin-top: 2rem;
|
|
||||||
padding: 1rem;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #666;
|
}
|
||||||
|
|
||||||
|
.ssvc-footer a {
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: opacity 0.2s;
|
||||||
}
|
}
|
@ -55,8 +55,17 @@ export default function RootLayout({
|
|||||||
<main>
|
<main>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
<footer className="ssvc-footer">
|
<footer className="ssvc-footer border-t border-gray-200 text-gray-700 py-3 mt-8">
|
||||||
<p>© {new Date().getFullYear()} SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING. Alle Rechte vorbehalten.</p>
|
<div className="container mx-auto px-4">
|
||||||
|
<div className="flex flex-col md:flex-row justify-between items-center">
|
||||||
|
<div className="mb-4 md:mb-0">
|
||||||
|
<p className="text-center md:text-left">© {new Date().getFullYear()} SCHAFWASCHENER SEGELVEREIN CHIEMSEE E.V. RIMSTING. Alle Rechte vorbehalten.</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-center md:text-right">
|
||||||
|
<p className="whitespace-nowrap">Erstellt von <a href="https://jeanavril.com" target="_blank" rel="noopener noreferrer" className="font-medium text-gray-800 hover:text-blue-600 hover:underline transition-colors no-underline">Jean Jacques Avril</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -69,7 +69,7 @@ function VotePageContent() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!selectedOption) {
|
if (!selectedOption) {
|
||||||
setError('Bitte wählen Sie eine Option');
|
setError('Bitte wähle eine Option');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,7 +115,7 @@ function VotePageContent() {
|
|||||||
</div>
|
</div>
|
||||||
<h2 className="text-2xl font-bold text-[#0057a6] mb-2">Vielen Dank!</h2>
|
<h2 className="text-2xl font-bold text-[#0057a6] mb-2">Vielen Dank!</h2>
|
||||||
<p className="text-gray-600">
|
<p className="text-gray-600">
|
||||||
Ihre Stimme wurde erfolgreich übermittelt.
|
Deine Stimme wurde erfolgreich übermittelt.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -162,23 +162,26 @@ function VotePageContent() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="p-4 bg-[#f0f7ff] border border-[#cce0ff] rounded-lg">
|
||||||
<label htmlFor="comment" className="block text-sm font-medium text-gray-700 mb-1">
|
<label htmlFor="comment" className="flex items-center text-base font-medium text-[#0057a6] mb-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 8h10M7 12h4m1 8l-4-4H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-3l-4 4z" />
|
||||||
|
</svg>
|
||||||
Kommentare oder Vorschläge (Optional)
|
Kommentare oder Vorschläge (Optional)
|
||||||
</label>
|
</label>
|
||||||
<div className="mt-1">
|
<div>
|
||||||
<textarea
|
<textarea
|
||||||
id="comment"
|
id="comment"
|
||||||
name="comment"
|
name="comment"
|
||||||
rows={4}
|
rows={4}
|
||||||
value={comment}
|
value={comment}
|
||||||
onChange={(e) => setComment(e.target.value)}
|
onChange={(e) => setComment(e.target.value)}
|
||||||
placeholder="Bitte geben Sie keine persönlichen Daten an"
|
placeholder="Dein Kommentar..."
|
||||||
className="w-full px-4 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
|
className="w-full px-4 py-2 border-2 border-gray-300 rounded-md bg-[#e6f0fa] focus:outline-none focus:border-[#0057a6] focus:ring-2 focus:ring-[#0057a6]/20 transition-all duration-200 shadow-sm"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-sm text-gray-500">
|
<p className="mt-1 text-sm text-gray-500">
|
||||||
Bitte geben Sie keine persönlichen Daten in Ihren Kommentaren an.
|
Bitte kurz fassen und auf konkretes Feedback beschränken.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -111,7 +111,14 @@ export default function TokenGenerator() {
|
|||||||
<div className="mt-6 p-4 bg-[#e6f0fa] w-full">
|
<div className="mt-6 p-4 bg-[#e6f0fa] w-full">
|
||||||
<h3 className="font-medium text-[#0057a6] mb-2">Generierter Abstimmungslink:</h3>
|
<h3 className="font-medium text-[#0057a6] mb-2">Generierter Abstimmungslink:</h3>
|
||||||
<div className="break-all text-sm text-[#0057a6] mb-2">
|
<div className="break-all text-sm text-[#0057a6] mb-2">
|
||||||
{generatedLink}
|
<a
|
||||||
|
href={generatedLink}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="hover:underline"
|
||||||
|
>
|
||||||
|
{generatedLink}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={copyToClipboard}
|
onClick={copyToClipboard}
|
||||||
|
@ -1,6 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
|
||||||
|
// Function to generate a random ID
|
||||||
|
function generateRandomId(existingIds: string[]): string {
|
||||||
|
const characters = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const length = 8;
|
||||||
|
let result: string;
|
||||||
|
|
||||||
|
do {
|
||||||
|
result = '';
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
result += characters.charAt(Math.floor(Math.random() * characters.length));
|
||||||
|
}
|
||||||
|
} while (existingIds.includes(result));
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
interface VoteOptionsManagerProps {
|
interface VoteOptionsManagerProps {
|
||||||
voteOptions: { id: string; label: string }[];
|
voteOptions: { id: string; label: string }[];
|
||||||
@ -13,6 +29,18 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
const [newOptionLabel, setNewOptionLabel] = useState('');
|
const [newOptionLabel, setNewOptionLabel] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editingOptionId, setEditingOptionId] = useState<string | null>(null);
|
||||||
|
const [editedLabel, setEditedLabel] = useState('');
|
||||||
|
const editInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const labelInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
// Generate a random ID when the component mounts or when the form is reset
|
||||||
|
useEffect(() => {
|
||||||
|
if (!newOptionId && voteOptions) {
|
||||||
|
const existingIds = voteOptions.map(option => option.id);
|
||||||
|
setNewOptionId(generateRandomId(existingIds));
|
||||||
|
}
|
||||||
|
}, [newOptionId, voteOptions]);
|
||||||
|
|
||||||
// Add a new vote option
|
// Add a new vote option
|
||||||
const handleAddVoteOption = async () => {
|
const handleAddVoteOption = async () => {
|
||||||
@ -53,9 +81,147 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
// Update parent component state
|
// Update parent component state
|
||||||
onVoteOptionsChange(updatedOptions);
|
onVoteOptionsChange(updatedOptions);
|
||||||
|
|
||||||
// Reset form
|
// Reset form and generate a new random ID
|
||||||
setNewOptionId('');
|
|
||||||
setNewOptionLabel('');
|
setNewOptionLabel('');
|
||||||
|
const existingIds = updatedOptions.map(option => option.id);
|
||||||
|
setNewOptionId(generateRandomId(existingIds));
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start editing a vote option
|
||||||
|
const handleEditVoteOption = (option: { id: string; label: string }) => {
|
||||||
|
setEditingOptionId(option.id);
|
||||||
|
setEditedLabel(option.label);
|
||||||
|
// Focus the input field after it's rendered
|
||||||
|
setTimeout(() => {
|
||||||
|
if (editInputRef.current) {
|
||||||
|
editInputRef.current.focus();
|
||||||
|
}
|
||||||
|
}, 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save the edited vote option
|
||||||
|
const handleSaveVoteOption = async () => {
|
||||||
|
if (!editingOptionId || !editedLabel.trim()) {
|
||||||
|
setError('Das Label darf nicht leer sein');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Update in local state
|
||||||
|
const updatedOptions = voteOptions.map(option =>
|
||||||
|
option.id === editingOptionId
|
||||||
|
? { ...option, label: editedLabel.trim() }
|
||||||
|
: option
|
||||||
|
);
|
||||||
|
|
||||||
|
// Save to server
|
||||||
|
const response = await fetch('/api/editable-text', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: 'vote-options',
|
||||||
|
content: updatedOptions
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Fehler beim Speichern der Abstimmungsoptionen');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update parent component state
|
||||||
|
onVoteOptionsChange(updatedOptions);
|
||||||
|
|
||||||
|
// Exit edit mode
|
||||||
|
setEditingOptionId(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Cancel editing
|
||||||
|
const handleCancelEdit = () => {
|
||||||
|
setEditingOptionId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Move option up in the list
|
||||||
|
const handleMoveOptionUp = async (index: number) => {
|
||||||
|
if (index <= 0) return; // Already at the top
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a new array with the option moved up
|
||||||
|
const updatedOptions = [...voteOptions];
|
||||||
|
[updatedOptions[index - 1], updatedOptions[index]] = [updatedOptions[index], updatedOptions[index - 1]];
|
||||||
|
|
||||||
|
// Save to server
|
||||||
|
const response = await fetch('/api/editable-text', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: 'vote-options',
|
||||||
|
content: updatedOptions
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Fehler beim Speichern der Abstimmungsoptionen');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update parent component state
|
||||||
|
onVoteOptionsChange(updatedOptions);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Move option down in the list
|
||||||
|
const handleMoveOptionDown = async (index: number) => {
|
||||||
|
if (index >= voteOptions.length - 1) return; // Already at the bottom
|
||||||
|
|
||||||
|
setIsLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create a new array with the option moved down
|
||||||
|
const updatedOptions = [...voteOptions];
|
||||||
|
[updatedOptions[index], updatedOptions[index + 1]] = [updatedOptions[index + 1], updatedOptions[index]];
|
||||||
|
|
||||||
|
// Save to server
|
||||||
|
const response = await fetch('/api/editable-text', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
id: 'vote-options',
|
||||||
|
content: updatedOptions
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Fehler beim Speichern der Abstimmungsoptionen');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update parent component state
|
||||||
|
onVoteOptionsChange(updatedOptions);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
setError(err instanceof Error ? err.message : 'Ein Fehler ist aufgetreten');
|
||||||
} finally {
|
} finally {
|
||||||
@ -72,7 +238,8 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show confirmation dialog
|
// Show confirmation dialog
|
||||||
if (!confirm(`Sind Sie sicher, dass Sie die Option "${optionId}" entfernen möchten? Bestehende Abstimmungen mit dieser Option werden nicht gelöscht, aber in den Statistiken möglicherweise nicht mehr korrekt angezeigt.`)) {
|
const optionToRemove = voteOptions.find(option => option.id === optionId);
|
||||||
|
if (!confirm(`Sind Sie sicher, dass Sie die Option "${optionToRemove?.label}" entfernen möchten? Bestehende Abstimmungen mit dieser Option werden nicht gelöscht, aber in den Statistiken möglicherweise nicht mehr korrekt angezeigt.`)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,18 +292,82 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
<h3 className="font-medium text-[#0057a6] mb-3">Aktuelle Optionen</h3>
|
<h3 className="font-medium text-[#0057a6] mb-3">Aktuelle Optionen</h3>
|
||||||
|
|
||||||
<div className="space-y-2 mb-4">
|
<div className="space-y-2 mb-4">
|
||||||
{voteOptions.map((option) => (
|
{voteOptions.map((option, index) => (
|
||||||
<div key={option.id} className="flex items-center justify-between p-2 bg-white">
|
<div key={option.id} className="flex items-center justify-between p-2 bg-white">
|
||||||
<div>
|
<div className="flex items-center space-x-2">
|
||||||
<span className="font-medium">{option.id}:</span> {option.label}
|
<div className="flex flex-col">
|
||||||
|
<button
|
||||||
|
onClick={() => handleMoveOptionUp(index)}
|
||||||
|
className="text-gray-600 hover:text-gray-800 disabled:opacity-30"
|
||||||
|
disabled={index === 0}
|
||||||
|
title="Nach oben verschieben"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleMoveOptionDown(index)}
|
||||||
|
className="text-gray-600 hover:text-gray-800 disabled:opacity-30"
|
||||||
|
disabled={index === voteOptions.length - 1}
|
||||||
|
title="Nach unten verschieben"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{editingOptionId === option.id ? (
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span className="font-medium mr-2">{option.id}:</span>
|
||||||
|
<input
|
||||||
|
ref={editInputRef}
|
||||||
|
type="text"
|
||||||
|
value={editedLabel}
|
||||||
|
onChange={(e) => setEditedLabel(e.target.value)}
|
||||||
|
className="px-2 py-1 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') handleSaveVoteOption();
|
||||||
|
if (e.key === 'Escape') handleCancelEdit();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={handleSaveVoteOption}
|
||||||
|
className="ml-2 text-green-600 hover:text-green-800"
|
||||||
|
title="Speichern"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleCancelEdit}
|
||||||
|
className="ml-1 text-gray-600 hover:text-gray-800"
|
||||||
|
title="Abbrechen"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<span className="font-medium">{option.id}:</span> {option.label}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
{editingOptionId !== option.id && (
|
||||||
|
<button
|
||||||
|
onClick={() => handleEditVoteOption(option)}
|
||||||
|
className="text-blue-600 hover:text-blue-800"
|
||||||
|
title="Option bearbeiten"
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveVoteOption(option.id)}
|
||||||
|
className="text-red-600 hover:text-red-800"
|
||||||
|
title="Option entfernen"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveVoteOption(option.id)}
|
|
||||||
className="text-red-600 hover:text-red-800"
|
|
||||||
title="Option entfernen"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -167,10 +398,18 @@ export default function VoteOptionsManager({ voteOptions, onVoteOptionsChange }:
|
|||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="newOptionLabel"
|
id="newOptionLabel"
|
||||||
|
ref={labelInputRef}
|
||||||
value={newOptionLabel}
|
value={newOptionLabel}
|
||||||
onChange={(e) => setNewOptionLabel(e.target.value)}
|
onChange={(e) => setNewOptionLabel(e.target.value)}
|
||||||
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
|
className="w-full px-3 py-2 border border-gray-300 focus:outline-none focus:border-[#0057a6]"
|
||||||
placeholder="Anzeigename der Option"
|
placeholder="Anzeigename der Option"
|
||||||
|
onFocus={() => {
|
||||||
|
// Generate a random ID if the field is empty
|
||||||
|
if (!newOptionId) {
|
||||||
|
const existingIds = voteOptions.map(option => option.id);
|
||||||
|
setNewOptionId(generateRandomId(existingIds));
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Der Anzeigename wird den Abstimmenden angezeigt.
|
Der Anzeigename wird den Abstimmenden angezeigt.
|
||||||
|
Loading…
x
Reference in New Issue
Block a user