Refactor: Implement app-wide slug generation

This commit is contained in:
gpt-engineer-app[bot]
2025-10-01 17:02:03 +00:00
parent 8cd6a35fcf
commit 91afb4f769
9 changed files with 185 additions and 207 deletions

View File

@@ -7,9 +7,11 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { SlugField } from '@/components/ui/slug-field';
import { Ruler, Save, X } from 'lucide-react'; import { Ruler, Save, X } from 'lucide-react';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData'; import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData';
import { useUserRole } from '@/hooks/useUserRole';
const designerSchema = z.object({ const designerSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
@@ -30,6 +32,7 @@ interface DesignerFormProps {
} }
export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormProps) { export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormProps) {
const { isModerator } = useUserRole();
const { headquarters } = useCompanyHeadquarters(); const { headquarters } = useCompanyHeadquarters();
const { const {
@@ -51,20 +54,6 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
} }
}); });
const generateSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const name = e.target.value;
const slug = generateSlug(name);
setValue('slug', slug);
};
return ( return (
<Card> <Card>
@@ -83,10 +72,6 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
<Input <Input
id="name" id="name"
{...register('name')} {...register('name')}
onChange={(e) => {
register('name').onChange(e);
handleNameChange(e);
}}
placeholder="Enter designer name" placeholder="Enter designer name"
/> />
{errors.name && ( {errors.name && (
@@ -94,17 +79,12 @@ export function DesignerForm({ onSubmit, onCancel, initialData }: DesignerFormPr
)} )}
</div> </div>
<div className="space-y-2"> <SlugField
<Label htmlFor="slug">URL Slug *</Label> name={watch('name')}
<Input slug={watch('slug')}
id="slug" onSlugChange={(slug) => setValue('slug', slug)}
{...register('slug')} isModerator={isModerator()}
placeholder="designer-slug" />
/>
{errors.slug && (
<p className="text-sm text-destructive">{errors.slug.message}</p>
)}
</div>
</div> </div>
{/* Description */} {/* Description */}

View File

@@ -7,9 +7,11 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { SlugField } from '@/components/ui/slug-field';
import { Building2, Save, X } from 'lucide-react'; import { Building2, Save, X } from 'lucide-react';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData'; import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData';
import { useUserRole } from '@/hooks/useUserRole';
const manufacturerSchema = z.object({ const manufacturerSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
@@ -30,6 +32,7 @@ interface ManufacturerFormProps {
} }
export function ManufacturerForm({ onSubmit, onCancel, initialData }: ManufacturerFormProps) { export function ManufacturerForm({ onSubmit, onCancel, initialData }: ManufacturerFormProps) {
const { isModerator } = useUserRole();
const { headquarters } = useCompanyHeadquarters(); const { headquarters } = useCompanyHeadquarters();
const { const {
@@ -51,20 +54,6 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
} }
}); });
const generateSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const name = e.target.value;
const slug = generateSlug(name);
setValue('slug', slug);
};
return ( return (
<Card> <Card>
@@ -83,10 +72,6 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
<Input <Input
id="name" id="name"
{...register('name')} {...register('name')}
onChange={(e) => {
register('name').onChange(e);
handleNameChange(e);
}}
placeholder="Enter manufacturer name" placeholder="Enter manufacturer name"
/> />
{errors.name && ( {errors.name && (
@@ -94,17 +79,12 @@ export function ManufacturerForm({ onSubmit, onCancel, initialData }: Manufactur
)} )}
</div> </div>
<div className="space-y-2"> <SlugField
<Label htmlFor="slug">URL Slug *</Label> name={watch('name')}
<Input slug={watch('slug')}
id="slug" onSlugChange={(slug) => setValue('slug', slug)}
{...register('slug')} isModerator={isModerator()}
placeholder="manufacturer-slug" />
/>
{errors.slug && (
<p className="text-sm text-destructive">{errors.slug.message}</p>
)}
</div>
</div> </div>
{/* Description */} {/* Description */}

View File

@@ -7,9 +7,11 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { SlugField } from '@/components/ui/slug-field';
import { FerrisWheel, Save, X } from 'lucide-react'; import { FerrisWheel, Save, X } from 'lucide-react';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData'; import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData';
import { useUserRole } from '@/hooks/useUserRole';
const operatorSchema = z.object({ const operatorSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
@@ -30,6 +32,7 @@ interface OperatorFormProps {
} }
export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormProps) { export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormProps) {
const { isModerator } = useUserRole();
const { headquarters } = useCompanyHeadquarters(); const { headquarters } = useCompanyHeadquarters();
const { const {
@@ -51,20 +54,6 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
} }
}); });
const generateSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const name = e.target.value;
const slug = generateSlug(name);
setValue('slug', slug);
};
return ( return (
<Card> <Card>
@@ -83,10 +72,6 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
<Input <Input
id="name" id="name"
{...register('name')} {...register('name')}
onChange={(e) => {
register('name').onChange(e);
handleNameChange(e);
}}
placeholder="Enter operator name" placeholder="Enter operator name"
/> />
{errors.name && ( {errors.name && (
@@ -94,17 +79,12 @@ export function OperatorForm({ onSubmit, onCancel, initialData }: OperatorFormPr
)} )}
</div> </div>
<div className="space-y-2"> <SlugField
<Label htmlFor="slug">URL Slug *</Label> name={watch('name')}
<Input slug={watch('slug')}
id="slug" onSlugChange={(slug) => setValue('slug', slug)}
{...register('slug')} isModerator={isModerator()}
placeholder="operator-slug" />
/>
{errors.slug && (
<p className="text-sm text-destructive">{errors.slug.message}</p>
)}
</div>
</div> </div>
{/* Description */} {/* Description */}

View File

@@ -10,16 +10,18 @@ import { Label } from '@/components/ui/label';
import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload'; import { UppyPhotoUpload } from '@/components/upload/UppyPhotoUpload';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { DatePicker } from '@/components/ui/date-picker'; import { DatePicker } from '@/components/ui/date-picker';
import { SlugField } from '@/components/ui/slug-field';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { MapPin, Save, X, Plus } from 'lucide-react'; import { MapPin, Save, X, Plus } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useOperators, usePropertyOwners } from '@/hooks/useAutocompleteData'; import { useOperators, usePropertyOwners } from '@/hooks/useAutocompleteData';
import { useUserRole } from '@/hooks/useUserRole';
const parkSchema = z.object({ const parkSchema = z.object({
name: z.string().min(1, 'Park name is required'), name: z.string().min(1, 'Park name is required'),
slug: z.string().min(1, 'Slug is required'), slug: z.string().min(1, 'Slug is required'), // Auto-generated, validated on submit
description: z.string().optional(), description: z.string().optional(),
park_type: z.string().min(1, 'Park type is required'), park_type: z.string().min(1, 'Park type is required'),
status: z.string().min(1, 'Status is required'), status: z.string().min(1, 'Status is required'),
@@ -77,6 +79,7 @@ const statusOptions = [
]; ];
export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: ParkFormProps) { export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }: ParkFormProps) {
const { isModerator } = useUserRole();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [bannerImage, setBannerImage] = useState<string>(initialData?.banner_image_url || ''); const [bannerImage, setBannerImage] = useState<string>(initialData?.banner_image_url || '');
const [cardImage, setCardImage] = useState<string>(initialData?.card_image_url || ''); const [cardImage, setCardImage] = useState<string>(initialData?.card_image_url || '');
@@ -127,20 +130,6 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
} }
}); });
const generateSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const name = e.target.value;
const slug = generateSlug(name);
setValue('slug', slug);
};
const handleFormSubmit = async (data: ParkFormData) => { const handleFormSubmit = async (data: ParkFormData) => {
setSubmitting(true); setSubmitting(true);
@@ -213,10 +202,6 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
<Input <Input
id="name" id="name"
{...register('name')} {...register('name')}
onChange={(e) => {
register('name').onChange(e);
handleNameChange(e);
}}
placeholder="Enter park name" placeholder="Enter park name"
/> />
{errors.name && ( {errors.name && (
@@ -224,17 +209,12 @@ export function ParkForm({ onSubmit, onCancel, initialData, isEditing = false }:
)} )}
</div> </div>
<div className="space-y-2"> <SlugField
<Label htmlFor="slug">URL Slug *</Label> name={watch('name')}
<Input slug={watch('slug')}
id="slug" onSlugChange={(slug) => setValue('slug', slug)}
{...register('slug')} isModerator={isModerator()}
placeholder="park-url-slug" />
/>
{errors.slug && (
<p className="text-sm text-destructive">{errors.slug.message}</p>
)}
</div>
</div> </div>
{/* Description */} {/* Description */}

View File

@@ -7,9 +7,11 @@ import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { SlugField } from '@/components/ui/slug-field';
import { Building2, Save, X } from 'lucide-react'; import { Building2, Save, X } from 'lucide-react';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData'; import { useCompanyHeadquarters } from '@/hooks/useAutocompleteData';
import { useUserRole } from '@/hooks/useUserRole';
const propertyOwnerSchema = z.object({ const propertyOwnerSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
@@ -30,6 +32,7 @@ interface PropertyOwnerFormProps {
} }
export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyOwnerFormProps) { export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyOwnerFormProps) {
const { isModerator } = useUserRole();
const { headquarters } = useCompanyHeadquarters(); const { headquarters } = useCompanyHeadquarters();
const { const {
@@ -51,20 +54,6 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
} }
}); });
const generateSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const name = e.target.value;
const slug = generateSlug(name);
setValue('slug', slug);
};
return ( return (
<Card> <Card>
@@ -83,10 +72,6 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
<Input <Input
id="name" id="name"
{...register('name')} {...register('name')}
onChange={(e) => {
register('name').onChange(e);
handleNameChange(e);
}}
placeholder="Enter property owner name" placeholder="Enter property owner name"
/> />
{errors.name && ( {errors.name && (
@@ -94,17 +79,12 @@ export function PropertyOwnerForm({ onSubmit, onCancel, initialData }: PropertyO
)} )}
</div> </div>
<div className="space-y-2"> <SlugField
<Label htmlFor="slug">URL Slug *</Label> name={watch('name')}
<Input slug={watch('slug')}
id="slug" onSlugChange={(slug) => setValue('slug', slug)}
{...register('slug')} isModerator={isModerator()}
placeholder="property-owner-slug" />
/>
{errors.slug && (
<p className="text-sm text-destructive">{errors.slug.message}</p>
)}
</div>
</div> </div>
{/* Description */} {/* Description */}

View File

@@ -13,10 +13,12 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { DatePicker } from '@/components/ui/date-picker'; import { DatePicker } from '@/components/ui/date-picker';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Combobox } from '@/components/ui/combobox'; import { Combobox } from '@/components/ui/combobox';
import { SlugField } from '@/components/ui/slug-field';
import { toast } from '@/hooks/use-toast'; import { toast } from '@/hooks/use-toast';
import { Plus, Zap, Save, X } from 'lucide-react'; import { Plus, Zap, Save, X } from 'lucide-react';
import { useUnitPreferences } from '@/hooks/useUnitPreferences'; import { useUnitPreferences } from '@/hooks/useUnitPreferences';
import { useManufacturers, useRideModels } from '@/hooks/useAutocompleteData'; import { useManufacturers, useRideModels } from '@/hooks/useAutocompleteData';
import { useUserRole } from '@/hooks/useUserRole';
import { ManufacturerForm } from './ManufacturerForm'; import { ManufacturerForm } from './ManufacturerForm';
import { RideModelForm } from './RideModelForm'; import { RideModelForm } from './RideModelForm';
import { import {
@@ -127,6 +129,7 @@ const intensityLevels = [
]; ];
export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: RideFormProps) { export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }: RideFormProps) {
const { isModerator } = useUserRole();
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [bannerImageUrl, setBannerImageUrl] = useState<string>(initialData?.banner_image_url || ''); const [bannerImageUrl, setBannerImageUrl] = useState<string>(initialData?.banner_image_url || '');
const [bannerImageId, setBannerImageId] = useState<string>(initialData?.banner_image_id || ''); const [bannerImageId, setBannerImageId] = useState<string>(initialData?.banner_image_id || '');
@@ -200,20 +203,6 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
const selectedCategory = watch('category'); const selectedCategory = watch('category');
const generateSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const name = e.target.value;
const slug = generateSlug(name);
setValue('slug', slug);
};
const handleFormSubmit = async (data: RideFormData) => { const handleFormSubmit = async (data: RideFormData) => {
setSubmitting(true); setSubmitting(true);
@@ -301,10 +290,6 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
<Input <Input
id="name" id="name"
{...register('name')} {...register('name')}
onChange={(e) => {
register('name').onChange(e);
handleNameChange(e);
}}
placeholder="Enter ride name" placeholder="Enter ride name"
/> />
{errors.name && ( {errors.name && (
@@ -312,17 +297,12 @@ export function RideForm({ onSubmit, onCancel, initialData, isEditing = false }:
)} )}
</div> </div>
<div className="space-y-2"> <SlugField
<Label htmlFor="slug">URL Slug *</Label> name={watch('name')}
<Input slug={watch('slug')}
id="slug" onSlugChange={(slug) => setValue('slug', slug)}
{...register('slug')} isModerator={isModerator()}
placeholder="ride-url-slug" />
/>
{errors.slug && (
<p className="text-sm text-destructive">{errors.slug.message}</p>
)}
</div>
</div> </div>
{/* Description */} {/* Description */}

View File

@@ -8,7 +8,9 @@ import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { SlugField } from '@/components/ui/slug-field';
import { Layers, Save, X } from 'lucide-react'; import { Layers, Save, X } from 'lucide-react';
import { useUserRole } from '@/hooks/useUserRole';
const rideModelSchema = z.object({ const rideModelSchema = z.object({
name: z.string().min(1, 'Name is required'), name: z.string().min(1, 'Name is required'),
@@ -45,6 +47,8 @@ export function RideModelForm({
onCancel, onCancel,
initialData initialData
}: RideModelFormProps) { }: RideModelFormProps) {
const { isModerator } = useUserRole();
const { const {
register, register,
handleSubmit, handleSubmit,
@@ -63,20 +67,6 @@ export function RideModelForm({
} }
}); });
const generateSlug = (name: string) => {
return name
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.trim();
};
const handleNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const name = e.target.value;
const slug = generateSlug(name);
setValue('slug', slug);
};
return ( return (
<Card> <Card>
@@ -99,10 +89,6 @@ export function RideModelForm({
<Input <Input
id="name" id="name"
{...register('name')} {...register('name')}
onChange={(e) => {
register('name').onChange(e);
handleNameChange(e);
}}
placeholder="e.g. Mega Coaster, Sky Screamer" placeholder="e.g. Mega Coaster, Sky Screamer"
/> />
{errors.name && ( {errors.name && (
@@ -110,17 +96,12 @@ export function RideModelForm({
)} )}
</div> </div>
<div className="space-y-2"> <SlugField
<Label htmlFor="slug">URL Slug *</Label> name={watch('name')}
<Input slug={watch('slug')}
id="slug" onSlugChange={(slug) => setValue('slug', slug)}
{...register('slug')} isModerator={isModerator()}
placeholder="model-slug" />
/>
{errors.slug && (
<p className="text-sm text-destructive">{errors.slug.message}</p>
)}
</div>
</div> </div>
{/* Category and Type */} {/* Category and Type */}

View File

@@ -0,0 +1,105 @@
import { useEffect, useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { RefreshCw, Lock } from 'lucide-react';
import { generateSlugFromName } from '@/lib/slugUtils';
interface SlugFieldProps {
name: string;
slug: string;
onSlugChange: (slug: string) => void;
isModerator: boolean;
label?: string;
hideForNonModerators?: boolean;
}
export function SlugField({
name,
slug,
onSlugChange,
isModerator,
label = 'URL Slug',
hideForNonModerators = true
}: SlugFieldProps) {
const [isEditing, setIsEditing] = useState(false);
// Auto-generate slug when name changes
useEffect(() => {
if (!isEditing && name) {
const generatedSlug = generateSlugFromName(name);
onSlugChange(generatedSlug);
}
}, [name, isEditing, onSlugChange]);
const handleRegenerate = () => {
if (name) {
const generatedSlug = generateSlugFromName(name);
onSlugChange(generatedSlug);
setIsEditing(false);
}
};
// Hide completely for non-moderators if configured
if (!isModerator && hideForNonModerators) {
return null;
}
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Label htmlFor="slug">{label}</Label>
{!isModerator && (
<Badge variant="secondary" className="text-xs">
<Lock className="w-3 h-3 mr-1" />
Auto-generated
</Badge>
)}
{isModerator && (
<Badge variant="outline" className="text-xs">
Moderator
</Badge>
)}
</div>
<div className="flex gap-2">
<Input
id="slug"
value={slug}
onChange={(e) => {
onSlugChange(e.target.value);
setIsEditing(true);
}}
placeholder="url-slug"
readOnly={!isModerator}
className={!isModerator ? 'bg-muted cursor-not-allowed' : ''}
/>
{isModerator && (
<Button
type="button"
variant="outline"
size="icon"
onClick={handleRegenerate}
title="Regenerate from name"
>
<RefreshCw className="w-4 h-4" />
</Button>
)}
</div>
{!isModerator && (
<p className="text-xs text-muted-foreground">
URL will be automatically generated from the name
</p>
)}
{isModerator && slug && (
<p className="text-xs text-muted-foreground">
Preview: <span className="font-mono">.../{slug}</span>
</p>
)}
</div>
);
}

View File

@@ -2,16 +2,28 @@ import { supabase } from '@/integrations/supabase/client';
/** /**
* Generate a URL-safe slug from a name * Generate a URL-safe slug from a name
* This is the canonical slug generation function used throughout the app
*/ */
export function generateSlugFromName(name: string): string { export function generateSlugFromName(name: string): string {
if (!name) return '';
return name return name
.toLowerCase() .toLowerCase()
.replace(/[^a-z0-9\s-]/g, '') .replace(/[^a-z0-9\s-]/g, '') // Remove non-alphanumeric except spaces and hyphens
.replace(/\s+/g, '-') .replace(/\s+/g, '-') // Replace spaces with hyphens
.replace(/-+/g, '-') .replace(/-+/g, '-') // Replace multiple hyphens with single hyphen
.replace(/^-+|-+$/g, '') // Remove leading/trailing hyphens
.trim(); .trim();
} }
/**
* Validate that a user has permission to edit slugs
* Only moderators should be able to manually edit slugs
*/
export function canEditSlug(isModerator: boolean): boolean {
return isModerator;
}
/** /**
* Ensure slug is unique by checking database and appending number if needed * Ensure slug is unique by checking database and appending number if needed
*/ */