From 2ac5df0b3277c22734343fcc8ebf932219f30aa9 Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Sat, 17 Jan 2026 19:05:12 +0200 Subject: [PATCH 01/16] inital try - working i think --- apps/admin/locale/en.json | 57 ++++- apps/admin/locale/he.json | 57 ++++- .../faqs/components/delete-confirm-dialog.tsx | 87 ++++++++ .../faqs/components/faq-editor-dialog.tsx | 181 ++++++++++++++++ .../app/[locale]/(dashboard)/faqs/page.tsx | 195 ++++++++++++++++++ .../src/app/[locale]/(dashboard)/layout.tsx | 8 +- apps/backend/src/routers/admin/faqs.ts | 140 +++++++++++++ apps/backend/src/routers/admin/index.ts | 2 + apps/backend/src/routers/portal/faqs.ts | 62 ++++++ apps/backend/src/routers/portal/index.ts | 2 + apps/portal/locale/en.json | 16 +- apps/portal/locale/he.json | 16 +- .../src/app/[locale]/components/app-bar.tsx | 6 +- apps/portal/src/app/[locale]/faqs/page.tsx | 174 ++++++++++++++++ libs/database/src/database.ts | 3 + .../026_add_manage_faq_permission.ts | 14 ++ .../src/migrations/027_create_faqs_table.ts | 46 +++++ libs/database/src/repositories/faqs.ts | 99 +++++++++ libs/database/src/schema/index.ts | 1 + libs/database/src/schema/kysely.ts | 2 + .../src/schema/tables/admin-permissions.ts | 3 +- libs/database/src/schema/tables/faqs.ts | 16 ++ libs/types/src/lib/api/admin/faqs.ts | 32 +++ libs/types/src/lib/api/admin/index.ts | 1 + libs/types/src/lib/api/admin/users.ts | 3 +- libs/types/src/lib/api/portal/faqs.ts | 14 ++ libs/types/src/lib/api/portal/index.ts | 1 + 27 files changed, 1229 insertions(+), 9 deletions(-) create mode 100644 apps/admin/src/app/[locale]/(dashboard)/faqs/components/delete-confirm-dialog.tsx create mode 100644 apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx create mode 100644 apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx create mode 100644 apps/backend/src/routers/admin/faqs.ts create mode 100644 apps/backend/src/routers/portal/faqs.ts create mode 100644 apps/portal/src/app/[locale]/faqs/page.tsx create mode 100644 libs/database/src/migrations/026_add_manage_faq_permission.ts create mode 100644 libs/database/src/migrations/027_create_faqs_table.ts create mode 100644 libs/database/src/repositories/faqs.ts create mode 100644 libs/database/src/schema/tables/faqs.ts create mode 100644 libs/types/src/lib/api/admin/faqs.ts create mode 100644 libs/types/src/lib/api/portal/faqs.ts diff --git a/apps/admin/locale/en.json b/apps/admin/locale/en.json index f0e14f686..651eead68 100644 --- a/apps/admin/locale/en.json +++ b/apps/admin/locale/en.json @@ -1058,6 +1058,59 @@ "submitting": "Resetting..." } } + }, + "faqs": { + "title": "Robot Game Rules FAQ", + "actions": { + "create": "Create FAQ" + }, + "filter": { + "season": "Season", + "all-seasons": "All Seasons" + }, + "table": { + "question": "Question", + "answer": "Answer", + "season": "Season", + "order": "Order", + "actions": "Actions" + }, + "empty-state": "No FAQs found. Create your first FAQ to get started.", + "errors": { + "no-permission": "You don't have permission to manage FAQs.", + "load-failed": "Failed to load FAQs. Please try again." + }, + "editor": { + "title-create": "Create FAQ", + "title-edit": "Edit FAQ", + "fields": { + "season": "Season", + "question": "Question", + "answer": "Answer", + "display-order": "Display Order", + "display-order-help": "Leave empty to add at the end" + }, + "errors": { + "required-fields": "Please fill in all required fields.", + "save-failed": "Failed to save FAQ. Please try again." + }, + "actions": { + "cancel": "Cancel", + "save": "Save", + "saving": "Saving..." + } + }, + "delete": { + "title": "Delete FAQ", + "message": "Are you sure you want to delete this FAQ? This action cannot be undone.", + "question": "Question", + "error": "Failed to delete FAQ. Please try again.", + "actions": { + "cancel": "Cancel", + "delete": "Delete", + "deleting": "Deleting..." + } + } } }, "general": { @@ -1067,7 +1120,8 @@ "manage-events": "Manage Events", "manage-event-details": "Manage Event Details", "manage-teams": "Manage Teams", - "view-insights": "View Insights" + "view-insights": "View Insights", + "manage-faq": "Manage FAQ" } }, "layouts": { @@ -1078,6 +1132,7 @@ "events": "Events", "teams": "Teams", "insights": "Insights", + "faqs": "FAQ", "graphql": "GraphQL" }, "user-menu": { diff --git a/apps/admin/locale/he.json b/apps/admin/locale/he.json index cb16a977a..94508e40e 100644 --- a/apps/admin/locale/he.json +++ b/apps/admin/locale/he.json @@ -1058,6 +1058,59 @@ "submitting": "מאפס..." } } + }, + "faqs": { + "title": "שאלות נפוצות - חוקי משחק הרובוט", + "actions": { + "create": "יצירת שאלה נפוצה" + }, + "filter": { + "season": "עונה", + "all-seasons": "כל העונות" + }, + "table": { + "question": "שאלה", + "answer": "תשובה", + "season": "עונה", + "order": "סדר", + "actions": "פעולות" + }, + "empty-state": "לא נמצאו שאלות נפוצות. צור את השאלה הנפוצה הראשונה שלך כדי להתחיל.", + "errors": { + "no-permission": "אין לך הרשאה לנהל שאלות נפוצות.", + "load-failed": "טעינת השאלות הנפוצות נכשלה. אנא נסה שוב." + }, + "editor": { + "title-create": "יצירת שאלה נפוצה", + "title-edit": "עריכת שאלה נפוצה", + "fields": { + "season": "עונה", + "question": "שאלה", + "answer": "תשובה", + "display-order": "סדר תצוגה", + "display-order-help": "השאר ריק כדי להוסיף בסוף" + }, + "errors": { + "required-fields": "אנא מלא את כל השדות הנדרשים.", + "save-failed": "שמירת השאלה הנפוצה נכשלה. אנא נסה שוב." + }, + "actions": { + "cancel": "ביטול", + "save": "שמירה", + "saving": "שומר..." + } + }, + "delete": { + "title": "מחיקת שאלה נפוצה", + "message": "האם אתה בטוח שברצונך למחוק שאלה נפוצה זו? פעולה זו אינה ניתנת לביטול.", + "question": "שאלה", + "error": "מחיקת השאלה הנפוצה נכשלה. אנא נסה שוב.", + "actions": { + "cancel": "ביטול", + "delete": "מחיקה", + "deleting": "מוחק..." + } + } } }, "general": { @@ -1067,7 +1120,8 @@ "manage-events": "ניהול אירועים", "manage-event-details": "ניהול פרטי אירועים", "manage-teams": "ניהול קבוצות", - "view-insights": "צפייה בתובנות" + "view-insights": "צפייה בתובנות", + "manage-faq": "ניהול שאלות נפוצות" } }, "layouts": { @@ -1078,6 +1132,7 @@ "events": "אירועים", "teams": "קבוצות", "insights": "תובנות", + "faqs": "שאלות נפוצות", "graphql": "GraphQL" }, "user-menu": { diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/delete-confirm-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/delete-confirm-dialog.tsx new file mode 100644 index 000000000..d03bcfbfd --- /dev/null +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/delete-confirm-dialog.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useState } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + Typography, + Alert, + CircularProgress +} from '@mui/material'; +import { useTranslations } from 'next-intl'; +import { mutate } from 'swr'; +import { FaqResponse } from '@lems/types/api/admin'; +import { apiFetch } from '@lems/shared'; + +interface DeleteConfirmDialogProps { + open: boolean; + faq: FaqResponse; + onClose: () => void; +} + +export function DeleteConfirmDialog({ open, faq, onClose }: DeleteConfirmDialogProps) { + const t = useTranslations('pages.faqs.delete'); + const [isDeleting, setIsDeleting] = useState(false); + const [error, setError] = useState(null); + + const handleDelete = async () => { + setIsDeleting(true); + setError(null); + + try { + const result = await apiFetch(`/admin/faqs/${faq.id}`, { + method: 'DELETE' + }); + + if (!result.ok) { + throw new Error('Failed to delete FAQ'); + } + + // Refresh FAQ lists + await Promise.all([ + mutate('/admin/faqs'), + mutate(key => typeof key === 'string' && key.startsWith('/admin/faqs/season/')) + ]); + + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : t('error')); + } finally { + setIsDeleting(false); + } + }; + + return ( + + {t('title')} + + {error && ( + + {error} + + )} + {t('message')} + + {t('question')}: {faq.question} + + + + + + + + ); +} diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx new file mode 100644 index 000000000..eda3f7a6d --- /dev/null +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + FormControl, + InputLabel, + Select, + MenuItem, + Alert, + CircularProgress, + Box +} from '@mui/material'; +import { useTranslations } from 'next-intl'; +import { mutate } from 'swr'; +import { FaqResponse } from '@lems/types/api/admin'; +import { Season } from '@lems/database'; +import { apiFetch } from '@lems/shared'; + +interface FaqEditorDialogProps { + open: boolean; + faq: FaqResponse | null; + seasons: Season[]; + onClose: () => void; +} + +export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialogProps) { + const t = useTranslations('pages.faqs.editor'); + const [seasonId, setSeasonId] = useState(faq?.seasonId || ''); + const [question, setQuestion] = useState(faq?.question || ''); + const [answer, setAnswer] = useState(faq?.answer || ''); + const [displayOrder, setDisplayOrder] = useState(faq?.displayOrder?.toString() || ''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (faq) { + setSeasonId(faq.seasonId); + setQuestion(faq.question); + setAnswer(faq.answer); + setDisplayOrder(faq.displayOrder.toString()); + } else { + setSeasonId(seasons[0]?.id || ''); + setQuestion(''); + setAnswer(''); + setDisplayOrder(''); + } + setError(null); + }, [faq, seasons]); + + const handleSubmit = async () => { + if (!seasonId || !question.trim() || !answer.trim()) { + setError(t('errors.required-fields')); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + const body: { + question: string; + answer: string; + seasonId?: string; + displayOrder?: number; + } = { + question: question.trim(), + answer: answer.trim() + }; + + if (!faq) { + body.seasonId = seasonId; + } + + if (displayOrder) { + body.displayOrder = parseInt(displayOrder); + } + + const url = faq ? `/admin/faqs/${faq.id}` : '/admin/faqs'; + const method = faq ? 'PUT' : 'POST'; + + const result = await apiFetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!result.ok) { + throw new Error('Failed to save FAQ'); + } + + // Refresh FAQ lists + await Promise.all([ + mutate('/admin/faqs'), + mutate(key => typeof key === 'string' && key.startsWith('/admin/faqs/season/')) + ]); + + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : t('errors.save-failed')); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + {faq ? t('title-edit') : t('title-create')} + + + {error && {error}} + + + {t('fields.season')} + + + + setQuestion(e.target.value)} + fullWidth + required + multiline + rows={2} + disabled={isSubmitting} + /> + + setAnswer(e.target.value)} + fullWidth + required + multiline + rows={6} + disabled={isSubmitting} + /> + + setDisplayOrder(e.target.value)} + type="number" + fullWidth + helperText={t('fields.display-order-help')} + disabled={isSubmitting} + /> + + + + + + + + ); +} diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx new file mode 100644 index 000000000..09d1f6092 --- /dev/null +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx @@ -0,0 +1,195 @@ +'use client'; + +import { useState } from 'react'; +import { + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + IconButton, + Button, + Typography, + Box, + Chip, + Alert, + CircularProgress, + FormControl, + InputLabel, + Select, + MenuItem +} from '@mui/material'; +import { Edit as EditIcon, Delete as DeleteIcon, Add as AddIcon } from '@mui/icons-material'; +import { useTranslations } from 'next-intl'; +import useSWR from 'swr'; +import { FaqResponse } from '@lems/types/api/admin'; +import { Season } from '@lems/database'; +import { useSession } from '../components/session-context'; +import { FaqEditorDialog } from './components/faq-editor-dialog'; +import { DeleteConfirmDialog } from './components/delete-confirm-dialog'; + +export default function FaqsPage() { + const t = useTranslations('pages.faqs'); + const { permissions } = useSession(); + const hasPermission = permissions.includes('MANAGE_FAQ'); + const [selectedSeasonId, setSelectedSeasonId] = useState('all'); + const [editingFaq, setEditingFaq] = useState(null); + const [deletingFaq, setDeletingFaq] = useState(null); + const [isCreating, setIsCreating] = useState(false); + + const { data: seasons, error: seasonsError } = useSWR('/admin/seasons'); + const { data: faqs, error: faqsError } = useSWR( + selectedSeasonId === 'all' ? '/admin/faqs' : `/admin/faqs/season/${selectedSeasonId}` + ); + + if (!hasPermission) { + return ( + + {t('errors.no-permission')} + + ); + } + + const loading = !faqs || !seasons; + const error = faqsError || seasonsError; + + const handleEdit = (faq: FaqResponse) => { + setEditingFaq(faq); + }; + + const handleDelete = (faq: FaqResponse) => { + setDeletingFaq(faq); + }; + + const handleCreate = () => { + setIsCreating(true); + }; + + const handleCloseDialog = () => { + setEditingFaq(null); + setIsCreating(false); + }; + + const handleCloseDeleteDialog = () => { + setDeletingFaq(null); + }; + + const getSeasonName = (seasonId: string) => { + const season = seasons?.find(s => s.id === seasonId); + return season?.name || seasonId; + }; + + return ( + + + {t('title')} + + + + + + {t('filter.season')} + + + + + {error && ( + + {t('errors.load-failed')} + + )} + + {loading ? ( + + + + ) : ( + + + + + {t('table.question')} + {t('table.answer')} + {t('table.season')} + {t('table.order')} + {t('table.actions')} + + + + {faqs && faqs.length === 0 ? ( + + + + {t('empty-state')} + + + + ) : ( + faqs?.map(faq => ( + + + + {faq.question} + + + + + {faq.answer} + + + + + + {faq.displayOrder} + + handleEdit(faq)} color="primary"> + + + handleDelete(faq)} color="error"> + + + + + )) + )} + +
+
+ )} + + {(isCreating || editingFaq) && ( + + )} + + {deletingFaq && ( + + )} +
+ ); +} diff --git a/apps/admin/src/app/[locale]/(dashboard)/layout.tsx b/apps/admin/src/app/[locale]/(dashboard)/layout.tsx index 1c9483508..9ee0934cb 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/layout.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/layout.tsx @@ -17,7 +17,8 @@ import { EmojiEventsOutlined, GroupOutlined, InsightsOutlined, - DataObjectOutlined + DataObjectOutlined, + QuestionAnswer } from '@mui/icons-material'; import { PermissionType } from '@lems/database'; import { @@ -65,6 +66,11 @@ const navigator: Navigator = { icon: , label: 'users', route: 'users' + }, + MANAGE_FAQ: { + icon: , + label: 'faqs', + route: 'faqs' } }; diff --git a/apps/backend/src/routers/admin/faqs.ts b/apps/backend/src/routers/admin/faqs.ts new file mode 100644 index 000000000..faa1f07ba --- /dev/null +++ b/apps/backend/src/routers/admin/faqs.ts @@ -0,0 +1,140 @@ +import express from 'express'; +import { CreateFaqRequestSchema, UpdateFaqRequestSchema } from '@lems/types/api/admin'; +import { Faq } from '@lems/database'; +import db from '../../lib/database'; +import { AdminRequest } from '../../types/express'; +import { requirePermission } from './middleware/require-permission'; + +const router = express.Router(); + +// Helper function to format FAQ response +const formatFaqResponse = (faq: Faq) => ({ + id: faq.id, + seasonId: faq.season_id, + question: faq.question, + answer: faq.answer, + displayOrder: faq.display_order, + createdAt: faq.created_at.toISOString(), + updatedAt: faq.updated_at.toISOString() +}); + +// GET /admin/faqs - Get all FAQs +router.get('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { + try { + const faqs = await db.faqs.all().getAll(); + res.json(faqs.map(formatFaqResponse)); + } catch (error) { + console.error('Error fetching FAQs:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// GET /admin/faqs/season/:seasonId - Get FAQs by season +router.get('/season/:seasonId', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { + try { + const { seasonId } = req.params; + const faqs = await db.faqs.bySeason(seasonId).getAll(); + res.json(faqs.map(formatFaqResponse)); + } catch (error) { + console.error('Error fetching FAQs by season:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// GET /admin/faqs/:id - Get single FAQ +router.get('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { + try { + const { id } = req.params; + const faq = await db.faqs.byId(id).get(); + + if (!faq) { + res.status(404).json({ error: 'FAQ not found' }); + return; + } + + res.json(formatFaqResponse(faq)); + } catch (error) { + console.error('Error fetching FAQ:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// POST /admin/faqs - Create new FAQ +router.post('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { + try { + const validation = CreateFaqRequestSchema.safeParse(req.body); + + if (!validation.success) { + res.status(400).json({ error: 'Invalid request data', details: validation.error.issues }); + return; + } + + const { seasonId, question, answer, displayOrder } = validation.data; + + // If no display order provided, get the next available order + const order = displayOrder ?? (await db.faqs.getMaxDisplayOrder(seasonId)) + 1; + + const faq = await db.faqs.create({ + season_id: seasonId, + question, + answer, + display_order: order + }); + + res.status(201).json(formatFaqResponse(faq)); + } catch (error) { + console.error('Error creating FAQ:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// PUT /admin/faqs/:id - Update FAQ +router.put('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { + try { + const { id } = req.params; + const validation = UpdateFaqRequestSchema.safeParse(req.body); + + if (!validation.success) { + res.status(400).json({ error: 'Invalid request data', details: validation.error.issues }); + return; + } + + const existingFaq = await db.faqs.byId(id).get(); + if (!existingFaq) { + res.status(404).json({ error: 'FAQ not found' }); + return; + } + + const updates: Partial<{ question: string; answer: string; display_order: number }> = {}; + if (validation.data.question !== undefined) updates.question = validation.data.question; + if (validation.data.answer !== undefined) updates.answer = validation.data.answer; + if (validation.data.displayOrder !== undefined) updates.display_order = validation.data.displayOrder; + + const updatedFaq = await db.faqs.byId(id).update(updates); + res.json(formatFaqResponse(updatedFaq)); + } catch (error) { + console.error('Error updating FAQ:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// DELETE /admin/faqs/:id - Delete FAQ +router.delete('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { + try { + const { id } = req.params; + + const existingFaq = await db.faqs.byId(id).get(); + if (!existingFaq) { + res.status(404).json({ error: 'FAQ not found' }); + return; + } + + await db.faqs.byId(id).delete(); + res.status(204).send(); + } catch (error) { + console.error('Error deleting FAQ:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/apps/backend/src/routers/admin/index.ts b/apps/backend/src/routers/admin/index.ts index c175d22e7..3912a9ec6 100644 --- a/apps/backend/src/routers/admin/index.ts +++ b/apps/backend/src/routers/admin/index.ts @@ -5,6 +5,7 @@ import authRouter from './auth'; import seasonsRouter from './seasons'; import teamsRouter from './teams'; import eventsRouter from './events'; +import faqsRouter from './faqs'; const router = express.Router({ mergeParams: true }); @@ -15,5 +16,6 @@ router.use('/users', usersRouter); router.use('/seasons', seasonsRouter); router.use('/teams', teamsRouter); router.use('/events', eventsRouter); +router.use('/faqs', faqsRouter); export default router; diff --git a/apps/backend/src/routers/portal/faqs.ts b/apps/backend/src/routers/portal/faqs.ts new file mode 100644 index 000000000..ed644082a --- /dev/null +++ b/apps/backend/src/routers/portal/faqs.ts @@ -0,0 +1,62 @@ +import express from 'express'; +import { Faq } from '@lems/database'; +import db from '../../lib/database'; + +const router = express.Router(); + +// Helper function to format FAQ response for portal (excludes timestamps and seasonId) +const formatPortalFaqResponse = (faq: Faq) => ({ + id: faq.id, + question: faq.question, + answer: faq.answer, + displayOrder: faq.display_order +}); + +// GET /portal/faqs - Get all FAQs +router.get('/', async (req, res) => { + try { + const faqs = await db.faqs.all().getAll(); + res.json(faqs.map(formatPortalFaqResponse)); + } catch (error) { + console.error('Error fetching FAQs:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// GET /portal/faqs/season/:seasonId - Get FAQs by season +router.get('/season/:seasonId', async (req, res) => { + try { + const { seasonId } = req.params; + const faqs = await db.faqs.bySeason(seasonId).getAll(); + res.json(faqs.map(formatPortalFaqResponse)); + } catch (error) { + console.error('Error fetching FAQs by season:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +// GET /portal/faqs/search - Search FAQs +router.get('/search', async (req, res) => { + try { + const { q, seasonId } = req.query; + + if (!q || typeof q !== 'string') { + res.status(400).json({ error: 'Search query is required' }); + return; + } + + let faqs: Faq[]; + if (seasonId && typeof seasonId === 'string') { + faqs = await db.faqs.bySeason(seasonId).search(q); + } else { + faqs = await db.faqs.all().search(q); + } + + res.json(faqs.map(formatPortalFaqResponse)); + } catch (error) { + console.error('Error searching FAQs:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router; diff --git a/apps/backend/src/routers/portal/index.ts b/apps/backend/src/routers/portal/index.ts index 8bef62569..a0cbc3192 100644 --- a/apps/backend/src/routers/portal/index.ts +++ b/apps/backend/src/routers/portal/index.ts @@ -5,6 +5,7 @@ import eventsRouter from './events'; import teamsRouter from './teams'; import divisionsRouter from './divisions'; import searchRouter from './search'; +import faqsRouter from './faqs'; const router = express.Router({ mergeParams: true }); @@ -15,5 +16,6 @@ router.use('/events', eventsRouter); router.use('/teams', teamsRouter); router.use('/divisions', divisionsRouter); router.use('/search', searchRouter); +router.use('/faqs', faqsRouter); export default router; diff --git a/apps/portal/locale/en.json b/apps/portal/locale/en.json index 70ae492e1..155a9d3e0 100644 --- a/apps/portal/locale/en.json +++ b/apps/portal/locale/en.json @@ -266,6 +266,19 @@ "mobile-placeholder": "Choose value...", "mobile-feedback": "Feedback" } + }, + "faqs": { + "title": "Robot Game Rules FAQ", + "subtitle": "Frequently Asked Questions about FIRST LEGO League Challenge Robot Game Rules", + "search-placeholder": "Search questions and answers...", + "results-count": "{count} questions", + "search-active": "Searching", + "no-results": "No questions found", + "empty-state": "No FAQs available yet", + "try-different-search": "Try searching with different keywords", + "errors": { + "load-failed": "Failed to load FAQs. Please try again later." + } } }, "layouts": { @@ -274,7 +287,8 @@ "teams": "Teams", "events": "Events", "scorer": "Scorer", - "rubrics": "Rubrics" + "rubrics": "Rubrics", + "faqs": "FAQ" } } } diff --git a/apps/portal/locale/he.json b/apps/portal/locale/he.json index eec12c817..d42884d01 100644 --- a/apps/portal/locale/he.json +++ b/apps/portal/locale/he.json @@ -266,6 +266,19 @@ "mobile-placeholder": "סמנו ניקוד...", "mobile-feedback": "משוב" } + }, + "faqs": { + "title": "שאלות נפוצות - חוקי משחק הרובוט", + "subtitle": "שאלות נפוצות על חוקי משחק הרובוט של FIRST LEGO League Challenge", + "search-placeholder": "חיפוש שאלות ותשובות...", + "results-count": "{count} שאלות", + "search-active": "מחפש", + "no-results": "לא נמצאו שאלות", + "empty-state": "אין עדיין שאלות נפוצות זמינות", + "try-different-search": "נסה לחפש עם מילות מפתח שונות", + "errors": { + "load-failed": "טעינת השאלות הנפוצות נכשלה. אנא נסה שוב מאוחר יותר." + } } }, "layouts": { @@ -274,7 +287,8 @@ "teams": "קבוצות", "events": "אירועים", "scorer": "מחשבון ניקוד", - "rubrics": "מחווני שיפוט" + "rubrics": "מחווני שיפוט", + "faqs": "שאלות נפוצות" } } } diff --git a/apps/portal/src/app/[locale]/components/app-bar.tsx b/apps/portal/src/app/[locale]/components/app-bar.tsx index 81200dc82..ada96a025 100644 --- a/apps/portal/src/app/[locale]/components/app-bar.tsx +++ b/apps/portal/src/app/[locale]/components/app-bar.tsx @@ -20,7 +20,8 @@ import { CalculateOutlined, Group, Event, - MenuRounded + MenuRounded, + QuestionAnswer } from '@mui/icons-material'; import { ResponsiveComponent } from '@lems/shared'; import { Link } from '../../../i18n/navigation'; @@ -31,7 +32,8 @@ const pages = [ { name: 'teams', href: '/teams', icon: }, { name: 'events', href: '/events', icon: }, { name: 'scorer', href: '/tools/scorer', icon: }, - { name: 'rubrics', href: '/tools/rubrics', icon: } + { name: 'rubrics', href: '/tools/rubrics', icon: }, + { name: 'faqs', href: '/faqs', icon: } ]; interface PortalAppBarProps { diff --git a/apps/portal/src/app/[locale]/faqs/page.tsx b/apps/portal/src/app/[locale]/faqs/page.tsx new file mode 100644 index 000000000..50b1b6c53 --- /dev/null +++ b/apps/portal/src/app/[locale]/faqs/page.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { + Container, + Paper, + Typography, + Box, + TextField, + InputAdornment, + Accordion, + AccordionSummary, + AccordionDetails, + CircularProgress, + Alert, + Chip, + Stack +} from '@mui/material'; +import { + Search as SearchIcon, + ExpandMore as ExpandMoreIcon, + QuestionAnswer as QuestionAnswerIcon +} from '@mui/icons-material'; +import { useTranslations } from 'next-intl'; +import useSWR from 'swr'; +import { PortalFaqResponse } from '@lems/types/api/portal'; + +export default function FaqsPage() { + const t = useTranslations('pages.faqs'); + const [searchQuery, setSearchQuery] = useState(''); + const [expandedId, setExpandedId] = useState(false); + + const { data: faqs, error } = useSWR('/portal/faqs'); + + const filteredFaqs = useMemo(() => { + if (!faqs) return []; + if (!searchQuery.trim()) return faqs; + + const query = searchQuery.toLowerCase(); + return faqs.filter( + faq => faq.question.toLowerCase().includes(query) || faq.answer.toLowerCase().includes(query) + ); + }, [faqs, searchQuery]); + + const handleAccordionChange = (id: string) => (_: React.SyntheticEvent, isExpanded: boolean) => { + setExpandedId(isExpanded ? id : false); + }; + + const loading = !faqs && !error; + + return ( + + {/* Header Section */} + + + + + {t('title')} + + + + {t('subtitle')} + + + + {/* Search Section */} + + setSearchQuery(e.target.value)} + InputProps={{ + startAdornment: ( + + + + ) + }} + /> + + + {/* Results Section */} + {error && ( + + {t('errors.load-failed')} + + )} + + {loading ? ( + + + + ) : filteredFaqs.length === 0 ? ( + + + + {searchQuery ? t('no-results') : t('empty-state')} + + {searchQuery && ( + + {t('try-different-search')} + + )} + + ) : ( + + + + {t('results-count', { count: filteredFaqs.length })} + + {searchQuery && ( + setSearchQuery('')} + color="primary" + size="small" + /> + )} + + + + {filteredFaqs.map(faq => ( + + } + sx={{ + '& .MuiAccordionSummary-content': { + my: 2 + } + }} + > + + {faq.question} + + + + + {faq.answer} + + + + ))} + + + )} + + ); +} diff --git a/libs/database/src/database.ts b/libs/database/src/database.ts index ffc46cbbd..98f48cc5c 100644 --- a/libs/database/src/database.ts +++ b/libs/database/src/database.ts @@ -18,6 +18,7 @@ import { RubricsRepository } from './repositories/rubrics'; import { ScoresheetsRepository } from './repositories/scoresheets'; import { JudgingDeliberationsRepository } from './repositories/judging-deliberations'; import { FinalDeliberationsRepository } from './repositories/final-deliberations'; +import { FaqsRepository } from './repositories/faqs'; const IS_PRODUCTION = process.env.NODE_ENV === 'production'; @@ -72,6 +73,7 @@ export class Database { public scoresheets: ScoresheetsRepository; public awards: AwardsRepository; + public faqs: FaqsRepository; /** * Direct access to low-level database connections for advanced queries. @@ -138,6 +140,7 @@ export class Database { this.scoresheets = new ScoresheetsRepository(this.kysely, this.mongoDb); this.awards = new AwardsRepository(this.kysely); + this.faqs = new FaqsRepository(this.kysely); } async connect(): Promise { diff --git a/libs/database/src/migrations/026_add_manage_faq_permission.ts b/libs/database/src/migrations/026_add_manage_faq_permission.ts new file mode 100644 index 000000000..e553286aa --- /dev/null +++ b/libs/database/src/migrations/026_add_manage_faq_permission.ts @@ -0,0 +1,14 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // Add MANAGE_FAQ to the permission_type enum + await sql`ALTER TYPE permission_type ADD VALUE 'MANAGE_FAQ'`.execute(db); +} + +export async function down(_db: Kysely): Promise { + // Note: PostgreSQL doesn't support removing enum values directly + // This would require recreating the enum type and all dependent objects + // For safety, we'll leave the enum value in place + console.warn('Cannot remove enum value MANAGE_FAQ - PostgreSQL limitation'); +} diff --git a/libs/database/src/migrations/027_create_faqs_table.ts b/libs/database/src/migrations/027_create_faqs_table.ts new file mode 100644 index 000000000..f112b6df8 --- /dev/null +++ b/libs/database/src/migrations/027_create_faqs_table.ts @@ -0,0 +1,46 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + // Create the faqs table + await db.schema + .createTable('faqs') + .addColumn('pk', 'serial', col => col.primaryKey()) + .addColumn('id', 'uuid', col => col.notNull().defaultTo(sql`gen_random_uuid()`).unique()) + .addColumn('season_id', 'uuid', col => col.notNull()) + .addColumn('question', 'text', col => col.notNull()) + .addColumn('answer', 'text', col => col.notNull()) + .addColumn('display_order', 'integer', col => col.notNull().defaultTo(0)) + .addColumn('created_at', 'timestamptz', col => col.notNull().defaultTo(sql`now()`)) + .addColumn('updated_at', 'timestamptz', col => col.notNull().defaultTo(sql`now()`)) + .execute(); + + // Add foreign key constraint to seasons table + await db.schema + .alterTable('faqs') + .addForeignKeyConstraint('fk_faqs_season_id', ['season_id'], 'seasons', ['id']) + .onDelete('cascade') + .execute(); + + // Create indexes for faster lookups + await db.schema + .createIndex('idx_faqs_season_id') + .on('faqs') + .column('season_id') + .execute(); + + await db.schema + .createIndex('idx_faqs_display_order') + .on('faqs') + .columns(['season_id', 'display_order']) + .execute(); +} + +export async function down(db: Kysely): Promise { + // Drop the indexes + await db.schema.dropIndex('idx_faqs_season_id').ifExists().execute(); + await db.schema.dropIndex('idx_faqs_display_order').ifExists().execute(); + + // Drop the table + await db.schema.dropTable('faqs').ifExists().execute(); +} diff --git a/libs/database/src/repositories/faqs.ts b/libs/database/src/repositories/faqs.ts new file mode 100644 index 000000000..4aad6457c --- /dev/null +++ b/libs/database/src/repositories/faqs.ts @@ -0,0 +1,99 @@ +import { Kysely } from 'kysely'; +import { KyselyDatabaseSchema } from '../schema/kysely'; +import { InsertableFaq, Faq, UpdateableFaq } from '../schema/tables/faqs'; + +class FaqSelector { + constructor( + private db: Kysely, + private faqId: string + ) {} + + private getFaqQuery() { + return this.db.selectFrom('faqs').selectAll().where('id', '=', this.faqId); + } + + async get(): Promise { + const faq = await this.getFaqQuery().executeTakeFirst(); + return faq || null; + } + + async update(updates: UpdateableFaq): Promise { + const [updatedFaq] = await this.db + .updateTable('faqs') + .set({ ...updates, updated_at: new Date() }) + .where('id', '=', this.faqId) + .returningAll() + .execute(); + return updatedFaq; + } + + async delete(): Promise { + await this.db.deleteFrom('faqs').where('id', '=', this.faqId).execute(); + } +} + +class FaqsSelector { + constructor( + private db: Kysely, + private seasonId?: string + ) {} + + private getBaseQuery() { + let query = this.db.selectFrom('faqs').selectAll(); + if (this.seasonId) { + query = query.where('season_id', '=', this.seasonId); + } + return query; + } + + async getAll(): Promise { + return await this.getBaseQuery().orderBy('display_order', 'asc').execute(); + } + + async search(searchTerm: string): Promise { + const term = `%${searchTerm}%`; + return await this.getBaseQuery() + .where(eb => + eb.or([ + eb('question', 'ilike', term), + eb('answer', 'ilike', term) + ]) + ) + .orderBy('display_order', 'asc') + .execute(); + } +} + +export class FaqsRepository { + constructor(private db: Kysely) {} + + byId(faqId: string): FaqSelector { + return new FaqSelector(this.db, faqId); + } + + bySeason(seasonId: string): FaqsSelector { + return new FaqsSelector(this.db, seasonId); + } + + all(): FaqsSelector { + return new FaqsSelector(this.db); + } + + async create(faq: InsertableFaq): Promise { + const [createdFaq] = await this.db + .insertInto('faqs') + .values(faq) + .returningAll() + .execute(); + return createdFaq; + } + + async getMaxDisplayOrder(seasonId: string): Promise { + const result = await this.db + .selectFrom('faqs') + .select(eb => eb.fn.max('display_order').as('max_order')) + .where('season_id', '=', seasonId) + .executeTakeFirst(); + return (result?.max_order as number) || 0; + } +} diff --git a/libs/database/src/schema/index.ts b/libs/database/src/schema/index.ts index 4ba8add82..835e177e6 100644 --- a/libs/database/src/schema/index.ts +++ b/libs/database/src/schema/index.ts @@ -36,3 +36,4 @@ export * from './documents/final-deliberation'; export * from './tables/judging-deliberation'; export * from './tables/awards'; +export * from './tables/faqs'; diff --git a/libs/database/src/schema/kysely.ts b/libs/database/src/schema/kysely.ts index 856610e18..b2ebe0403 100644 --- a/libs/database/src/schema/kysely.ts +++ b/libs/database/src/schema/kysely.ts @@ -18,6 +18,7 @@ import { RobotGameMatchParticipantsTable } from './tables/robot-game-match-parti import { AwardsTable } from './tables/awards'; import { AgendaEventsTable } from './tables/agenda-events'; import { JudgingDeliberationsTable } from './tables/judging-deliberation'; +import { FaqsTable } from './tables/faqs'; export interface KyselyDatabaseSchema { admins: AdminsTable; @@ -40,4 +41,5 @@ export interface KyselyDatabaseSchema { awards: AwardsTable; agenda_events: AgendaEventsTable; judging_deliberations: JudgingDeliberationsTable; + faqs: FaqsTable; } diff --git a/libs/database/src/schema/tables/admin-permissions.ts b/libs/database/src/schema/tables/admin-permissions.ts index e2cd8f585..27ea6d8f5 100644 --- a/libs/database/src/schema/tables/admin-permissions.ts +++ b/libs/database/src/schema/tables/admin-permissions.ts @@ -6,7 +6,8 @@ export type PermissionType = | 'MANAGE_EVENTS' | 'MANAGE_EVENT_DETAILS' | 'MANAGE_TEAMS' - | 'VIEW_INSIGHTS'; + | 'VIEW_INSIGHTS' + | 'MANAGE_FAQ'; export interface AdminPermissionTable { pk: ColumnType; // Serial primary key diff --git a/libs/database/src/schema/tables/faqs.ts b/libs/database/src/schema/tables/faqs.ts new file mode 100644 index 000000000..9be300d55 --- /dev/null +++ b/libs/database/src/schema/tables/faqs.ts @@ -0,0 +1,16 @@ +import { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; + +export interface FaqsTable { + pk: ColumnType; // Serial primary key + id: ColumnType; // UUID, generated + season_id: string; // UUID foreign key to seasons.id + question: string; + answer: string; + display_order: number; + created_at: ColumnType; // Generated on insert + updated_at: ColumnType; // Updated on modification +} + +export type Faq = Selectable; +export type InsertableFaq = Insertable; +export type UpdateableFaq = Updateable; diff --git a/libs/types/src/lib/api/admin/faqs.ts b/libs/types/src/lib/api/admin/faqs.ts new file mode 100644 index 000000000..b0190603b --- /dev/null +++ b/libs/types/src/lib/api/admin/faqs.ts @@ -0,0 +1,32 @@ +import { z } from 'zod'; + +export const FaqResponseSchema = z.object({ + id: z.string(), + seasonId: z.string(), + question: z.string(), + answer: z.string(), + displayOrder: z.number(), + createdAt: z.string(), + updatedAt: z.string() +}); + +export type FaqResponse = z.infer; + +export const FaqsResponseSchema = z.array(FaqResponseSchema); + +export const CreateFaqRequestSchema = z.object({ + seasonId: z.string(), + question: z.string().min(1, 'Question is required'), + answer: z.string().min(1, 'Answer is required'), + displayOrder: z.number().optional() +}); + +export type CreateFaqRequest = z.infer; + +export const UpdateFaqRequestSchema = z.object({ + question: z.string().min(1, 'Question is required').optional(), + answer: z.string().min(1, 'Answer is required').optional(), + displayOrder: z.number().optional() +}); + +export type UpdateFaqRequest = z.infer; diff --git a/libs/types/src/lib/api/admin/index.ts b/libs/types/src/lib/api/admin/index.ts index d3ec28ab7..2e2b8bf58 100644 --- a/libs/types/src/lib/api/admin/index.ts +++ b/libs/types/src/lib/api/admin/index.ts @@ -7,3 +7,4 @@ export * from './tables'; export * from './rooms'; export * from './awards'; export * from './schedule'; +export * from './faqs'; diff --git a/libs/types/src/lib/api/admin/users.ts b/libs/types/src/lib/api/admin/users.ts index 05bdf3b1d..3ae9ec3ea 100644 --- a/libs/types/src/lib/api/admin/users.ts +++ b/libs/types/src/lib/api/admin/users.ts @@ -19,7 +19,8 @@ export const ALL_ADMIN_PERMISSIONS = [ 'MANAGE_EVENTS', 'MANAGE_EVENT_DETAILS', 'MANAGE_TEAMS', - 'VIEW_INSIGHTS' + 'VIEW_INSIGHTS', + 'MANAGE_FAQ' ] as const satisfies readonly PermissionType[]; export const AdminUserPermissionsResponseSchema = z.array(z.enum(ALL_ADMIN_PERMISSIONS)); diff --git a/libs/types/src/lib/api/portal/faqs.ts b/libs/types/src/lib/api/portal/faqs.ts new file mode 100644 index 000000000..c417499af --- /dev/null +++ b/libs/types/src/lib/api/portal/faqs.ts @@ -0,0 +1,14 @@ +import { z } from 'zod'; + +export const PortalFaqResponseSchema = z.object({ + id: z.string(), + question: z.string(), + answer: z.string(), + displayOrder: z.number() +}); + +export type PortalFaqResponse = z.infer; + +export const PortalFaqsResponseSchema = z.array(PortalFaqResponseSchema); + +export type PortalFaqsResponse = z.infer; diff --git a/libs/types/src/lib/api/portal/index.ts b/libs/types/src/lib/api/portal/index.ts index 9d2868411..a6123f456 100644 --- a/libs/types/src/lib/api/portal/index.ts +++ b/libs/types/src/lib/api/portal/index.ts @@ -2,3 +2,4 @@ export * from './seasons'; export * from './events'; export * from './teams'; export * from './divisions'; +export * from './faqs'; From a9b7d3f54ef98cf024e63a7c53e4575c9a07c331 Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Sat, 17 Jan 2026 19:16:00 +0200 Subject: [PATCH 02/16] design --- apps/portal/src/app/[locale]/faqs/page.tsx | 32 +++++++++++----------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/apps/portal/src/app/[locale]/faqs/page.tsx b/apps/portal/src/app/[locale]/faqs/page.tsx index 50b1b6c53..7ef2ed626 100644 --- a/apps/portal/src/app/[locale]/faqs/page.tsx +++ b/apps/portal/src/app/[locale]/faqs/page.tsx @@ -49,27 +49,19 @@ export default function FaqsPage() { const loading = !faqs && !error; return ( - + {/* Header Section */} - - - - - {t('title')} - - - - {t('subtitle')} - - + {t('title')} + {/* Search Section */} @@ -138,6 +130,14 @@ export default function FaqsPage() { sx={{ '&:before': { display: 'none' }, boxShadow: 1, + transition: 'all 0.3s ease-in-out', + borderLeft: '3px solid transparent', + '&:hover': { + boxShadow: 4, + transform: 'translateY(-4px)', + borderLeft: theme => `3px solid ${theme.palette.primary.main}`, + bgcolor: theme => `${theme.palette.primary.main}08` + }, '&.Mui-expanded': { boxShadow: 3 } From 35a383c94c234e62b5cdea749b61d63501e1eed1 Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Sat, 17 Jan 2026 19:26:56 +0200 Subject: [PATCH 03/16] text style edit --- apps/admin/locale/en.json | 5 + apps/admin/locale/he.json | 5 + .../faqs/components/faq-editor-dialog.tsx | 117 ++++++++++++++++-- .../[locale]/lems/(integration)/layout.tsx | 0 apps/portal/src/app/[locale]/faqs/page.tsx | 8 +- .../026_add_manage_faq_permission.ts | 1 + 6 files changed, 120 insertions(+), 16 deletions(-) create mode 100644 apps/frontend/src/app/[locale]/lems/(integration)/layout.tsx diff --git a/apps/admin/locale/en.json b/apps/admin/locale/en.json index 651eead68..26ad443e3 100644 --- a/apps/admin/locale/en.json +++ b/apps/admin/locale/en.json @@ -1090,6 +1090,11 @@ "display-order": "Display Order", "display-order-help": "Leave empty to add at the end" }, + "toolbar": { + "bold": "Bold", + "italic": "Italic", + "color": "Text color" + }, "errors": { "required-fields": "Please fill in all required fields.", "save-failed": "Failed to save FAQ. Please try again." diff --git a/apps/admin/locale/he.json b/apps/admin/locale/he.json index 94508e40e..f9571fb81 100644 --- a/apps/admin/locale/he.json +++ b/apps/admin/locale/he.json @@ -1090,6 +1090,11 @@ "display-order": "סדר תצוגה", "display-order-help": "השאר ריק כדי להוסיף בסוף" }, + "toolbar": { + "bold": "מודגש", + "italic": "נטוי", + "color": "צבע טקסט" + }, "errors": { "required-fields": "אנא מלא את כל השדות הנדרשים.", "save-failed": "שמירת השאלה הנפוצה נכשלה. אנא נסה שוב." diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx index eda3f7a6d..4b89967d6 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; import { Dialog, DialogTitle, @@ -8,19 +8,25 @@ import { DialogActions, Button, TextField, + IconButton, FormControl, InputLabel, Select, MenuItem, Alert, CircularProgress, - Box + Box, + Paper, + Typography, + Tooltip } from '@mui/material'; +import { FormatBold, FormatItalic, Palette } from '@mui/icons-material'; import { useTranslations } from 'next-intl'; import { mutate } from 'swr'; import { FaqResponse } from '@lems/types/api/admin'; import { Season } from '@lems/database'; -import { apiFetch } from '@lems/shared'; +import { apiFetch, ColorPicker } from '@lems/shared'; +import { HsvaColor, hexToHsva, hsvaToHex } from '@uiw/react-color'; interface FaqEditorDialogProps { open: boolean; @@ -37,6 +43,8 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog const [displayOrder, setDisplayOrder] = useState(faq?.displayOrder?.toString() || ''); const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); + const [textColor, setTextColor] = useState(hexToHsva('#000000')); + const editorRef = useRef(null); useEffect(() => { if (faq) { @@ -53,6 +61,21 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog setError(null); }, [faq, seasons]); + useEffect(() => { + if (editorRef.current && editorRef.current.innerHTML !== answer) { + editorRef.current.innerHTML = answer; + } + }, [answer]); + + const applyCommand = (command: string, value?: string) => { + if (!editorRef.current) return; + editorRef.current.focus(); + document.execCommand(command, false, value); + setAnswer(editorRef.current.innerHTML); + }; + + const currentColor = hsvaToHex(textColor); + const handleSubmit = async () => { if (!seasonId || !question.trim() || !answer.trim()) { setError(t('errors.required-fields')); @@ -141,16 +164,84 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog disabled={isSubmitting} /> - setAnswer(e.target.value)} - fullWidth - required - multiline - rows={6} - disabled={isSubmitting} - /> + + + {t('fields.answer')} + + + + + applyCommand('bold')} + disabled={isSubmitting} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + + applyCommand('italic')} + disabled={isSubmitting} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + setTextColor(color)}> + applyCommand('foreColor', currentColor)} + disabled={isSubmitting} + sx={{ + border: '1px solid', + borderColor: 'divider', + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + {currentColor} + + + + setAnswer(editorRef.current?.innerHTML ?? '')} + onBlur={() => setAnswer(editorRef.current?.innerHTML ?? '')} + sx={{ + outline: 'none', + minHeight: 140, + '&:empty:before': { + content: '""' + } + }} + /> + + - {faq.answer} - + dangerouslySetInnerHTML={{ + __html: DOMPurify.sanitize(faq.answer) + }} + /> ))} diff --git a/libs/database/src/migrations/026_add_manage_faq_permission.ts b/libs/database/src/migrations/026_add_manage_faq_permission.ts index e553286aa..1377571a7 100644 --- a/libs/database/src/migrations/026_add_manage_faq_permission.ts +++ b/libs/database/src/migrations/026_add_manage_faq_permission.ts @@ -7,6 +7,7 @@ export async function up(db: Kysely): Promise { } export async function down(_db: Kysely): Promise { + void _db; // Note: PostgreSQL doesn't support removing enum values directly // This would require recreating the enum type and all dependent objects // For safety, we'll leave the enum value in place From a78c46418c63c716762575c140ca703a517549bc Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Sat, 17 Jan 2026 19:33:38 +0200 Subject: [PATCH 04/16] color picker --- .../faqs/components/faq-editor-dialog.tsx | 95 +++++++++++++++++-- 1 file changed, 88 insertions(+), 7 deletions(-) diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx index 4b89967d6..6ffee63f0 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx @@ -44,7 +44,9 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog const [isSubmitting, setIsSubmitting] = useState(false); const [error, setError] = useState(null); const [textColor, setTextColor] = useState(hexToHsva('#000000')); + const [usedColors, setUsedColors] = useState(['#000000']); const editorRef = useRef(null); + const selectionRef = useRef(null); useEffect(() => { if (faq) { @@ -67,14 +69,57 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog } }, [answer]); + useEffect(() => { + const colors = extractColors(answer); + setUsedColors(colors.length > 0 ? colors : ['#000000']); + }, [answer]); + + const saveSelection = () => { + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + selectionRef.current = selection.getRangeAt(0); + } + }; + + const restoreSelection = () => { + const selection = window.getSelection(); + if (selectionRef.current && selection) { + selection.removeAllRanges(); + selection.addRange(selectionRef.current); + } + }; + const applyCommand = (command: string, value?: string) => { if (!editorRef.current) return; editorRef.current.focus(); + restoreSelection(); document.execCommand(command, false, value); setAnswer(editorRef.current.innerHTML); }; const currentColor = hsvaToHex(textColor); + const applyColor = (color: string) => { + setTextColor(hexToHsva(color)); + applyCommand('foreColor', color); + }; + + const extractColors = (html: string) => { + const colors = new Set(); + const colorRegexes = [/color\s*=\s*"?([#a-fA-F0-9]{3,7})"?/g, /color\s*:\s*([^;"']+)/g]; + + colorRegexes.forEach(regex => { + let match = regex.exec(html); + while (match) { + const color = match[1].trim(); + if (color.startsWith('#')) { + colors.add(color.toLowerCase()); + } + match = regex.exec(html); + } + }); + + return Array.from(colors); + }; const handleSubmit = async () => { if (!seasonId || !question.trim() || !answer.trim()) { @@ -201,9 +246,10 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog - setTextColor(color)}> + applyColor(hsvaToHex(color))}> applyCommand('foreColor', currentColor)} disabled={isSubmitting} sx={{ @@ -217,12 +263,45 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog - - {currentColor} - + + {usedColors + .filter(color => color.toLowerCase() !== currentColor.toLowerCase()) + .map(color => ( + + applyColor(color)} + sx={{ + width: 18, + height: 18, + borderRadius: '50%', + bgcolor: color, + cursor: isSubmitting ? 'default' : 'pointer', + border: '1px solid', + borderColor: 'divider' + }} + /> + + ))} + + + `0 0 0 2px ${theme.palette.primary.main}` + }} + /> + + {currentColor} + + setAnswer(editorRef.current?.innerHTML ?? '')} onBlur={() => setAnswer(editorRef.current?.innerHTML ?? '')} + onMouseUp={saveSelection} + onKeyUp={saveSelection} sx={{ outline: 'none', minHeight: 140, From 08709da39e7a8a2af74475d8ddb310fdb7623062 Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Sat, 17 Jan 2026 19:40:45 +0200 Subject: [PATCH 05/16] bullets --- apps/admin/locale/en.json | 3 ++- apps/admin/locale/he.json | 3 ++- .../faqs/components/faq-editor-dialog.tsx | 18 +++++++++++++++++- 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/admin/locale/en.json b/apps/admin/locale/en.json index 26ad443e3..81d557262 100644 --- a/apps/admin/locale/en.json +++ b/apps/admin/locale/en.json @@ -1093,7 +1093,8 @@ "toolbar": { "bold": "Bold", "italic": "Italic", - "color": "Text color" + "color": "Text color", + "bullets": "Bulleted list" }, "errors": { "required-fields": "Please fill in all required fields.", diff --git a/apps/admin/locale/he.json b/apps/admin/locale/he.json index f9571fb81..0997ec6b3 100644 --- a/apps/admin/locale/he.json +++ b/apps/admin/locale/he.json @@ -1093,7 +1093,8 @@ "toolbar": { "bold": "מודגש", "italic": "נטוי", - "color": "צבע טקסט" + "color": "צבע טקסט", + "bullets": "רשימת נקודות" }, "errors": { "required-fields": "אנא מלא את כל השדות הנדרשים.", diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx index 6ffee63f0..09cc45c49 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx @@ -20,7 +20,7 @@ import { Typography, Tooltip } from '@mui/material'; -import { FormatBold, FormatItalic, Palette } from '@mui/icons-material'; +import { FormatBold, FormatItalic, Palette, FormatListBulleted } from '@mui/icons-material'; import { useTranslations } from 'next-intl'; import { mutate } from 'swr'; import { FaqResponse } from '@lems/types/api/admin'; @@ -246,6 +246,22 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog + + + applyCommand('insertUnorderedList')} + disabled={isSubmitting} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + applyColor(hsvaToHex(color))}> Date: Sun, 18 Jan 2026 21:50:32 +0200 Subject: [PATCH 06/16] can add pictuer clean a bit --- apps/admin/locale/en.json | 10 +- apps/admin/locale/he.json | 10 +- .../components/delete-confirm-dialog.tsx | 0 .../components/faq-editor-dialog.tsx | 167 +++++++++++++++++- .../(dashboard)/{faqs => faq}/page.tsx | 8 +- apps/backend/src/lib/blob-storage/upload.ts | 3 +- apps/backend/src/routers/admin/faqs.ts | 54 +++++- .../src/app/[locale]/{faqs => faq}/page.tsx | 3 - 8 files changed, 238 insertions(+), 17 deletions(-) rename apps/admin/src/app/[locale]/(dashboard)/{faqs => faq}/components/delete-confirm-dialog.tsx (100%) rename apps/admin/src/app/[locale]/(dashboard)/{faqs => faq}/components/faq-editor-dialog.tsx (67%) rename apps/admin/src/app/[locale]/(dashboard)/{faqs => faq}/page.tsx (96%) rename apps/portal/src/app/[locale]/{faqs => faq}/page.tsx (98%) diff --git a/apps/admin/locale/en.json b/apps/admin/locale/en.json index 81d557262..cb239104e 100644 --- a/apps/admin/locale/en.json +++ b/apps/admin/locale/en.json @@ -1094,11 +1094,17 @@ "bold": "Bold", "italic": "Italic", "color": "Text color", - "bullets": "Bulleted list" + "bullets": "Bulleted list", + "image": "Insert image", + "delete-image": "Click to delete image", + "confirm-delete-image": "Are you sure you want to delete this image?" }, "errors": { "required-fields": "Please fill in all required fields.", - "save-failed": "Failed to save FAQ. Please try again." + "save-failed": "Failed to save FAQ. Please try again.", + "invalid-image-type": "Image must be a PNG or JPEG file.", + "image-too-large": "Image size must not exceed 2 MB.", + "image-upload-failed": "Failed to upload image. Please try again." }, "actions": { "cancel": "Cancel", diff --git a/apps/admin/locale/he.json b/apps/admin/locale/he.json index 0997ec6b3..53a63cec1 100644 --- a/apps/admin/locale/he.json +++ b/apps/admin/locale/he.json @@ -1094,11 +1094,17 @@ "bold": "מודגש", "italic": "נטוי", "color": "צבע טקסט", - "bullets": "רשימת נקודות" + "bullets": "רשימת נקודות", + "image": "הוספת תמונה", + "delete-image": "לחץ למחיקת התמונה", + "confirm-delete-image": "האם אתה בטוח שברצונך למחוק תמונה זו?" }, "errors": { "required-fields": "אנא מלא את כל השדות הנדרשים.", - "save-failed": "שמירת השאלה הנפוצה נכשלה. אנא נסה שוב." + "save-failed": "שמירת השאלה הנפוצה נכשלה. אנא נסה שוב.", + "invalid-image-type": "התמונה חייבת להיות קובץ PNG או JPEG.", + "image-too-large": "גודל התמונה לא יכול לעלות על 2 MB.", + "image-upload-failed": "העלאת התמונה נכשלה. אנא נסה שוב." }, "actions": { "cancel": "ביטול", diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/delete-confirm-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/faq/components/delete-confirm-dialog.tsx similarity index 100% rename from apps/admin/src/app/[locale]/(dashboard)/faqs/components/delete-confirm-dialog.tsx rename to apps/admin/src/app/[locale]/(dashboard)/faq/components/delete-confirm-dialog.tsx diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/faq/components/faq-editor-dialog.tsx similarity index 67% rename from apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx rename to apps/admin/src/app/[locale]/(dashboard)/faq/components/faq-editor-dialog.tsx index 09cc45c49..bbd7f6b87 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/faq/components/faq-editor-dialog.tsx @@ -20,7 +20,13 @@ import { Typography, Tooltip } from '@mui/material'; -import { FormatBold, FormatItalic, Palette, FormatListBulleted } from '@mui/icons-material'; +import { + FormatBold, + FormatItalic, + Palette, + FormatListBulleted, + Image as ImageIcon +} from '@mui/icons-material'; import { useTranslations } from 'next-intl'; import { mutate } from 'swr'; import { FaqResponse } from '@lems/types/api/admin'; @@ -45,8 +51,10 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog const [error, setError] = useState(null); const [textColor, setTextColor] = useState(hexToHsva('#000000')); const [usedColors, setUsedColors] = useState(['#000000']); + const [isUploadingImage, setIsUploadingImage] = useState(false); const editorRef = useRef(null); const selectionRef = useRef(null); + const fileInputRef = useRef(null); useEffect(() => { if (faq) { @@ -121,6 +129,140 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog return Array.from(colors); }; + const handleImageUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + // Validate file type + if ( + !file.type.startsWith('image/') || + (!file.name.endsWith('.jpg') && !file.name.endsWith('.jpeg') && !file.name.endsWith('.png')) + ) { + setError(t('errors.invalid-image-type')); + return; + } + + // Validate file size (2MB) + if (file.size > 2 * 1024 * 1024) { + setError(t('errors.image-too-large')); + return; + } + + setIsUploadingImage(true); + setError(null); + + try { + const formData = new FormData(); + formData.append('image', file); + + const result = await apiFetch('/admin/faqs/upload-image', { + method: 'POST', + body: formData + }); + + if (result.ok) { + const imageUrl = (result.data as { url: string }).url; + + if (editorRef.current) { + editorRef.current.focus(); + restoreSelection(); + + // Create wrapper div for image with delete button + const wrapper = document.createElement('div'); + wrapper.style.position = 'relative'; + wrapper.style.display = 'inline-block'; + wrapper.style.maxWidth = '100%'; + wrapper.style.margin = '10px 0'; + + const img = document.createElement('img'); + img.src = imageUrl; + img.style.maxWidth = '100%'; + img.style.height = 'auto'; + img.style.display = 'block'; + + // Create delete button overlay + const deleteBtn = document.createElement('button'); + deleteBtn.innerHTML = '✕'; + deleteBtn.title = t('toolbar.delete-image'); + deleteBtn.style.position = 'absolute'; + deleteBtn.style.top = '5px'; + deleteBtn.style.right = '5px'; + deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; + deleteBtn.style.color = 'white'; + deleteBtn.style.border = 'none'; + deleteBtn.style.borderRadius = '50%'; + deleteBtn.style.width = '28px'; + deleteBtn.style.height = '28px'; + deleteBtn.style.cursor = 'pointer'; + deleteBtn.style.fontSize = '16px'; + deleteBtn.style.fontWeight = 'bold'; + deleteBtn.style.display = 'flex'; + deleteBtn.style.alignItems = 'center'; + deleteBtn.style.justifyContent = 'center'; + deleteBtn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)'; + deleteBtn.style.transition = 'all 0.2s'; + deleteBtn.style.zIndex = '10'; + + deleteBtn.onmouseover = () => { + deleteBtn.style.backgroundColor = 'rgba(211, 47, 47, 1)'; + deleteBtn.style.transform = 'scale(1.1)'; + }; + + deleteBtn.onmouseout = () => { + deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; + deleteBtn.style.transform = 'scale(1)'; + }; + + deleteBtn.onclick = e => { + e.preventDefault(); + e.stopPropagation(); + if (confirm(t('toolbar.confirm-delete-image'))) { + wrapper.remove(); + setAnswer(editorRef.current?.innerHTML ?? ''); + } + }; + + wrapper.appendChild(img); + wrapper.appendChild(deleteBtn); + + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(wrapper); + + // Insert a line break after the wrapper to allow typing + const br = document.createElement('br'); + range.collapse(false); + range.insertNode(br); + + // Move cursor after the line break + range.setStartAfter(br); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } else { + editorRef.current.appendChild(wrapper); + const br = document.createElement('br'); + editorRef.current.appendChild(br); + } + + setAnswer(editorRef.current.innerHTML); + } + } else { + setError(t('errors.image-upload-failed')); + } + } catch (err) { + console.error('Error uploading image:', err); + setError(t('errors.image-upload-failed')); + } finally { + setIsUploadingImage(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + const handleSubmit = async () => { if (!seasonId || !question.trim() || !answer.trim()) { setError(t('errors.required-fields')); @@ -262,6 +404,29 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog + + + fileInputRef.current?.click()} + disabled={isSubmitting || isUploadingImage} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + applyColor(hsvaToHex(color))}> { + const tmp = document.createElement('div'); + tmp.innerHTML = html; + return tmp.textContent || tmp.innerText || ''; + }; + return ( @@ -155,7 +161,7 @@ export default function FaqsPage() { - {faq.answer} + {stripHtml(faq.answer)} diff --git a/apps/backend/src/lib/blob-storage/upload.ts b/apps/backend/src/lib/blob-storage/upload.ts index 975afa5d5..4ceacae7a 100644 --- a/apps/backend/src/lib/blob-storage/upload.ts +++ b/apps/backend/src/lib/blob-storage/upload.ts @@ -3,6 +3,7 @@ import { s3Client } from './s3-client'; export const uploadFile = async (data: Buffer, key: string) => { const bucket = process.env.DIGITALOCEAN_SPACE; + const endpoint = process.env.DIGITALOCEAN_ENDPOINT; const bucketParams = { ACL: 'public-read' as ObjectCannedACL, Bucket: bucket, @@ -11,5 +12,5 @@ export const uploadFile = async (data: Buffer, key: string) => { }; await s3Client.send(new PutObjectCommand(bucketParams)); - return `${bucket}/${key}`; + return `https://${bucket}.${endpoint}/${key}`; }; diff --git a/apps/backend/src/routers/admin/faqs.ts b/apps/backend/src/routers/admin/faqs.ts index faa1f07ba..e7a8f5c6a 100644 --- a/apps/backend/src/routers/admin/faqs.ts +++ b/apps/backend/src/routers/admin/faqs.ts @@ -1,12 +1,16 @@ import express from 'express'; +import fileUpload from 'express-fileupload'; import { CreateFaqRequestSchema, UpdateFaqRequestSchema } from '@lems/types/api/admin'; import { Faq } from '@lems/database'; import db from '../../lib/database'; +import { uploadFile } from '../../lib/blob-storage/upload'; import { AdminRequest } from '../../types/express'; import { requirePermission } from './middleware/require-permission'; const router = express.Router(); +const FILE_SIZE_LIMIT = 2 * 1024 * 1024; // 2 MB + // Helper function to format FAQ response const formatFaqResponse = (faq: Faq) => ({ id: faq.id, @@ -18,7 +22,6 @@ const formatFaqResponse = (faq: Faq) => ({ updatedAt: faq.updated_at.toISOString() }); -// GET /admin/faqs - Get all FAQs router.get('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { try { const faqs = await db.faqs.all().getAll(); @@ -29,7 +32,6 @@ router.get('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) } }); -// GET /admin/faqs/season/:seasonId - Get FAQs by season router.get('/season/:seasonId', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { try { const { seasonId } = req.params; @@ -41,7 +43,6 @@ router.get('/season/:seasonId', requirePermission('MANAGE_FAQ'), async (req: Adm } }); -// GET /admin/faqs/:id - Get single FAQ router.get('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { try { const { id } = req.params; @@ -59,7 +60,6 @@ router.get('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, re } }); -// POST /admin/faqs - Create new FAQ router.post('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { try { const validation = CreateFaqRequestSchema.safeParse(req.body); @@ -71,7 +71,6 @@ router.post('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) const { seasonId, question, answer, displayOrder } = validation.data; - // If no display order provided, get the next available order const order = displayOrder ?? (await db.faqs.getMaxDisplayOrder(seasonId)) + 1; const faq = await db.faqs.create({ @@ -88,7 +87,6 @@ router.post('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) } }); -// PUT /admin/faqs/:id - Update FAQ router.put('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { try { const { id } = req.params; @@ -118,7 +116,6 @@ router.put('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, re } }); -// DELETE /admin/faqs/:id - Delete FAQ router.delete('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { try { const { id } = req.params; @@ -137,4 +134,47 @@ router.delete('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, } }); +router.post( + '/upload-image', + requirePermission('MANAGE_FAQ'), + fileUpload(), + async (req: AdminRequest, res) => { + if (!req.files || !req.files.image) { + res.status(400).json({ error: 'No image file provided' }); + return; + } + + const imageFile = req.files.image as fileUpload.UploadedFile; + + if ( + !imageFile.mimetype?.startsWith('image/') || + (!imageFile.name.endsWith('.jpg') && + !imageFile.name.endsWith('.jpeg') && + !imageFile.name.endsWith('.png')) + ) { + res.status(400).json({ error: 'Image must be a JPG or PNG file' }); + return; + } + + if (imageFile.size > FILE_SIZE_LIMIT) { + res.status(400).json({ error: 'Image file size must not exceed 2 MB' }); + return; + } + + try { + const timestamp = Date.now(); + const extension = imageFile.name.split('.').pop(); + const filename = `faq-${timestamp}.${extension}`; + const key = `faqs/${filename}`; + + const imageUrl = await uploadFile(imageFile.data, key); + + res.status(200).json({ url: imageUrl }); + } catch (error) { + console.error('Error uploading FAQ image:', error); + res.status(500).json({ error: 'Failed to upload image' }); + } + } +); + export default router; diff --git a/apps/portal/src/app/[locale]/faqs/page.tsx b/apps/portal/src/app/[locale]/faq/page.tsx similarity index 98% rename from apps/portal/src/app/[locale]/faqs/page.tsx rename to apps/portal/src/app/[locale]/faq/page.tsx index 904db2ebd..a889e8bfe 100644 --- a/apps/portal/src/app/[locale]/faqs/page.tsx +++ b/apps/portal/src/app/[locale]/faq/page.tsx @@ -51,7 +51,6 @@ export default function FaqsPage() { return ( - {/* Header Section */} - {/* Search Section */} - {/* Results Section */} {error && ( {t('errors.load-failed')} From 40fcf5b91018a7765cae3cce2245a07d8a66d68b Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Sun, 18 Jan 2026 21:58:44 +0200 Subject: [PATCH 07/16] revert --- .../{faq => faqs}/components/delete-confirm-dialog.tsx | 0 .../(dashboard)/{faq => faqs}/components/faq-editor-dialog.tsx | 0 apps/admin/src/app/[locale]/(dashboard)/{faq => faqs}/page.tsx | 0 apps/portal/src/app/[locale]/{faq => faqs}/page.tsx | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename apps/admin/src/app/[locale]/(dashboard)/{faq => faqs}/components/delete-confirm-dialog.tsx (100%) rename apps/admin/src/app/[locale]/(dashboard)/{faq => faqs}/components/faq-editor-dialog.tsx (100%) rename apps/admin/src/app/[locale]/(dashboard)/{faq => faqs}/page.tsx (100%) rename apps/portal/src/app/[locale]/{faq => faqs}/page.tsx (100%) diff --git a/apps/admin/src/app/[locale]/(dashboard)/faq/components/delete-confirm-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/delete-confirm-dialog.tsx similarity index 100% rename from apps/admin/src/app/[locale]/(dashboard)/faq/components/delete-confirm-dialog.tsx rename to apps/admin/src/app/[locale]/(dashboard)/faqs/components/delete-confirm-dialog.tsx diff --git a/apps/admin/src/app/[locale]/(dashboard)/faq/components/faq-editor-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx similarity index 100% rename from apps/admin/src/app/[locale]/(dashboard)/faq/components/faq-editor-dialog.tsx rename to apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx diff --git a/apps/admin/src/app/[locale]/(dashboard)/faq/page.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx similarity index 100% rename from apps/admin/src/app/[locale]/(dashboard)/faq/page.tsx rename to apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx diff --git a/apps/portal/src/app/[locale]/faq/page.tsx b/apps/portal/src/app/[locale]/faqs/page.tsx similarity index 100% rename from apps/portal/src/app/[locale]/faq/page.tsx rename to apps/portal/src/app/[locale]/faqs/page.tsx From 8d806dd572cc35716a6e3d18f024327c6310e0cd Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Sun, 18 Jan 2026 22:08:33 +0200 Subject: [PATCH 08/16] video --- apps/admin/locale/en.json | 10 +- apps/admin/locale/he.json | 10 +- .../faqs/components/faq-editor-dialog.tsx | 155 ++++++++++++++++-- apps/backend/src/routers/admin/faqs.ts | 46 +++++- 4 files changed, 202 insertions(+), 19 deletions(-) diff --git a/apps/admin/locale/en.json b/apps/admin/locale/en.json index cb239104e..fd3205582 100644 --- a/apps/admin/locale/en.json +++ b/apps/admin/locale/en.json @@ -1096,15 +1096,21 @@ "color": "Text color", "bullets": "Bulleted list", "image": "Insert image", + "media": "Insert media", "delete-image": "Click to delete image", - "confirm-delete-image": "Are you sure you want to delete this image?" + "delete-video": "Click to delete video", + "confirm-delete-image": "Are you sure you want to delete this image?", + "confirm-delete-video": "Are you sure you want to delete this video?" }, "errors": { "required-fields": "Please fill in all required fields.", "save-failed": "Failed to save FAQ. Please try again.", "invalid-image-type": "Image must be a PNG or JPEG file.", "image-too-large": "Image size must not exceed 2 MB.", - "image-upload-failed": "Failed to upload image. Please try again." + "image-upload-failed": "Failed to upload image. Please try again.", + "invalid-video-type": "Video must be an MP4 or WebM file.", + "video-too-large": "Video size must not exceed 50 MB.", + "video-upload-failed": "Failed to upload video. Please try again." }, "actions": { "cancel": "Cancel", diff --git a/apps/admin/locale/he.json b/apps/admin/locale/he.json index 53a63cec1..78eba9fa0 100644 --- a/apps/admin/locale/he.json +++ b/apps/admin/locale/he.json @@ -1096,15 +1096,21 @@ "color": "צבע טקסט", "bullets": "רשימת נקודות", "image": "הוספת תמונה", + "media": "הוספת מדיה", "delete-image": "לחץ למחיקת התמונה", - "confirm-delete-image": "האם אתה בטוח שברצונך למחוק תמונה זו?" + "delete-video": "לחץ למחיקת הוידאו", + "confirm-delete-image": "האם אתה בטוח שברצונך למחוק תמונה זו?", + "confirm-delete-video": "האם אתה בטוח שברצונך למחוק וידאו זה?" }, "errors": { "required-fields": "אנא מלא את כל השדות הנדרשים.", "save-failed": "שמירת השאלה הנפוצה נכשלה. אנא נסה שוב.", "invalid-image-type": "התמונה חייבת להיות קובץ PNG או JPEG.", "image-too-large": "גודל התמונה לא יכול לעלות על 2 MB.", - "image-upload-failed": "העלאת התמונה נכשלה. אנא נסה שוב." + "image-upload-failed": "העלאת התמונה נכשלה. אנא נסה שוב.", + "invalid-video-type": "הוידאו חייב להיות קובץ MP4 או WebM.", + "video-too-large": "גודל הוידאו לא יכול לעלות על 50 MB.", + "video-upload-failed": "העלאת הוידאו נכשלה. אנא נסה שוב." }, "actions": { "cancel": "ביטול", diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx index bbd7f6b87..2bda74cfa 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx @@ -52,6 +52,7 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog const [textColor, setTextColor] = useState(hexToHsva('#000000')); const [usedColors, setUsedColors] = useState(['#000000']); const [isUploadingImage, setIsUploadingImage] = useState(false); + const [isUploadingVideo, setIsUploadingVideo] = useState(false); const editorRef = useRef(null); const selectionRef = useRef(null); const fileInputRef = useRef(null); @@ -129,11 +130,133 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog return Array.from(colors); }; - const handleImageUpload = async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; + const handleVideoUpload = async (file: File) => { + if ( + !file.type.startsWith('video/') || + (!file.name.endsWith('.mp4') && !file.name.endsWith('.webm')) + ) { + setError(t('errors.invalid-video-type')); + return; + } + + if (file.size > 50 * 1024 * 1024) { + setError(t('errors.video-too-large')); + return; + } + + setIsUploadingVideo(true); + setError(null); + + try { + const formData = new FormData(); + formData.append('video', file); + + const result = await apiFetch('/admin/faqs/upload-video', { + method: 'POST', + body: formData + }); + + if (result.ok) { + const videoUrl = (result.data as { url: string }).url; + + if (editorRef.current) { + editorRef.current.focus(); + restoreSelection(); + + const wrapper = document.createElement('div'); + wrapper.style.position = 'relative'; + wrapper.style.display = 'inline-block'; + wrapper.style.maxWidth = '100%'; + wrapper.style.margin = '10px 0'; + + const video = document.createElement('video'); + video.src = videoUrl; + video.controls = true; + video.style.maxWidth = '100%'; + video.style.height = 'auto'; + video.style.display = 'block'; + + const deleteBtn = document.createElement('button'); + deleteBtn.innerHTML = '✕'; + deleteBtn.title = t('toolbar.delete-video'); + deleteBtn.style.position = 'absolute'; + deleteBtn.style.top = '5px'; + deleteBtn.style.right = '5px'; + deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; + deleteBtn.style.color = 'white'; + deleteBtn.style.border = 'none'; + deleteBtn.style.borderRadius = '50%'; + deleteBtn.style.width = '28px'; + deleteBtn.style.height = '28px'; + deleteBtn.style.cursor = 'pointer'; + deleteBtn.style.fontSize = '16px'; + deleteBtn.style.fontWeight = 'bold'; + deleteBtn.style.display = 'flex'; + deleteBtn.style.alignItems = 'center'; + deleteBtn.style.justifyContent = 'center'; + deleteBtn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)'; + deleteBtn.style.transition = 'all 0.2s'; + deleteBtn.style.zIndex = '10'; - // Validate file type + deleteBtn.onmouseover = () => { + deleteBtn.style.backgroundColor = 'rgba(211, 47, 47, 1)'; + deleteBtn.style.transform = 'scale(1.1)'; + }; + + deleteBtn.onmouseout = () => { + deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; + deleteBtn.style.transform = 'scale(1)'; + }; + + deleteBtn.onclick = e => { + e.preventDefault(); + e.stopPropagation(); + if (confirm(t('toolbar.confirm-delete-video'))) { + wrapper.remove(); + setAnswer(editorRef.current?.innerHTML ?? ''); + } + }; + + wrapper.appendChild(video); + wrapper.appendChild(deleteBtn); + + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(wrapper); + + const br = document.createElement('br'); + range.collapse(false); + range.insertNode(br); + + range.setStartAfter(br); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } else { + editorRef.current.appendChild(wrapper); + const br = document.createElement('br'); + editorRef.current.appendChild(br); + } + + setAnswer(editorRef.current.innerHTML); + } + } else { + setError(t('errors.video-upload-failed')); + } + } catch (err) { + console.error('Error uploading video:', err); + setError(t('errors.video-upload-failed')); + } finally { + setIsUploadingVideo(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + const handleImageUpload = async (file: File) => { if ( !file.type.startsWith('image/') || (!file.name.endsWith('.jpg') && !file.name.endsWith('.jpeg') && !file.name.endsWith('.png')) @@ -142,7 +265,6 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog return; } - // Validate file size (2MB) if (file.size > 2 * 1024 * 1024) { setError(t('errors.image-too-large')); return; @@ -167,7 +289,6 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog editorRef.current.focus(); restoreSelection(); - // Create wrapper div for image with delete button const wrapper = document.createElement('div'); wrapper.style.position = 'relative'; wrapper.style.display = 'inline-block'; @@ -180,7 +301,6 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog img.style.height = 'auto'; img.style.display = 'block'; - // Create delete button overlay const deleteBtn = document.createElement('button'); deleteBtn.innerHTML = '✕'; deleteBtn.title = t('toolbar.delete-image'); @@ -231,12 +351,10 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog range.deleteContents(); range.insertNode(wrapper); - // Insert a line break after the wrapper to allow typing const br = document.createElement('br'); range.collapse(false); range.insertNode(br); - // Move cursor after the line break range.setStartAfter(br); range.collapse(true); selection.removeAllRanges(); @@ -263,6 +381,17 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog } }; + const handleMediaUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + if (file.type.startsWith('video/')) { + await handleVideoUpload(file); + } else { + await handleImageUpload(file); + } + }; + const handleSubmit = async () => { if (!seasonId || !question.trim() || !answer.trim()) { setError(t('errors.required-fields')); @@ -404,12 +533,12 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog - + fileInputRef.current?.click()} - disabled={isSubmitting || isUploadingImage} + disabled={isSubmitting || isUploadingImage || isUploadingVideo} sx={{ '&:hover': { backgroundColor: 'action.hover' @@ -423,9 +552,9 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog applyColor(hsvaToHex(color))}> ({ @@ -156,7 +157,7 @@ router.post( return; } - if (imageFile.size > FILE_SIZE_LIMIT) { + if (imageFile.size > IMAGE_SIZE_LIMIT) { res.status(400).json({ error: 'Image file size must not exceed 2 MB' }); return; } @@ -177,4 +178,45 @@ router.post( } ); +router.post( + '/upload-video', + requirePermission('MANAGE_FAQ'), + fileUpload(), + async (req: AdminRequest, res) => { + if (!req.files || !req.files.video) { + res.status(400).json({ error: 'No video file provided' }); + return; + } + + const videoFile = req.files.video as fileUpload.UploadedFile; + + if ( + !videoFile.mimetype?.startsWith('video/') || + (!videoFile.name.endsWith('.mp4') && !videoFile.name.endsWith('.webm')) + ) { + res.status(400).json({ error: 'Video must be an MP4 or WebM file' }); + return; + } + + if (videoFile.size > VIDEO_SIZE_LIMIT) { + res.status(400).json({ error: 'Video file size must not exceed 50 MB' }); + return; + } + + try { + const timestamp = Date.now(); + const extension = videoFile.name.split('.').pop(); + const filename = `faq-video-${timestamp}.${extension}`; + const key = `faqs/${filename}`; + + const videoUrl = await uploadFile(videoFile.data, key); + + res.status(200).json({ url: videoUrl }); + } catch (error) { + console.error('Error uploading FAQ video:', error); + res.status(500).json({ error: 'Failed to upload video' }); + } + } +); + export default router; From 0b9efeaae7994827c58965314d7a506cd9be2586 Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Sun, 18 Jan 2026 22:27:06 +0200 Subject: [PATCH 09/16] split --- .../faqs/components/color-palette.tsx | 79 ++ .../faqs/components/faq-editor-dialog-old.tsx | 687 ++++++++++++++++++ .../faqs/components/faq-editor-dialog.tsx | 440 +---------- .../faqs/components/media-upload-button.tsx | 52 ++ .../faqs/components/rich-text-toolbar.tsx | 65 ++ .../faqs/components/use-media-upload.tsx | 220 ++++++ 6 files changed, 1142 insertions(+), 401 deletions(-) create mode 100644 apps/admin/src/app/[locale]/(dashboard)/faqs/components/color-palette.tsx create mode 100644 apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog-old.tsx create mode 100644 apps/admin/src/app/[locale]/(dashboard)/faqs/components/media-upload-button.tsx create mode 100644 apps/admin/src/app/[locale]/(dashboard)/faqs/components/rich-text-toolbar.tsx create mode 100644 apps/admin/src/app/[locale]/(dashboard)/faqs/components/use-media-upload.tsx diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/color-palette.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/color-palette.tsx new file mode 100644 index 000000000..304f11f4e --- /dev/null +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/color-palette.tsx @@ -0,0 +1,79 @@ +import { IconButton, Tooltip, Box, Typography } from '@mui/material'; +import { Palette } from '@mui/icons-material'; +import { ColorPicker } from '@lems/shared'; +import { HsvaColor, hsvaToHex } from '@uiw/react-color'; +interface ColorPaletteProps { + textColor: HsvaColor; + usedColors: string[]; + onColorChange: (color: string) => void; + onSaveSelection: () => void; + disabled?: boolean; +} + +export function ColorPalette({ + textColor, + usedColors, + onColorChange, + onSaveSelection, + disabled = false +}: ColorPaletteProps) { + const currentColor = hsvaToHex(textColor); + + return ( + + onColorChange(hsvaToHex(color))}> + onColorChange(currentColor)} + disabled={disabled} + sx={{ + border: '1px solid', + borderColor: 'divider', + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + {usedColors + .filter(color => color.toLowerCase() !== currentColor.toLowerCase()) + .map(color => ( + + onColorChange(color)} + sx={{ + width: 18, + height: 18, + borderRadius: '50%', + bgcolor: color, + cursor: disabled ? 'default' : 'pointer', + border: '1px solid', + borderColor: 'divider' + }} + /> + + ))} + + + `0 0 0 2px ${theme.palette.primary.main}` + }} + /> + + {currentColor} + + + + ); +} diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog-old.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog-old.tsx new file mode 100644 index 000000000..759b085ab --- /dev/null +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog-old.tsx @@ -0,0 +1,687 @@ +'use client'; + +import { useState, useEffect, useRef } from 'react'; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + IconButton, + FormControl, + InputLabel, + Select, + MenuItem, + Alert, + CircularProgress, + Box, + Paper, + Typography, + Tooltip +} from '@mui/material'; +import { + FormatBold, + FormatItalic, + Palette, + FormatListBulleted, + Image as ImageIcon +} from '@mui/icons-material'; +import { useTranslations } from 'next-intl'; +import { mutate } from 'swr'; +import { FaqResponse } from '@lems/types/api/admin'; +import { Season } from '@lems/database'; +import { apiFetch, ColorPicker } from '@lems/shared'; +import { HsvaColor, hexToHsva, hsvaToHex } from '@uiw/react-color'; + +interface FaqEditorDialogProps { + open: boolean; + faq: FaqResponse | null; + seasons: Season[]; + onClose: () => void; +} + +export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialogProps) { + const t = useTranslations('pages.faqs.editor'); + const [seasonId, setSeasonId] = useState(faq?.seasonId || ''); + const [question, setQuestion] = useState(faq?.question || ''); + const [answer, setAnswer] = useState(faq?.answer || ''); + const [displayOrder, setDisplayOrder] = useState(faq?.displayOrder?.toString() || ''); + const [isSubmitting, setIsSubmitting] = useState(false); + const [error, setError] = useState(null); + const [textColor, setTextColor] = useState(hexToHsva('#000000')); + const [usedColors, setUsedColors] = useState(['#000000']); + const [isUploadingImage, setIsUploadingImage] = useState(false); + const [isUploadingVideo, setIsUploadingVideo] = useState(false); + const editorRef = useRef(null); + const selectionRef = useRef(null); + const fileInputRef = useRef(null); + + useEffect(() => { + if (faq) { + setSeasonId(faq.seasonId); + setQuestion(faq.question); + setAnswer(faq.answer); + setDisplayOrder(faq.displayOrder.toString()); + } else { + setSeasonId(seasons[0]?.id || ''); + setQuestion(''); + setAnswer(''); + setDisplayOrder(''); + } + setError(null); + }, [faq, seasons]); + + useEffect(() => { + if (editorRef.current && editorRef.current.innerHTML !== answer) { + editorRef.current.innerHTML = answer; + } + }, [answer]); + + useEffect(() => { + const colors = extractColors(answer); + setUsedColors(colors.length > 0 ? colors : ['#000000']); + }, [answer]); + + const saveSelection = () => { + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + selectionRef.current = selection.getRangeAt(0); + } + }; + + const restoreSelection = () => { + const selection = window.getSelection(); + if (selectionRef.current && selection) { + selection.removeAllRanges(); + selection.addRange(selectionRef.current); + } + }; + + const applyCommand = (command: string, value?: string) => { + if (!editorRef.current) return; + editorRef.current.focus(); + restoreSelection(); + document.execCommand(command, false, value); + setAnswer(editorRef.current.innerHTML); + }; + + const currentColor = hsvaToHex(textColor); + const applyColor = (color: string) => { + setTextColor(hexToHsva(color)); + applyCommand('foreColor', color); + }; + + const extractColors = (html: string) => { + const colors = new Set(); + const colorRegexes = [/color\s*=\s*"?([#a-fA-F0-9]{3,7})"?/g, /color\s*:\s*([^;"']+)/g]; + + colorRegexes.forEach(regex => { + let match = regex.exec(html); + while (match) { + const color = match[1].trim(); + if (color.startsWith('#')) { + colors.add(color.toLowerCase()); + } + match = regex.exec(html); + } + }); + + return Array.from(colors); + }; + + const handleVideoUpload = async (file: File) => { + if ( + !file.type.startsWith('video/') || + (!file.name.endsWith('.mp4') && !file.name.endsWith('.webm')) + ) { + setError(t('errors.invalid-video-type')); + return; + } + + if (file.size > 50 * 1024 * 1024) { + setError(t('errors.video-too-large')); + return; + } + + setIsUploadingVideo(true); + setError(null); + + try { + const formData = new FormData(); + formData.append('video', file); + + const result = await apiFetch('/admin/faqs/upload-video', { + method: 'POST', + body: formData + }); + + if (result.ok) { + const videoUrl = (result.data as { url: string }).url; + + if (editorRef.current) { + editorRef.current.focus(); + restoreSelection(); + + const wrapper = document.createElement('div'); + wrapper.style.position = 'relative'; + wrapper.style.display = 'inline-block'; + wrapper.style.maxWidth = '100%'; + wrapper.style.margin = '10px 0'; + + const video = document.createElement('video'); + video.src = videoUrl; + video.controls = true; + video.style.maxWidth = '100%'; + video.style.height = 'auto'; + video.style.display = 'block'; + + const deleteBtn = document.createElement('button'); + deleteBtn.innerHTML = '✕'; + deleteBtn.title = t('toolbar.delete-video'); + deleteBtn.style.position = 'absolute'; + deleteBtn.style.top = '5px'; + deleteBtn.style.right = '5px'; + deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; + deleteBtn.style.color = 'white'; + deleteBtn.style.border = 'none'; + deleteBtn.style.borderRadius = '50%'; + deleteBtn.style.width = '28px'; + deleteBtn.style.height = '28px'; + deleteBtn.style.cursor = 'pointer'; + deleteBtn.style.fontSize = '16px'; + deleteBtn.style.fontWeight = 'bold'; + deleteBtn.style.display = 'flex'; + deleteBtn.style.alignItems = 'center'; + deleteBtn.style.justifyContent = 'center'; + deleteBtn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)'; + deleteBtn.style.transition = 'all 0.2s'; + deleteBtn.style.zIndex = '10'; + + deleteBtn.onmouseover = () => { + deleteBtn.style.backgroundColor = 'rgba(211, 47, 47, 1)'; + deleteBtn.style.transform = 'scale(1.1)'; + }; + + deleteBtn.onmouseout = () => { + deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; + deleteBtn.style.transform = 'scale(1)'; + }; + + deleteBtn.onclick = e => { + e.preventDefault(); + e.stopPropagation(); + if (confirm(t('toolbar.confirm-delete-video'))) { + wrapper.remove(); + setAnswer(editorRef.current?.innerHTML ?? ''); + } + }; + + wrapper.appendChild(video); + wrapper.appendChild(deleteBtn); + + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(wrapper); + + const br = document.createElement('br'); + range.collapse(false); + range.insertNode(br); + + range.setStartAfter(br); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } else { + editorRef.current.appendChild(wrapper); + const br = document.createElement('br'); + editorRef.current.appendChild(br); + } + + setAnswer(editorRef.current.innerHTML); + } + } else { + setError(t('errors.video-upload-failed')); + } + } catch (err) { + console.error('Error uploading video:', err); + setError(t('errors.video-upload-failed')); + } finally { + setIsUploadingVideo(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + const handleImageUpload = async (file: File) => { + if ( + !file.type.startsWith('image/') || + (!file.name.endsWith('.jpg') && !file.name.endsWith('.jpeg') && !file.name.endsWith('.png')) + ) { + setError(t('errors.invalid-image-type')); + return; + } + + if (file.size > 2 * 1024 * 1024) { + setError(t('errors.image-too-large')); + return; + } + + setIsUploadingImage(true); + setError(null); + + try { + const formData = new FormData(); + formData.append('image', file); + + const result = await apiFetch('/admin/faqs/upload-image', { + method: 'POST', + body: formData + }); + + if (result.ok) { + const imageUrl = (result.data as { url: string }).url; + + if (editorRef.current) { + editorRef.current.focus(); + restoreSelection(); + + const wrapper = document.createElement('div'); + wrapper.style.position = 'relative'; + wrapper.style.display = 'inline-block'; + wrapper.style.maxWidth = '100%'; + wrapper.style.margin = '10px 0'; + + const img = document.createElement('img'); + img.src = imageUrl; + img.style.maxWidth = '100%'; + img.style.height = 'auto'; + img.style.display = 'block'; + + const deleteBtn = document.createElement('button'); + deleteBtn.innerHTML = '✕'; + deleteBtn.title = t('toolbar.delete-image'); + deleteBtn.style.position = 'absolute'; + deleteBtn.style.top = '5px'; + deleteBtn.style.right = '5px'; + deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; + deleteBtn.style.color = 'white'; + deleteBtn.style.border = 'none'; + deleteBtn.style.borderRadius = '50%'; + deleteBtn.style.width = '28px'; + deleteBtn.style.height = '28px'; + deleteBtn.style.cursor = 'pointer'; + deleteBtn.style.fontSize = '16px'; + deleteBtn.style.fontWeight = 'bold'; + deleteBtn.style.display = 'flex'; + deleteBtn.style.alignItems = 'center'; + deleteBtn.style.justifyContent = 'center'; + deleteBtn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)'; + deleteBtn.style.transition = 'all 0.2s'; + deleteBtn.style.zIndex = '10'; + + deleteBtn.onmouseover = () => { + deleteBtn.style.backgroundColor = 'rgba(211, 47, 47, 1)'; + deleteBtn.style.transform = 'scale(1.1)'; + }; + + deleteBtn.onmouseout = () => { + deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; + deleteBtn.style.transform = 'scale(1)'; + }; + + deleteBtn.onclick = e => { + e.preventDefault(); + e.stopPropagation(); + if (confirm(t('toolbar.confirm-delete-image'))) { + wrapper.remove(); + setAnswer(editorRef.current?.innerHTML ?? ''); + } + }; + + wrapper.appendChild(img); + wrapper.appendChild(deleteBtn); + + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(wrapper); + + const br = document.createElement('br'); + range.collapse(false); + range.insertNode(br); + + range.setStartAfter(br); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } else { + editorRef.current.appendChild(wrapper); + const br = document.createElement('br'); + editorRef.current.appendChild(br); + } + + setAnswer(editorRef.current.innerHTML); + } + } else { + setError(t('errors.image-upload-failed')); + } + } catch (err) { + console.error('Error uploading image:', err); + setError(t('errors.image-upload-failed')); + } finally { + setIsUploadingImage(false); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + const handleMediaUpload = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) return; + + if (file.type.startsWith('video/')) { + await handleVideoUpload(file); + } else { + await handleImageUpload(file); + } + }; + + const sanitizeFaqHtml = (html: string): string => { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; + + // Find all wrapper divs with delete buttons + const wrappers = tempDiv.querySelectorAll('div[style*="position: relative"]'); + wrappers.forEach(wrapper => { + // Remove delete button + const deleteBtn = wrapper.querySelector('button'); + if (deleteBtn) { + deleteBtn.remove(); + } + + // Extract the actual media element (img or video) + const media = wrapper.querySelector('img, video'); + if (media && wrapper.parentNode) { + // Replace wrapper with just the media element + wrapper.parentNode.replaceChild(media, wrapper); + } + }); + + return tempDiv.innerHTML; + }; + + const handleSubmit = async () => { + if (!seasonId || !question.trim() || !answer.trim()) { + setError(t('errors.required-fields')); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + const body: { + question: string; + answer: string; + seasonId?: string; + displayOrder?: number; + } = { + question: question.trim(), + answer: sanitizeFaqHtml(answer.trim()) + }; + + if (!faq) { + body.seasonId = seasonId; + } + + if (displayOrder) { + body.displayOrder = parseInt(displayOrder); + } + + const url = faq ? `/admin/faqs/${faq.id}` : '/admin/faqs'; + const method = faq ? 'PUT' : 'POST'; + + const result = await apiFetch(url, { + method, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + + if (!result.ok) { + throw new Error('Failed to save FAQ'); + } + + // Refresh FAQ lists + await Promise.all([ + mutate('/admin/faqs'), + mutate(key => typeof key === 'string' && key.startsWith('/admin/faqs/season/')) + ]); + + onClose(); + } catch (err) { + setError(err instanceof Error ? err.message : t('errors.save-failed')); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + {faq ? t('title-edit') : t('title-create')} + + + {error && {error}} + + + {t('fields.season')} + + + + setQuestion(e.target.value)} + fullWidth + required + multiline + rows={2} + disabled={isSubmitting} + /> + + + + {t('fields.answer')} + + + + + applyCommand('bold')} + disabled={isSubmitting} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + + applyCommand('italic')} + disabled={isSubmitting} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + + applyCommand('insertUnorderedList')} + disabled={isSubmitting} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + + fileInputRef.current?.click()} + disabled={isSubmitting || isUploadingImage || isUploadingVideo} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + applyColor(hsvaToHex(color))}> + applyCommand('foreColor', currentColor)} + disabled={isSubmitting} + sx={{ + border: '1px solid', + borderColor: 'divider', + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + {usedColors + .filter(color => color.toLowerCase() !== currentColor.toLowerCase()) + .map(color => ( + + applyColor(color)} + sx={{ + width: 18, + height: 18, + borderRadius: '50%', + bgcolor: color, + cursor: isSubmitting ? 'default' : 'pointer', + border: '1px solid', + borderColor: 'divider' + }} + /> + + ))} + + + `0 0 0 2px ${theme.palette.primary.main}` + }} + /> + + {currentColor} + + + + + setAnswer(editorRef.current?.innerHTML ?? '')} + onBlur={() => setAnswer(editorRef.current?.innerHTML ?? '')} + onMouseUp={saveSelection} + onKeyUp={saveSelection} + sx={{ + outline: 'none', + minHeight: 140, + '&:empty:before': { + content: '""' + } + }} + /> + + + + setDisplayOrder(e.target.value)} + type="number" + fullWidth + helperText={t('fields.display-order-help')} + disabled={isSubmitting} + /> + + + + + + + + ); +} diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx index 2bda74cfa..7a0326706 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx @@ -8,7 +8,6 @@ import { DialogActions, Button, TextField, - IconButton, FormControl, InputLabel, Select, @@ -17,22 +16,18 @@ import { CircularProgress, Box, Paper, - Typography, - Tooltip + Typography } from '@mui/material'; -import { - FormatBold, - FormatItalic, - Palette, - FormatListBulleted, - Image as ImageIcon -} from '@mui/icons-material'; import { useTranslations } from 'next-intl'; import { mutate } from 'swr'; import { FaqResponse } from '@lems/types/api/admin'; import { Season } from '@lems/database'; -import { apiFetch, ColorPicker } from '@lems/shared'; -import { HsvaColor, hexToHsva, hsvaToHex } from '@uiw/react-color'; +import { apiFetch } from '@lems/shared'; +import { HsvaColor, hexToHsva } from '@uiw/react-color'; +import { RichTextToolbar } from './rich-text-toolbar'; +import { ColorPalette } from './color-palette'; +import { MediaUploadButton } from './media-upload-button'; +import { useMediaUpload } from './use-media-upload'; interface FaqEditorDialogProps { open: boolean; @@ -48,14 +43,10 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog const [answer, setAnswer] = useState(faq?.answer || ''); const [displayOrder, setDisplayOrder] = useState(faq?.displayOrder?.toString() || ''); const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); const [textColor, setTextColor] = useState(hexToHsva('#000000')); const [usedColors, setUsedColors] = useState(['#000000']); - const [isUploadingImage, setIsUploadingImage] = useState(false); - const [isUploadingVideo, setIsUploadingVideo] = useState(false); const editorRef = useRef(null); const selectionRef = useRef(null); - const fileInputRef = useRef(null); useEffect(() => { if (faq) { @@ -69,7 +60,6 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog setAnswer(''); setDisplayOrder(''); } - setError(null); }, [faq, seasons]); useEffect(() => { @@ -106,7 +96,6 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog setAnswer(editorRef.current.innerHTML); }; - const currentColor = hsvaToHex(textColor); const applyColor = (color: string) => { setTextColor(hexToHsva(color)); applyCommand('foreColor', color); @@ -130,267 +119,32 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog return Array.from(colors); }; - const handleVideoUpload = async (file: File) => { - if ( - !file.type.startsWith('video/') || - (!file.name.endsWith('.mp4') && !file.name.endsWith('.webm')) - ) { - setError(t('errors.invalid-video-type')); - return; - } + const sanitizeFaqHtml = (html: string): string => { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = html; - if (file.size > 50 * 1024 * 1024) { - setError(t('errors.video-too-large')); - return; - } + // Remove all delete links + tempDiv.querySelectorAll('a.media-delete-link').forEach(link => link.remove()); - setIsUploadingVideo(true); - setError(null); - - try { - const formData = new FormData(); - formData.append('video', file); - - const result = await apiFetch('/admin/faqs/upload-video', { - method: 'POST', - body: formData - }); - - if (result.ok) { - const videoUrl = (result.data as { url: string }).url; - - if (editorRef.current) { - editorRef.current.focus(); - restoreSelection(); - - const wrapper = document.createElement('div'); - wrapper.style.position = 'relative'; - wrapper.style.display = 'inline-block'; - wrapper.style.maxWidth = '100%'; - wrapper.style.margin = '10px 0'; - - const video = document.createElement('video'); - video.src = videoUrl; - video.controls = true; - video.style.maxWidth = '100%'; - video.style.height = 'auto'; - video.style.display = 'block'; - - const deleteBtn = document.createElement('button'); - deleteBtn.innerHTML = '✕'; - deleteBtn.title = t('toolbar.delete-video'); - deleteBtn.style.position = 'absolute'; - deleteBtn.style.top = '5px'; - deleteBtn.style.right = '5px'; - deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; - deleteBtn.style.color = 'white'; - deleteBtn.style.border = 'none'; - deleteBtn.style.borderRadius = '50%'; - deleteBtn.style.width = '28px'; - deleteBtn.style.height = '28px'; - deleteBtn.style.cursor = 'pointer'; - deleteBtn.style.fontSize = '16px'; - deleteBtn.style.fontWeight = 'bold'; - deleteBtn.style.display = 'flex'; - deleteBtn.style.alignItems = 'center'; - deleteBtn.style.justifyContent = 'center'; - deleteBtn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)'; - deleteBtn.style.transition = 'all 0.2s'; - deleteBtn.style.zIndex = '10'; - - deleteBtn.onmouseover = () => { - deleteBtn.style.backgroundColor = 'rgba(211, 47, 47, 1)'; - deleteBtn.style.transform = 'scale(1.1)'; - }; - - deleteBtn.onmouseout = () => { - deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; - deleteBtn.style.transform = 'scale(1)'; - }; - - deleteBtn.onclick = e => { - e.preventDefault(); - e.stopPropagation(); - if (confirm(t('toolbar.confirm-delete-video'))) { - wrapper.remove(); - setAnswer(editorRef.current?.innerHTML ?? ''); - } - }; - - wrapper.appendChild(video); - wrapper.appendChild(deleteBtn); - - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - range.deleteContents(); - range.insertNode(wrapper); - - const br = document.createElement('br'); - range.collapse(false); - range.insertNode(br); - - range.setStartAfter(br); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - } else { - editorRef.current.appendChild(wrapper); - const br = document.createElement('br'); - editorRef.current.appendChild(br); - } - - setAnswer(editorRef.current.innerHTML); - } - } else { - setError(t('errors.video-upload-failed')); - } - } catch (err) { - console.error('Error uploading video:', err); - setError(t('errors.video-upload-failed')); - } finally { - setIsUploadingVideo(false); - if (fileInputRef.current) { - fileInputRef.current.value = ''; + // Unwrap media from wrappers + tempDiv.querySelectorAll('div.media-wrapper').forEach(wrapper => { + const media = wrapper.querySelector('img, video'); + if (media && wrapper.parentNode) { + wrapper.parentNode.replaceChild(media, wrapper); } - } - }; - - const handleImageUpload = async (file: File) => { - if ( - !file.type.startsWith('image/') || - (!file.name.endsWith('.jpg') && !file.name.endsWith('.jpeg') && !file.name.endsWith('.png')) - ) { - setError(t('errors.invalid-image-type')); - return; - } - - if (file.size > 2 * 1024 * 1024) { - setError(t('errors.image-too-large')); - return; - } - - setIsUploadingImage(true); - setError(null); - - try { - const formData = new FormData(); - formData.append('image', file); - - const result = await apiFetch('/admin/faqs/upload-image', { - method: 'POST', - body: formData - }); + }); - if (result.ok) { - const imageUrl = (result.data as { url: string }).url; - - if (editorRef.current) { - editorRef.current.focus(); - restoreSelection(); - - const wrapper = document.createElement('div'); - wrapper.style.position = 'relative'; - wrapper.style.display = 'inline-block'; - wrapper.style.maxWidth = '100%'; - wrapper.style.margin = '10px 0'; - - const img = document.createElement('img'); - img.src = imageUrl; - img.style.maxWidth = '100%'; - img.style.height = 'auto'; - img.style.display = 'block'; - - const deleteBtn = document.createElement('button'); - deleteBtn.innerHTML = '✕'; - deleteBtn.title = t('toolbar.delete-image'); - deleteBtn.style.position = 'absolute'; - deleteBtn.style.top = '5px'; - deleteBtn.style.right = '5px'; - deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; - deleteBtn.style.color = 'white'; - deleteBtn.style.border = 'none'; - deleteBtn.style.borderRadius = '50%'; - deleteBtn.style.width = '28px'; - deleteBtn.style.height = '28px'; - deleteBtn.style.cursor = 'pointer'; - deleteBtn.style.fontSize = '16px'; - deleteBtn.style.fontWeight = 'bold'; - deleteBtn.style.display = 'flex'; - deleteBtn.style.alignItems = 'center'; - deleteBtn.style.justifyContent = 'center'; - deleteBtn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)'; - deleteBtn.style.transition = 'all 0.2s'; - deleteBtn.style.zIndex = '10'; - - deleteBtn.onmouseover = () => { - deleteBtn.style.backgroundColor = 'rgba(211, 47, 47, 1)'; - deleteBtn.style.transform = 'scale(1.1)'; - }; - - deleteBtn.onmouseout = () => { - deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; - deleteBtn.style.transform = 'scale(1)'; - }; - - deleteBtn.onclick = e => { - e.preventDefault(); - e.stopPropagation(); - if (confirm(t('toolbar.confirm-delete-image'))) { - wrapper.remove(); - setAnswer(editorRef.current?.innerHTML ?? ''); - } - }; - - wrapper.appendChild(img); - wrapper.appendChild(deleteBtn); - - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - range.deleteContents(); - range.insertNode(wrapper); - - const br = document.createElement('br'); - range.collapse(false); - range.insertNode(br); - - range.setStartAfter(br); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - } else { - editorRef.current.appendChild(wrapper); - const br = document.createElement('br'); - editorRef.current.appendChild(br); - } - - setAnswer(editorRef.current.innerHTML); - } - } else { - setError(t('errors.image-upload-failed')); - } - } catch (err) { - console.error('Error uploading image:', err); - setError(t('errors.image-upload-failed')); - } finally { - setIsUploadingImage(false); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - } + return tempDiv.innerHTML; }; - const handleMediaUpload = async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - if (file.type.startsWith('video/')) { - await handleVideoUpload(file); - } else { - await handleImageUpload(file); + const { isUploadingImage, isUploadingVideo, error, setError, handleMediaUpload } = useMediaUpload( + { + editorRef, + onAnswerChange: setAnswer, + saveSelection, + restoreSelection } - }; + ); const handleSubmit = async () => { if (!seasonId || !question.trim() || !answer.trim()) { @@ -409,7 +163,7 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog displayOrder?: number; } = { question: question.trim(), - answer: answer.trim() + answer: sanitizeFaqHtml(answer.trim()) }; if (!faq) { @@ -433,7 +187,6 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog throw new Error('Failed to save FAQ'); } - // Refresh FAQ lists await Promise.all([ mutate('/admin/faqs'), mutate(key => typeof key === 'string' && key.startsWith('/admin/faqs/season/')) @@ -484,134 +237,19 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog {t('fields.answer')} - - - - applyCommand('bold')} - disabled={isSubmitting} - sx={{ - '&:hover': { - backgroundColor: 'action.hover' - } - }} - > - - - - - - - applyCommand('italic')} - disabled={isSubmitting} - sx={{ - '&:hover': { - backgroundColor: 'action.hover' - } - }} - > - - - - - - - applyCommand('insertUnorderedList')} - disabled={isSubmitting} - sx={{ - '&:hover': { - backgroundColor: 'action.hover' - } - }} - > - - - - - - - fileInputRef.current?.click()} - disabled={isSubmitting || isUploadingImage || isUploadingVideo} - sx={{ - '&:hover': { - backgroundColor: 'action.hover' - } - }} - > - - - - - + + + - applyColor(hsvaToHex(color))}> - applyCommand('foreColor', currentColor)} - disabled={isSubmitting} - sx={{ - border: '1px solid', - borderColor: 'divider', - '&:hover': { - backgroundColor: 'action.hover' - } - }} - > - - - - - {usedColors - .filter(color => color.toLowerCase() !== currentColor.toLowerCase()) - .map(color => ( - - applyColor(color)} - sx={{ - width: 18, - height: 18, - borderRadius: '50%', - bgcolor: color, - cursor: isSubmitting ? 'default' : 'pointer', - border: '1px solid', - borderColor: 'divider' - }} - /> - - ))} - - - `0 0 0 2px ${theme.palette.primary.main}` - }} - /> - - {currentColor} - - void; + disabled?: boolean; +} + +export function MediaUploadButton({ onFileSelect, disabled = false }: MediaUploadButtonProps) { + const t = useTranslations('pages.faqs.editor.toolbar'); + const fileInputRef = useRef(null); + + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + onFileSelect(file); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + + return ( + <> + + + fileInputRef.current?.click()} + disabled={disabled} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + + ); +} diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/rich-text-toolbar.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/rich-text-toolbar.tsx new file mode 100644 index 000000000..93a9b4c0d --- /dev/null +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/rich-text-toolbar.tsx @@ -0,0 +1,65 @@ +import { IconButton, Tooltip, Box } from '@mui/material'; +import { FormatBold, FormatItalic, FormatListBulleted } from '@mui/icons-material'; +import { useTranslations } from 'next-intl'; + +interface RichTextToolbarProps { + onCommand: (command: string, value?: string) => void; + disabled?: boolean; +} + +export function RichTextToolbar({ onCommand, disabled = false }: RichTextToolbarProps) { + const t = useTranslations('pages.faqs.editor.toolbar'); + + return ( + + + + onCommand('bold')} + disabled={disabled} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + + onCommand('italic')} + disabled={disabled} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + + onCommand('insertUnorderedList')} + disabled={disabled} + sx={{ + '&:hover': { + backgroundColor: 'action.hover' + } + }} + > + + + + + + ); +} diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/use-media-upload.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/use-media-upload.tsx new file mode 100644 index 000000000..753da7aa5 --- /dev/null +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/use-media-upload.tsx @@ -0,0 +1,220 @@ +import { useState } from 'react'; +import { apiFetch } from '@lems/shared'; +import { useTranslations } from 'next-intl'; + +interface UseMediaUploadProps { + editorRef: React.RefObject; + onAnswerChange: (html: string) => void; + saveSelection: () => void; + restoreSelection: () => void; +} + +export function useMediaUpload({ + editorRef, + onAnswerChange, + restoreSelection +}: UseMediaUploadProps) { + const t = useTranslations('pages.faqs.editor'); + const [isUploadingImage, setIsUploadingImage] = useState(false); + const [isUploadingVideo, setIsUploadingVideo] = useState(false); + const [error, setError] = useState(null); + + const createDeleteLink = (type: 'image' | 'video', onDelete: () => void) => { + const deleteLink = document.createElement('a'); + deleteLink.innerHTML = `🗑️ ${t(`toolbar.delete-${type}`)}`; + deleteLink.href = '#'; + deleteLink.className = 'media-delete-link'; + + Object.assign(deleteLink.style, { + display: 'block', + marginTop: '4px', + fontSize: '12px', + color: '#d32f2f', + textDecoration: 'none', + cursor: 'pointer', + opacity: '0', + transition: 'opacity 0.2s' + }); + + deleteLink.onclick = e => { + e.preventDefault(); + e.stopPropagation(); + if (confirm(t(`toolbar.confirm-delete-${type}`))) { + onDelete(); + } + }; + + return deleteLink; + }; + + const insertMediaElement = ( + element: HTMLImageElement | HTMLVideoElement, + type: 'image' | 'video' + ) => { + if (!editorRef.current) return; + + editorRef.current.focus(); + restoreSelection(); + + const wrapper = document.createElement('div'); + wrapper.className = 'media-wrapper'; + Object.assign(wrapper.style, { + display: 'inline-block', + maxWidth: '100%', + margin: '16px 0', + padding: '8px', + border: '1px solid transparent', + borderRadius: '4px', + transition: 'border-color 0.2s' + }); + + Object.assign(element.style, { + maxWidth: '100%', + height: 'auto', + display: 'block', + borderRadius: '4px' + }); + + const deleteLink = createDeleteLink(type, () => { + wrapper.remove(); + onAnswerChange(editorRef.current?.innerHTML ?? ''); + }); + + // Show delete link on hover + wrapper.onmouseenter = () => { + wrapper.style.borderColor = '#e0e0e0'; + deleteLink.style.opacity = '1'; + }; + + wrapper.onmouseleave = () => { + wrapper.style.borderColor = 'transparent'; + deleteLink.style.opacity = '0'; + }; + + wrapper.appendChild(element); + wrapper.appendChild(deleteLink); + + const selection = window.getSelection(); + if (selection && selection.rangeCount > 0) { + const range = selection.getRangeAt(0); + range.deleteContents(); + range.insertNode(wrapper); + + const br = document.createElement('br'); + range.collapse(false); + range.insertNode(br); + + range.setStartAfter(br); + range.collapse(true); + selection.removeAllRanges(); + selection.addRange(range); + } else { + editorRef.current.appendChild(wrapper); + const br = document.createElement('br'); + editorRef.current.appendChild(br); + } + + onAnswerChange(editorRef.current.innerHTML); + }; + + const handleImageUpload = async (file: File) => { + if ( + !file.type.startsWith('image/') || + (!file.name.endsWith('.jpg') && !file.name.endsWith('.jpeg') && !file.name.endsWith('.png')) + ) { + setError(t('errors.invalid-image-type')); + return; + } + + if (file.size > 2 * 1024 * 1024) { + setError(t('errors.image-too-large')); + return; + } + + setIsUploadingImage(true); + setError(null); + + try { + const formData = new FormData(); + formData.append('image', file); + + const result = await apiFetch('/admin/faqs/upload-image', { + method: 'POST', + body: formData + }); + + if (result.ok) { + const imageUrl = (result.data as { url: string }).url; + const img = document.createElement('img'); + img.src = imageUrl; + insertMediaElement(img, 'image'); + } else { + setError(t('errors.image-upload-failed')); + } + } catch (err) { + console.error('Error uploading image:', err); + setError(t('errors.image-upload-failed')); + } finally { + setIsUploadingImage(false); + } + }; + + const handleVideoUpload = async (file: File) => { + if ( + !file.type.startsWith('video/') || + (!file.name.endsWith('.mp4') && !file.name.endsWith('.webm')) + ) { + setError(t('errors.invalid-video-type')); + return; + } + + if (file.size > 50 * 1024 * 1024) { + setError(t('errors.video-too-large')); + return; + } + + setIsUploadingVideo(true); + setError(null); + + try { + const formData = new FormData(); + formData.append('video', file); + + const result = await apiFetch('/admin/faqs/upload-video', { + method: 'POST', + body: formData + }); + + if (result.ok) { + const videoUrl = (result.data as { url: string }).url; + const video = document.createElement('video'); + video.src = videoUrl; + video.controls = true; + insertMediaElement(video, 'video'); + } else { + setError(t('errors.video-upload-failed')); + } + } catch (err) { + console.error('Error uploading video:', err); + setError(t('errors.video-upload-failed')); + } finally { + setIsUploadingVideo(false); + } + }; + + const handleMediaUpload = async (file: File) => { + if (file.type.startsWith('video/')) { + await handleVideoUpload(file); + } else { + await handleImageUpload(file); + } + }; + + return { + isUploadingImage, + isUploadingVideo, + error, + setError, + handleMediaUpload + }; +} From 8ec5931231025213820151dc39278622474ffad2 Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Mon, 19 Jan 2026 20:39:42 +0200 Subject: [PATCH 10/16] added by in admin --- apps/admin/locale/en.json | 1 + apps/admin/locale/he.json | 1 + .../app/[locale]/(dashboard)/faqs/page.tsx | 6 +- apps/backend/src/routers/admin/faqs.ts | 11 ++- libs/database/src/index.ts | 1 + .../src/migrations/028_add_faq_created_by.ts | 61 +++++++++++++++ libs/database/src/repositories/faqs.ts | 67 +++++++++++++--- libs/database/src/schema/tables/faqs.ts | 1 + .../src/scripts/fix-faq-created-by.ts | 77 +++++++++++++++++++ libs/types/src/lib/api/admin/faqs.ts | 4 + 10 files changed, 215 insertions(+), 15 deletions(-) create mode 100644 libs/database/src/migrations/028_add_faq_created_by.ts create mode 100644 libs/database/src/scripts/fix-faq-created-by.ts diff --git a/apps/admin/locale/en.json b/apps/admin/locale/en.json index fd3205582..a125c2b1e 100644 --- a/apps/admin/locale/en.json +++ b/apps/admin/locale/en.json @@ -1072,6 +1072,7 @@ "question": "Question", "answer": "Answer", "season": "Season", + "created-by": "Created By", "order": "Order", "actions": "Actions" }, diff --git a/apps/admin/locale/he.json b/apps/admin/locale/he.json index 78eba9fa0..143f0c36d 100644 --- a/apps/admin/locale/he.json +++ b/apps/admin/locale/he.json @@ -1072,6 +1072,7 @@ "question": "שאלה", "answer": "תשובה", "season": "עונה", + "created-by": "נוצר על ידי", "order": "סדר", "actions": "פעולות" }, diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx index 332f62fcd..7c20cedc7 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx @@ -138,6 +138,7 @@ export default function FaqsPage() { {t('table.question')} {t('table.answer')} {t('table.season')} + {t('table.created-by')} {t('table.order')} {t('table.actions')} @@ -145,7 +146,7 @@ export default function FaqsPage() { {faqs && faqs.length === 0 ? ( - + {t('empty-state')} @@ -167,6 +168,9 @@ export default function FaqsPage() { + + {faq.createdBy.name} + {faq.displayOrder} handleEdit(faq)} color="primary"> diff --git a/apps/backend/src/routers/admin/faqs.ts b/apps/backend/src/routers/admin/faqs.ts index cb9c7b2b3..ed558ceab 100644 --- a/apps/backend/src/routers/admin/faqs.ts +++ b/apps/backend/src/routers/admin/faqs.ts @@ -1,7 +1,7 @@ import express from 'express'; import fileUpload from 'express-fileupload'; import { CreateFaqRequestSchema, UpdateFaqRequestSchema } from '@lems/types/api/admin'; -import { Faq } from '@lems/database'; +import { FaqWithCreator } from '@lems/database'; import db from '../../lib/database'; import { uploadFile } from '../../lib/blob-storage/upload'; import { AdminRequest } from '../../types/express'; @@ -13,12 +13,16 @@ const IMAGE_SIZE_LIMIT = 2 * 1024 * 1024; // 2 MB const VIDEO_SIZE_LIMIT = 50 * 1024 * 1024; // 50 MB // Helper function to format FAQ response -const formatFaqResponse = (faq: Faq) => ({ +const formatFaqResponse = (faq: FaqWithCreator) => ({ id: faq.id, seasonId: faq.season_id, question: faq.question, answer: faq.answer, displayOrder: faq.display_order, + createdBy: { + id: faq.created_by, + name: `${faq.creator_first_name} ${faq.creator_last_name}` + }, createdAt: faq.created_at.toISOString(), updatedAt: faq.updated_at.toISOString() }); @@ -78,7 +82,8 @@ router.post('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) season_id: seasonId, question, answer, - display_order: order + display_order: order, + created_by: req.userId }); res.status(201).json(formatFaqResponse(faq)); diff --git a/libs/database/src/index.ts b/libs/database/src/index.ts index 3272b4fb2..faf257ea0 100644 --- a/libs/database/src/index.ts +++ b/libs/database/src/index.ts @@ -2,3 +2,4 @@ export { Database, type DatabaseRawAccess } from './database'; export { ObjectStorage } from './object-storage'; export * from './schema/index'; +export { FaqWithCreator } from './repositories/faqs'; diff --git a/libs/database/src/migrations/028_add_faq_created_by.ts b/libs/database/src/migrations/028_add_faq_created_by.ts new file mode 100644 index 000000000..6ee58f4c6 --- /dev/null +++ b/libs/database/src/migrations/028_add_faq_created_by.ts @@ -0,0 +1,61 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { Kysely } from 'kysely'; + +export async function up(db: Kysely): Promise { + // Add created_by column as nullable first + await db.schema + .alterTable('faqs') + .addColumn('created_by', 'uuid') + .execute(); + + const firstAdmin = await db + .selectFrom('admins') + .select('id') + .orderBy('created_at', 'asc') + .executeTakeFirst(); + + if (firstAdmin) { + // Update existing FAQs with the first admin as creator + await db + .updateTable('faqs') + .set({ created_by: firstAdmin.id }) + .where('created_by', 'is', null) + .execute(); + } + + // Now make the column NOT NULL + await db.schema + .alterTable('faqs') + .alterColumn('created_by', col => col.setNotNull()) + .execute(); + + // Add foreign key constraint to admins table + await db.schema + .alterTable('faqs') + .addForeignKeyConstraint('fk_faqs_created_by', ['created_by'], 'admins', ['id']) + .onDelete('restrict') + .execute(); + + // Create index + await db.schema + .createIndex('idx_faqs_created_by') + .on('faqs') + .column('created_by') + .execute(); +} + +export async function down(db: Kysely): Promise { + // Drop + await db.schema.dropIndex('idx_faqs_created_by').ifExists().execute(); + + await db.schema + .alterTable('faqs') + .dropConstraint('fk_faqs_created_by') + .ifExists() + .execute(); + + await db.schema + .alterTable('faqs') + .dropColumn('created_by') + .execute(); +} diff --git a/libs/database/src/repositories/faqs.ts b/libs/database/src/repositories/faqs.ts index 4aad6457c..3914680db 100644 --- a/libs/database/src/repositories/faqs.ts +++ b/libs/database/src/repositories/faqs.ts @@ -2,6 +2,11 @@ import { Kysely } from 'kysely'; import { KyselyDatabaseSchema } from '../schema/kysely'; import { InsertableFaq, Faq, UpdateableFaq } from '../schema/tables/faqs'; +export interface FaqWithCreator extends Faq { + creator_first_name: string; + creator_last_name: string; +} + class FaqSelector { constructor( private db: Kysely, @@ -9,21 +14,41 @@ class FaqSelector { ) {} private getFaqQuery() { - return this.db.selectFrom('faqs').selectAll().where('id', '=', this.faqId); + return this.db + .selectFrom('faqs') + .innerJoin('admins', 'faqs.created_by', 'admins.id') + .select([ + 'faqs.pk', + 'faqs.id', + 'faqs.season_id', + 'faqs.question', + 'faqs.answer', + 'faqs.display_order', + 'faqs.created_by', + 'faqs.created_at', + 'faqs.updated_at', + 'admins.first_name as creator_first_name', + 'admins.last_name as creator_last_name' + ]) + .where('faqs.id', '=', this.faqId); } - async get(): Promise { + async get(): Promise { const faq = await this.getFaqQuery().executeTakeFirst(); return faq || null; } - async update(updates: UpdateableFaq): Promise { - const [updatedFaq] = await this.db + async update(updates: UpdateableFaq): Promise { + await this.db .updateTable('faqs') .set({ ...updates, updated_at: new Date() }) .where('id', '=', this.faqId) - .returningAll() .execute(); + + const updatedFaq = await this.get(); + if (!updatedFaq) { + throw new Error('FAQ not found after update'); + } return updatedFaq; } @@ -39,18 +64,33 @@ class FaqsSelector { ) {} private getBaseQuery() { - let query = this.db.selectFrom('faqs').selectAll(); + let query = this.db + .selectFrom('faqs') + .innerJoin('admins', 'faqs.created_by', 'admins.id') + .select([ + 'faqs.pk', + 'faqs.id', + 'faqs.season_id', + 'faqs.question', + 'faqs.answer', + 'faqs.display_order', + 'faqs.created_by', + 'faqs.created_at', + 'faqs.updated_at', + 'admins.first_name as creator_first_name', + 'admins.last_name as creator_last_name' + ]); if (this.seasonId) { query = query.where('season_id', '=', this.seasonId); } return query; } - async getAll(): Promise { + async getAll(): Promise { return await this.getBaseQuery().orderBy('display_order', 'asc').execute(); } - async search(searchTerm: string): Promise { + async search(searchTerm: string): Promise { const term = `%${searchTerm}%`; return await this.getBaseQuery() .where(eb => @@ -79,13 +119,18 @@ export class FaqsRepository { return new FaqsSelector(this.db); } - async create(faq: InsertableFaq): Promise { + async create(faq: InsertableFaq): Promise { const [createdFaq] = await this.db .insertInto('faqs') .values(faq) - .returningAll() + .returning('id') .execute(); - return createdFaq; + + const faqWithCreator = await this.byId(createdFaq.id).get(); + if (!faqWithCreator) { + throw new Error('FAQ not found after creation'); + } + return faqWithCreator; } async getMaxDisplayOrder(seasonId: string): Promise { diff --git a/libs/database/src/schema/tables/faqs.ts b/libs/database/src/schema/tables/faqs.ts index 9be300d55..40583b2de 100644 --- a/libs/database/src/schema/tables/faqs.ts +++ b/libs/database/src/schema/tables/faqs.ts @@ -7,6 +7,7 @@ export interface FaqsTable { question: string; answer: string; display_order: number; + created_by: string; // UUID foreign key to admins.id created_at: ColumnType; // Generated on insert updated_at: ColumnType; // Updated on modification } diff --git a/libs/database/src/scripts/fix-faq-created-by.ts b/libs/database/src/scripts/fix-faq-created-by.ts new file mode 100644 index 000000000..f262a3466 --- /dev/null +++ b/libs/database/src/scripts/fix-faq-created-by.ts @@ -0,0 +1,77 @@ +import { Pool } from 'pg'; +import { Kysely, PostgresDialect, sql } from 'kysely'; +import { KyselyDatabaseSchema } from '../schema/kysely'; + +const PG_HOST = process.env.PG_HOST || 'localhost'; +const PG_PORT = parseInt(process.env.PG_PORT || '5432'); +const PG_USER = process.env.PG_USER || 'postgres'; +const PG_PASSWORD = process.env.PG_PASSWORD || 'postgres'; +const PG_DATABASE = process.env.PG_DATABASE || 'lems-local'; + +async function fixFaqCreatedBy() { + const pool = new Pool({ + host: PG_HOST, + port: PG_PORT, + user: PG_USER, + password: PG_PASSWORD, + database: PG_DATABASE + }); + + const db = new Kysely({ + dialect: new PostgresDialect({ pool }) + }); + + try { + + // Check if column exists + const columnCheck = await sql<{ column_name: string }>` + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'faqs' AND column_name = 'created_by' + `.execute(db); + + if (columnCheck.rows.length > 0) { + await db.destroy(); + return; + } + + + // Add column as nullable + await sql`ALTER TABLE faqs ADD COLUMN created_by uuid`.execute(db); + + // Get first admin + const firstAdmin = await db + .selectFrom('admins') + .select('id') + .orderBy('created_at', 'asc') + .executeTakeFirst(); + + if (firstAdmin) { + + // Update existing FAQs + await db + .updateTable('faqs') + .set({ created_by: firstAdmin.id }) + .where('created_by', 'is', null) + .execute(); + } + + // Make column NOT NULL + await sql`ALTER TABLE faqs ALTER COLUMN created_by SET NOT NULL`.execute(db); + + // Add foreign key + await sql` + ALTER TABLE faqs + ADD CONSTRAINT fk_faqs_created_by + FOREIGN KEY (created_by) REFERENCES admins(id) + ON DELETE RESTRICT + `.execute(db); + + // Add index + await sql`CREATE INDEX idx_faqs_created_by ON faqs(created_by)`.execute(db); + } finally { + await db.destroy(); + } +} + +fixFaqCreatedBy(); diff --git a/libs/types/src/lib/api/admin/faqs.ts b/libs/types/src/lib/api/admin/faqs.ts index b0190603b..7611e835f 100644 --- a/libs/types/src/lib/api/admin/faqs.ts +++ b/libs/types/src/lib/api/admin/faqs.ts @@ -6,6 +6,10 @@ export const FaqResponseSchema = z.object({ question: z.string(), answer: z.string(), displayOrder: z.number(), + createdBy: z.object({ + id: z.string(), + name: z.string() + }), createdAt: z.string(), updatedAt: z.string() }); From 0edd34ae814dbd52ea4fd5f1b378b0c678afe1c6 Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Mon, 19 Jan 2026 20:44:48 +0200 Subject: [PATCH 11/16] clean --- .../faqs/components/faq-editor-dialog-old.tsx | 687 ------------------ .../faqs/components/faq-editor-dialog.tsx | 1 - .../faqs/components/use-media-upload.tsx | 3 +- apps/backend/src/routers/admin/faqs.ts | 2 +- apps/backend/src/routers/portal/faqs.ts | 8 +- libs/database/src/repositories/faqs.ts | 42 +- .../src/scripts/fix-faq-created-by.ts | 77 -- 7 files changed, 22 insertions(+), 798 deletions(-) delete mode 100644 apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog-old.tsx delete mode 100644 libs/database/src/scripts/fix-faq-created-by.ts diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog-old.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog-old.tsx deleted file mode 100644 index 759b085ab..000000000 --- a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog-old.tsx +++ /dev/null @@ -1,687 +0,0 @@ -'use client'; - -import { useState, useEffect, useRef } from 'react'; -import { - Dialog, - DialogTitle, - DialogContent, - DialogActions, - Button, - TextField, - IconButton, - FormControl, - InputLabel, - Select, - MenuItem, - Alert, - CircularProgress, - Box, - Paper, - Typography, - Tooltip -} from '@mui/material'; -import { - FormatBold, - FormatItalic, - Palette, - FormatListBulleted, - Image as ImageIcon -} from '@mui/icons-material'; -import { useTranslations } from 'next-intl'; -import { mutate } from 'swr'; -import { FaqResponse } from '@lems/types/api/admin'; -import { Season } from '@lems/database'; -import { apiFetch, ColorPicker } from '@lems/shared'; -import { HsvaColor, hexToHsva, hsvaToHex } from '@uiw/react-color'; - -interface FaqEditorDialogProps { - open: boolean; - faq: FaqResponse | null; - seasons: Season[]; - onClose: () => void; -} - -export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialogProps) { - const t = useTranslations('pages.faqs.editor'); - const [seasonId, setSeasonId] = useState(faq?.seasonId || ''); - const [question, setQuestion] = useState(faq?.question || ''); - const [answer, setAnswer] = useState(faq?.answer || ''); - const [displayOrder, setDisplayOrder] = useState(faq?.displayOrder?.toString() || ''); - const [isSubmitting, setIsSubmitting] = useState(false); - const [error, setError] = useState(null); - const [textColor, setTextColor] = useState(hexToHsva('#000000')); - const [usedColors, setUsedColors] = useState(['#000000']); - const [isUploadingImage, setIsUploadingImage] = useState(false); - const [isUploadingVideo, setIsUploadingVideo] = useState(false); - const editorRef = useRef(null); - const selectionRef = useRef(null); - const fileInputRef = useRef(null); - - useEffect(() => { - if (faq) { - setSeasonId(faq.seasonId); - setQuestion(faq.question); - setAnswer(faq.answer); - setDisplayOrder(faq.displayOrder.toString()); - } else { - setSeasonId(seasons[0]?.id || ''); - setQuestion(''); - setAnswer(''); - setDisplayOrder(''); - } - setError(null); - }, [faq, seasons]); - - useEffect(() => { - if (editorRef.current && editorRef.current.innerHTML !== answer) { - editorRef.current.innerHTML = answer; - } - }, [answer]); - - useEffect(() => { - const colors = extractColors(answer); - setUsedColors(colors.length > 0 ? colors : ['#000000']); - }, [answer]); - - const saveSelection = () => { - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - selectionRef.current = selection.getRangeAt(0); - } - }; - - const restoreSelection = () => { - const selection = window.getSelection(); - if (selectionRef.current && selection) { - selection.removeAllRanges(); - selection.addRange(selectionRef.current); - } - }; - - const applyCommand = (command: string, value?: string) => { - if (!editorRef.current) return; - editorRef.current.focus(); - restoreSelection(); - document.execCommand(command, false, value); - setAnswer(editorRef.current.innerHTML); - }; - - const currentColor = hsvaToHex(textColor); - const applyColor = (color: string) => { - setTextColor(hexToHsva(color)); - applyCommand('foreColor', color); - }; - - const extractColors = (html: string) => { - const colors = new Set(); - const colorRegexes = [/color\s*=\s*"?([#a-fA-F0-9]{3,7})"?/g, /color\s*:\s*([^;"']+)/g]; - - colorRegexes.forEach(regex => { - let match = regex.exec(html); - while (match) { - const color = match[1].trim(); - if (color.startsWith('#')) { - colors.add(color.toLowerCase()); - } - match = regex.exec(html); - } - }); - - return Array.from(colors); - }; - - const handleVideoUpload = async (file: File) => { - if ( - !file.type.startsWith('video/') || - (!file.name.endsWith('.mp4') && !file.name.endsWith('.webm')) - ) { - setError(t('errors.invalid-video-type')); - return; - } - - if (file.size > 50 * 1024 * 1024) { - setError(t('errors.video-too-large')); - return; - } - - setIsUploadingVideo(true); - setError(null); - - try { - const formData = new FormData(); - formData.append('video', file); - - const result = await apiFetch('/admin/faqs/upload-video', { - method: 'POST', - body: formData - }); - - if (result.ok) { - const videoUrl = (result.data as { url: string }).url; - - if (editorRef.current) { - editorRef.current.focus(); - restoreSelection(); - - const wrapper = document.createElement('div'); - wrapper.style.position = 'relative'; - wrapper.style.display = 'inline-block'; - wrapper.style.maxWidth = '100%'; - wrapper.style.margin = '10px 0'; - - const video = document.createElement('video'); - video.src = videoUrl; - video.controls = true; - video.style.maxWidth = '100%'; - video.style.height = 'auto'; - video.style.display = 'block'; - - const deleteBtn = document.createElement('button'); - deleteBtn.innerHTML = '✕'; - deleteBtn.title = t('toolbar.delete-video'); - deleteBtn.style.position = 'absolute'; - deleteBtn.style.top = '5px'; - deleteBtn.style.right = '5px'; - deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; - deleteBtn.style.color = 'white'; - deleteBtn.style.border = 'none'; - deleteBtn.style.borderRadius = '50%'; - deleteBtn.style.width = '28px'; - deleteBtn.style.height = '28px'; - deleteBtn.style.cursor = 'pointer'; - deleteBtn.style.fontSize = '16px'; - deleteBtn.style.fontWeight = 'bold'; - deleteBtn.style.display = 'flex'; - deleteBtn.style.alignItems = 'center'; - deleteBtn.style.justifyContent = 'center'; - deleteBtn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)'; - deleteBtn.style.transition = 'all 0.2s'; - deleteBtn.style.zIndex = '10'; - - deleteBtn.onmouseover = () => { - deleteBtn.style.backgroundColor = 'rgba(211, 47, 47, 1)'; - deleteBtn.style.transform = 'scale(1.1)'; - }; - - deleteBtn.onmouseout = () => { - deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; - deleteBtn.style.transform = 'scale(1)'; - }; - - deleteBtn.onclick = e => { - e.preventDefault(); - e.stopPropagation(); - if (confirm(t('toolbar.confirm-delete-video'))) { - wrapper.remove(); - setAnswer(editorRef.current?.innerHTML ?? ''); - } - }; - - wrapper.appendChild(video); - wrapper.appendChild(deleteBtn); - - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - range.deleteContents(); - range.insertNode(wrapper); - - const br = document.createElement('br'); - range.collapse(false); - range.insertNode(br); - - range.setStartAfter(br); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - } else { - editorRef.current.appendChild(wrapper); - const br = document.createElement('br'); - editorRef.current.appendChild(br); - } - - setAnswer(editorRef.current.innerHTML); - } - } else { - setError(t('errors.video-upload-failed')); - } - } catch (err) { - console.error('Error uploading video:', err); - setError(t('errors.video-upload-failed')); - } finally { - setIsUploadingVideo(false); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - } - }; - - const handleImageUpload = async (file: File) => { - if ( - !file.type.startsWith('image/') || - (!file.name.endsWith('.jpg') && !file.name.endsWith('.jpeg') && !file.name.endsWith('.png')) - ) { - setError(t('errors.invalid-image-type')); - return; - } - - if (file.size > 2 * 1024 * 1024) { - setError(t('errors.image-too-large')); - return; - } - - setIsUploadingImage(true); - setError(null); - - try { - const formData = new FormData(); - formData.append('image', file); - - const result = await apiFetch('/admin/faqs/upload-image', { - method: 'POST', - body: formData - }); - - if (result.ok) { - const imageUrl = (result.data as { url: string }).url; - - if (editorRef.current) { - editorRef.current.focus(); - restoreSelection(); - - const wrapper = document.createElement('div'); - wrapper.style.position = 'relative'; - wrapper.style.display = 'inline-block'; - wrapper.style.maxWidth = '100%'; - wrapper.style.margin = '10px 0'; - - const img = document.createElement('img'); - img.src = imageUrl; - img.style.maxWidth = '100%'; - img.style.height = 'auto'; - img.style.display = 'block'; - - const deleteBtn = document.createElement('button'); - deleteBtn.innerHTML = '✕'; - deleteBtn.title = t('toolbar.delete-image'); - deleteBtn.style.position = 'absolute'; - deleteBtn.style.top = '5px'; - deleteBtn.style.right = '5px'; - deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; - deleteBtn.style.color = 'white'; - deleteBtn.style.border = 'none'; - deleteBtn.style.borderRadius = '50%'; - deleteBtn.style.width = '28px'; - deleteBtn.style.height = '28px'; - deleteBtn.style.cursor = 'pointer'; - deleteBtn.style.fontSize = '16px'; - deleteBtn.style.fontWeight = 'bold'; - deleteBtn.style.display = 'flex'; - deleteBtn.style.alignItems = 'center'; - deleteBtn.style.justifyContent = 'center'; - deleteBtn.style.boxShadow = '0 2px 4px rgba(0,0,0,0.2)'; - deleteBtn.style.transition = 'all 0.2s'; - deleteBtn.style.zIndex = '10'; - - deleteBtn.onmouseover = () => { - deleteBtn.style.backgroundColor = 'rgba(211, 47, 47, 1)'; - deleteBtn.style.transform = 'scale(1.1)'; - }; - - deleteBtn.onmouseout = () => { - deleteBtn.style.backgroundColor = 'rgba(244, 67, 54, 0.9)'; - deleteBtn.style.transform = 'scale(1)'; - }; - - deleteBtn.onclick = e => { - e.preventDefault(); - e.stopPropagation(); - if (confirm(t('toolbar.confirm-delete-image'))) { - wrapper.remove(); - setAnswer(editorRef.current?.innerHTML ?? ''); - } - }; - - wrapper.appendChild(img); - wrapper.appendChild(deleteBtn); - - const selection = window.getSelection(); - if (selection && selection.rangeCount > 0) { - const range = selection.getRangeAt(0); - range.deleteContents(); - range.insertNode(wrapper); - - const br = document.createElement('br'); - range.collapse(false); - range.insertNode(br); - - range.setStartAfter(br); - range.collapse(true); - selection.removeAllRanges(); - selection.addRange(range); - } else { - editorRef.current.appendChild(wrapper); - const br = document.createElement('br'); - editorRef.current.appendChild(br); - } - - setAnswer(editorRef.current.innerHTML); - } - } else { - setError(t('errors.image-upload-failed')); - } - } catch (err) { - console.error('Error uploading image:', err); - setError(t('errors.image-upload-failed')); - } finally { - setIsUploadingImage(false); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - } - }; - - const handleMediaUpload = async (event: React.ChangeEvent) => { - const file = event.target.files?.[0]; - if (!file) return; - - if (file.type.startsWith('video/')) { - await handleVideoUpload(file); - } else { - await handleImageUpload(file); - } - }; - - const sanitizeFaqHtml = (html: string): string => { - const tempDiv = document.createElement('div'); - tempDiv.innerHTML = html; - - // Find all wrapper divs with delete buttons - const wrappers = tempDiv.querySelectorAll('div[style*="position: relative"]'); - wrappers.forEach(wrapper => { - // Remove delete button - const deleteBtn = wrapper.querySelector('button'); - if (deleteBtn) { - deleteBtn.remove(); - } - - // Extract the actual media element (img or video) - const media = wrapper.querySelector('img, video'); - if (media && wrapper.parentNode) { - // Replace wrapper with just the media element - wrapper.parentNode.replaceChild(media, wrapper); - } - }); - - return tempDiv.innerHTML; - }; - - const handleSubmit = async () => { - if (!seasonId || !question.trim() || !answer.trim()) { - setError(t('errors.required-fields')); - return; - } - - setIsSubmitting(true); - setError(null); - - try { - const body: { - question: string; - answer: string; - seasonId?: string; - displayOrder?: number; - } = { - question: question.trim(), - answer: sanitizeFaqHtml(answer.trim()) - }; - - if (!faq) { - body.seasonId = seasonId; - } - - if (displayOrder) { - body.displayOrder = parseInt(displayOrder); - } - - const url = faq ? `/admin/faqs/${faq.id}` : '/admin/faqs'; - const method = faq ? 'PUT' : 'POST'; - - const result = await apiFetch(url, { - method, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body) - }); - - if (!result.ok) { - throw new Error('Failed to save FAQ'); - } - - // Refresh FAQ lists - await Promise.all([ - mutate('/admin/faqs'), - mutate(key => typeof key === 'string' && key.startsWith('/admin/faqs/season/')) - ]); - - onClose(); - } catch (err) { - setError(err instanceof Error ? err.message : t('errors.save-failed')); - } finally { - setIsSubmitting(false); - } - }; - - return ( - - {faq ? t('title-edit') : t('title-create')} - - - {error && {error}} - - - {t('fields.season')} - - - - setQuestion(e.target.value)} - fullWidth - required - multiline - rows={2} - disabled={isSubmitting} - /> - - - - {t('fields.answer')} - - - - - applyCommand('bold')} - disabled={isSubmitting} - sx={{ - '&:hover': { - backgroundColor: 'action.hover' - } - }} - > - - - - - - - applyCommand('italic')} - disabled={isSubmitting} - sx={{ - '&:hover': { - backgroundColor: 'action.hover' - } - }} - > - - - - - - - applyCommand('insertUnorderedList')} - disabled={isSubmitting} - sx={{ - '&:hover': { - backgroundColor: 'action.hover' - } - }} - > - - - - - - - fileInputRef.current?.click()} - disabled={isSubmitting || isUploadingImage || isUploadingVideo} - sx={{ - '&:hover': { - backgroundColor: 'action.hover' - } - }} - > - - - - - - applyColor(hsvaToHex(color))}> - applyCommand('foreColor', currentColor)} - disabled={isSubmitting} - sx={{ - border: '1px solid', - borderColor: 'divider', - '&:hover': { - backgroundColor: 'action.hover' - } - }} - > - - - - - {usedColors - .filter(color => color.toLowerCase() !== currentColor.toLowerCase()) - .map(color => ( - - applyColor(color)} - sx={{ - width: 18, - height: 18, - borderRadius: '50%', - bgcolor: color, - cursor: isSubmitting ? 'default' : 'pointer', - border: '1px solid', - borderColor: 'divider' - }} - /> - - ))} - - - `0 0 0 2px ${theme.palette.primary.main}` - }} - /> - - {currentColor} - - - - - setAnswer(editorRef.current?.innerHTML ?? '')} - onBlur={() => setAnswer(editorRef.current?.innerHTML ?? '')} - onMouseUp={saveSelection} - onKeyUp={saveSelection} - sx={{ - outline: 'none', - minHeight: 140, - '&:empty:before': { - content: '""' - } - }} - /> - - - - setDisplayOrder(e.target.value)} - type="number" - fullWidth - helperText={t('fields.display-order-help')} - disabled={isSubmitting} - /> - - - - - - - - ); -} diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx index 7a0326706..355d4ca35 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/faq-editor-dialog.tsx @@ -141,7 +141,6 @@ export function FaqEditorDialog({ open, faq, seasons, onClose }: FaqEditorDialog { editorRef, onAnswerChange: setAnswer, - saveSelection, restoreSelection } ); diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/use-media-upload.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/use-media-upload.tsx index 753da7aa5..a4a872f83 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/faqs/components/use-media-upload.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/components/use-media-upload.tsx @@ -3,9 +3,8 @@ import { apiFetch } from '@lems/shared'; import { useTranslations } from 'next-intl'; interface UseMediaUploadProps { - editorRef: React.RefObject; + editorRef: React.RefObject; onAnswerChange: (html: string) => void; - saveSelection: () => void; restoreSelection: () => void; } diff --git a/apps/backend/src/routers/admin/faqs.ts b/apps/backend/src/routers/admin/faqs.ts index ed558ceab..f9c491232 100644 --- a/apps/backend/src/routers/admin/faqs.ts +++ b/apps/backend/src/routers/admin/faqs.ts @@ -109,7 +109,7 @@ router.put('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, re return; } - const updates: Partial<{ question: string; answer: string; display_order: number }> = {}; + const updates: Record = {}; if (validation.data.question !== undefined) updates.question = validation.data.question; if (validation.data.answer !== undefined) updates.answer = validation.data.answer; if (validation.data.displayOrder !== undefined) updates.display_order = validation.data.displayOrder; diff --git a/apps/backend/src/routers/portal/faqs.ts b/apps/backend/src/routers/portal/faqs.ts index ed644082a..00b6f16aa 100644 --- a/apps/backend/src/routers/portal/faqs.ts +++ b/apps/backend/src/routers/portal/faqs.ts @@ -1,11 +1,11 @@ import express from 'express'; -import { Faq } from '@lems/database'; +import { FaqWithCreator } from '@lems/database'; import db from '../../lib/database'; const router = express.Router(); -// Helper function to format FAQ response for portal (excludes timestamps and seasonId) -const formatPortalFaqResponse = (faq: Faq) => ({ +// Format FAQ response for portal - excludes creator info, timestamps, and seasonId for public consumption +const formatPortalFaqResponse = (faq: FaqWithCreator) => ({ id: faq.id, question: faq.question, answer: faq.answer, @@ -45,7 +45,7 @@ router.get('/search', async (req, res) => { return; } - let faqs: Faq[]; + let faqs: FaqWithCreator[]; if (seasonId && typeof seasonId === 'string') { faqs = await db.faqs.bySeason(seasonId).search(q); } else { diff --git a/libs/database/src/repositories/faqs.ts b/libs/database/src/repositories/faqs.ts index 3914680db..41988ac25 100644 --- a/libs/database/src/repositories/faqs.ts +++ b/libs/database/src/repositories/faqs.ts @@ -7,6 +7,20 @@ export interface FaqWithCreator extends Faq { creator_last_name: string; } +const FAQ_FIELDS = [ + 'faqs.pk', + 'faqs.id', + 'faqs.season_id', + 'faqs.question', + 'faqs.answer', + 'faqs.display_order', + 'faqs.created_by', + 'faqs.created_at', + 'faqs.updated_at', + 'admins.first_name as creator_first_name', + 'admins.last_name as creator_last_name' +] as const; + class FaqSelector { constructor( private db: Kysely, @@ -17,19 +31,7 @@ class FaqSelector { return this.db .selectFrom('faqs') .innerJoin('admins', 'faqs.created_by', 'admins.id') - .select([ - 'faqs.pk', - 'faqs.id', - 'faqs.season_id', - 'faqs.question', - 'faqs.answer', - 'faqs.display_order', - 'faqs.created_by', - 'faqs.created_at', - 'faqs.updated_at', - 'admins.first_name as creator_first_name', - 'admins.last_name as creator_last_name' - ]) + .select(FAQ_FIELDS) .where('faqs.id', '=', this.faqId); } @@ -67,19 +69,7 @@ class FaqsSelector { let query = this.db .selectFrom('faqs') .innerJoin('admins', 'faqs.created_by', 'admins.id') - .select([ - 'faqs.pk', - 'faqs.id', - 'faqs.season_id', - 'faqs.question', - 'faqs.answer', - 'faqs.display_order', - 'faqs.created_by', - 'faqs.created_at', - 'faqs.updated_at', - 'admins.first_name as creator_first_name', - 'admins.last_name as creator_last_name' - ]); + .select(FAQ_FIELDS); if (this.seasonId) { query = query.where('season_id', '=', this.seasonId); } diff --git a/libs/database/src/scripts/fix-faq-created-by.ts b/libs/database/src/scripts/fix-faq-created-by.ts deleted file mode 100644 index f262a3466..000000000 --- a/libs/database/src/scripts/fix-faq-created-by.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { Pool } from 'pg'; -import { Kysely, PostgresDialect, sql } from 'kysely'; -import { KyselyDatabaseSchema } from '../schema/kysely'; - -const PG_HOST = process.env.PG_HOST || 'localhost'; -const PG_PORT = parseInt(process.env.PG_PORT || '5432'); -const PG_USER = process.env.PG_USER || 'postgres'; -const PG_PASSWORD = process.env.PG_PASSWORD || 'postgres'; -const PG_DATABASE = process.env.PG_DATABASE || 'lems-local'; - -async function fixFaqCreatedBy() { - const pool = new Pool({ - host: PG_HOST, - port: PG_PORT, - user: PG_USER, - password: PG_PASSWORD, - database: PG_DATABASE - }); - - const db = new Kysely({ - dialect: new PostgresDialect({ pool }) - }); - - try { - - // Check if column exists - const columnCheck = await sql<{ column_name: string }>` - SELECT column_name - FROM information_schema.columns - WHERE table_name = 'faqs' AND column_name = 'created_by' - `.execute(db); - - if (columnCheck.rows.length > 0) { - await db.destroy(); - return; - } - - - // Add column as nullable - await sql`ALTER TABLE faqs ADD COLUMN created_by uuid`.execute(db); - - // Get first admin - const firstAdmin = await db - .selectFrom('admins') - .select('id') - .orderBy('created_at', 'asc') - .executeTakeFirst(); - - if (firstAdmin) { - - // Update existing FAQs - await db - .updateTable('faqs') - .set({ created_by: firstAdmin.id }) - .where('created_by', 'is', null) - .execute(); - } - - // Make column NOT NULL - await sql`ALTER TABLE faqs ALTER COLUMN created_by SET NOT NULL`.execute(db); - - // Add foreign key - await sql` - ALTER TABLE faqs - ADD CONSTRAINT fk_faqs_created_by - FOREIGN KEY (created_by) REFERENCES admins(id) - ON DELETE RESTRICT - `.execute(db); - - // Add index - await sql`CREATE INDEX idx_faqs_created_by ON faqs(created_by)`.execute(db); - } finally { - await db.destroy(); - } -} - -fixFaqCreatedBy(); From f4eea6bec6cf8258e6dcdc065c32dfd55cd41993 Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Mon, 19 Jan 2026 20:48:02 +0200 Subject: [PATCH 12/16] clean a bit --- apps/backend/src/routers/admin/faqs.ts | 26 +++++++++---------- apps/backend/src/routers/portal/faqs.ts | 15 ++++++----- .../src/migrations/028_add_faq_created_by.ts | 4 +-- 3 files changed, 24 insertions(+), 21 deletions(-) diff --git a/apps/backend/src/routers/admin/faqs.ts b/apps/backend/src/routers/admin/faqs.ts index f9c491232..bec7dd865 100644 --- a/apps/backend/src/routers/admin/faqs.ts +++ b/apps/backend/src/routers/admin/faqs.ts @@ -12,7 +12,13 @@ const router = express.Router(); const IMAGE_SIZE_LIMIT = 2 * 1024 * 1024; // 2 MB const VIDEO_SIZE_LIMIT = 50 * 1024 * 1024; // 50 MB -// Helper function to format FAQ response +//to handle errors consistently +const handleError = (res: express.Response, error: unknown, context: string) => { + console.error(`Error ${context}:`, error); + res.status(500).json({ error: 'Internal server error' }); +}; + +//to format FAQ response const formatFaqResponse = (faq: FaqWithCreator) => ({ id: faq.id, seasonId: faq.season_id, @@ -32,8 +38,7 @@ router.get('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) const faqs = await db.faqs.all().getAll(); res.json(faqs.map(formatFaqResponse)); } catch (error) { - console.error('Error fetching FAQs:', error); - res.status(500).json({ error: 'Internal server error' }); + handleError(res, error, 'fetching FAQs'); } }); @@ -43,8 +48,7 @@ router.get('/season/:seasonId', requirePermission('MANAGE_FAQ'), async (req: Adm const faqs = await db.faqs.bySeason(seasonId).getAll(); res.json(faqs.map(formatFaqResponse)); } catch (error) { - console.error('Error fetching FAQs by season:', error); - res.status(500).json({ error: 'Internal server error' }); + handleError(res, error, 'fetching FAQs by season'); } }); @@ -60,8 +64,7 @@ router.get('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, re res.json(formatFaqResponse(faq)); } catch (error) { - console.error('Error fetching FAQ:', error); - res.status(500).json({ error: 'Internal server error' }); + handleError(res, error, 'fetching FAQ'); } }); @@ -88,8 +91,7 @@ router.post('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) res.status(201).json(formatFaqResponse(faq)); } catch (error) { - console.error('Error creating FAQ:', error); - res.status(500).json({ error: 'Internal server error' }); + handleError(res, error, 'creating FAQ'); } }); @@ -117,8 +119,7 @@ router.put('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, re const updatedFaq = await db.faqs.byId(id).update(updates); res.json(formatFaqResponse(updatedFaq)); } catch (error) { - console.error('Error updating FAQ:', error); - res.status(500).json({ error: 'Internal server error' }); + handleError(res, error, 'updating FAQ'); } }); @@ -135,8 +136,7 @@ router.delete('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, await db.faqs.byId(id).delete(); res.status(204).send(); } catch (error) { - console.error('Error deleting FAQ:', error); - res.status(500).json({ error: 'Internal server error' }); + handleError(res, error, 'deleting FAQ'); } }); diff --git a/apps/backend/src/routers/portal/faqs.ts b/apps/backend/src/routers/portal/faqs.ts index 00b6f16aa..2fc22a00e 100644 --- a/apps/backend/src/routers/portal/faqs.ts +++ b/apps/backend/src/routers/portal/faqs.ts @@ -4,6 +4,12 @@ import db from '../../lib/database'; const router = express.Router(); +// Helper function to handle errors consistently +const handleError = (res: express.Response, error: unknown, context: string) => { + console.error(`Error ${context}:`, error); + res.status(500).json({ error: 'Internal server error' }); +}; + // Format FAQ response for portal - excludes creator info, timestamps, and seasonId for public consumption const formatPortalFaqResponse = (faq: FaqWithCreator) => ({ id: faq.id, @@ -18,8 +24,7 @@ router.get('/', async (req, res) => { const faqs = await db.faqs.all().getAll(); res.json(faqs.map(formatPortalFaqResponse)); } catch (error) { - console.error('Error fetching FAQs:', error); - res.status(500).json({ error: 'Internal server error' }); + handleError(res, error, 'fetching FAQs'); } }); @@ -30,8 +35,7 @@ router.get('/season/:seasonId', async (req, res) => { const faqs = await db.faqs.bySeason(seasonId).getAll(); res.json(faqs.map(formatPortalFaqResponse)); } catch (error) { - console.error('Error fetching FAQs by season:', error); - res.status(500).json({ error: 'Internal server error' }); + handleError(res, error, 'fetching FAQs by season'); } }); @@ -54,8 +58,7 @@ router.get('/search', async (req, res) => { res.json(faqs.map(formatPortalFaqResponse)); } catch (error) { - console.error('Error searching FAQs:', error); - res.status(500).json({ error: 'Internal server error' }); + handleError(res, error, 'searching FAQs'); } }); diff --git a/libs/database/src/migrations/028_add_faq_created_by.ts b/libs/database/src/migrations/028_add_faq_created_by.ts index 6ee58f4c6..6b2d2378f 100644 --- a/libs/database/src/migrations/028_add_faq_created_by.ts +++ b/libs/database/src/migrations/028_add_faq_created_by.ts @@ -36,7 +36,7 @@ export async function up(db: Kysely): Promise { .onDelete('restrict') .execute(); - // Create index + // Create index for created_by lookups await db.schema .createIndex('idx_faqs_created_by') .on('faqs') @@ -45,7 +45,7 @@ export async function up(db: Kysely): Promise { } export async function down(db: Kysely): Promise { - // Drop + // Drop index and constraints await db.schema.dropIndex('idx_faqs_created_by').ifExists().execute(); await db.schema From 9c8efa36a4d1bbdfc86f7cd8101c5727a01bd81a Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Mon, 19 Jan 2026 21:03:24 +0200 Subject: [PATCH 13/16] fix build --- libs/database/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/database/src/index.ts b/libs/database/src/index.ts index faf257ea0..a808fda9b 100644 --- a/libs/database/src/index.ts +++ b/libs/database/src/index.ts @@ -2,4 +2,4 @@ export { Database, type DatabaseRawAccess } from './database'; export { ObjectStorage } from './object-storage'; export * from './schema/index'; -export { FaqWithCreator } from './repositories/faqs'; +export type { FaqWithCreator } from './repositories/faqs'; From a19c90ba7d7281bd6d1394095b003ef95df07c78 Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Mon, 19 Jan 2026 21:45:13 +0200 Subject: [PATCH 14/16] sql turned to mongo --- apps/backend/src/routers/admin/faqs.ts | 39 +++-- apps/backend/src/routers/portal/faqs.ts | 10 +- libs/database/src/database.ts | 4 +- libs/database/src/index.ts | 1 - libs/database/src/repositories/faqs-mongo.ts | 147 +++++++++++++++++++ libs/database/src/repositories/faqs.ts | 134 ----------------- libs/database/src/schema/documents/faq.ts | 16 ++ libs/database/src/schema/index.ts | 2 +- libs/database/src/schema/kysely.ts | 2 - libs/database/src/schema/tables/faqs.ts | 17 --- 10 files changed, 196 insertions(+), 176 deletions(-) create mode 100644 libs/database/src/repositories/faqs-mongo.ts delete mode 100644 libs/database/src/repositories/faqs.ts create mode 100644 libs/database/src/schema/documents/faq.ts delete mode 100644 libs/database/src/schema/tables/faqs.ts diff --git a/apps/backend/src/routers/admin/faqs.ts b/apps/backend/src/routers/admin/faqs.ts index bec7dd865..df5f5050c 100644 --- a/apps/backend/src/routers/admin/faqs.ts +++ b/apps/backend/src/routers/admin/faqs.ts @@ -1,7 +1,7 @@ import express from 'express'; import fileUpload from 'express-fileupload'; import { CreateFaqRequestSchema, UpdateFaqRequestSchema } from '@lems/types/api/admin'; -import { FaqWithCreator } from '@lems/database'; +import { Faq } from '@lems/database'; import db from '../../lib/database'; import { uploadFile } from '../../lib/blob-storage/upload'; import { AdminRequest } from '../../types/express'; @@ -19,18 +19,18 @@ const handleError = (res: express.Response, error: unknown, context: string) => }; //to format FAQ response -const formatFaqResponse = (faq: FaqWithCreator) => ({ - id: faq.id, - seasonId: faq.season_id, +const formatFaqResponse = (faq: Faq) => ({ + id: faq._id?.toString() || '', + seasonId: faq.seasonId, question: faq.question, answer: faq.answer, - displayOrder: faq.display_order, + displayOrder: faq.displayOrder, createdBy: { - id: faq.created_by, - name: `${faq.creator_first_name} ${faq.creator_last_name}` + id: faq.createdBy.id, + name: `${faq.createdBy.firstName} ${faq.createdBy.lastName}` }, - createdAt: faq.created_at.toISOString(), - updatedAt: faq.updated_at.toISOString() + createdAt: faq.createdAt.toISOString(), + updatedAt: faq.updatedAt.toISOString() }); router.get('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { @@ -81,12 +81,23 @@ router.post('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) const order = displayOrder ?? (await db.faqs.getMaxDisplayOrder(seasonId)) + 1; + // Get admin info for creator + const admin = await db.admins.byId(req.userId).get(); + if (!admin) { + res.status(401).json({ error: 'Admin not found' }); + return; + } + const faq = await db.faqs.create({ - season_id: seasonId, + seasonId, question, answer, - display_order: order, - created_by: req.userId + displayOrder: order, + createdBy: { + id: req.userId, + firstName: admin.first_name, + lastName: admin.last_name + } }); res.status(201).json(formatFaqResponse(faq)); @@ -111,10 +122,10 @@ router.put('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, re return; } - const updates: Record = {}; + const updates: { question?: string; answer?: string; displayOrder?: number } = {}; if (validation.data.question !== undefined) updates.question = validation.data.question; if (validation.data.answer !== undefined) updates.answer = validation.data.answer; - if (validation.data.displayOrder !== undefined) updates.display_order = validation.data.displayOrder; + if (validation.data.displayOrder !== undefined) updates.displayOrder = validation.data.displayOrder; const updatedFaq = await db.faqs.byId(id).update(updates); res.json(formatFaqResponse(updatedFaq)); diff --git a/apps/backend/src/routers/portal/faqs.ts b/apps/backend/src/routers/portal/faqs.ts index 2fc22a00e..cdc28bd7d 100644 --- a/apps/backend/src/routers/portal/faqs.ts +++ b/apps/backend/src/routers/portal/faqs.ts @@ -1,5 +1,5 @@ import express from 'express'; -import { FaqWithCreator } from '@lems/database'; +import { Faq } from '@lems/database'; import db from '../../lib/database'; const router = express.Router(); @@ -11,11 +11,11 @@ const handleError = (res: express.Response, error: unknown, context: string) => }; // Format FAQ response for portal - excludes creator info, timestamps, and seasonId for public consumption -const formatPortalFaqResponse = (faq: FaqWithCreator) => ({ - id: faq.id, +const formatPortalFaqResponse = (faq: Faq) => ({ + id: faq._id?.toString() || '', question: faq.question, answer: faq.answer, - displayOrder: faq.display_order + displayOrder: faq.displayOrder }); // GET /portal/faqs - Get all FAQs @@ -49,7 +49,7 @@ router.get('/search', async (req, res) => { return; } - let faqs: FaqWithCreator[]; + let faqs: Faq[]; if (seasonId && typeof seasonId === 'string') { faqs = await db.faqs.bySeason(seasonId).search(q); } else { diff --git a/libs/database/src/database.ts b/libs/database/src/database.ts index 98f48cc5c..dc2826762 100644 --- a/libs/database/src/database.ts +++ b/libs/database/src/database.ts @@ -18,7 +18,7 @@ import { RubricsRepository } from './repositories/rubrics'; import { ScoresheetsRepository } from './repositories/scoresheets'; import { JudgingDeliberationsRepository } from './repositories/judging-deliberations'; import { FinalDeliberationsRepository } from './repositories/final-deliberations'; -import { FaqsRepository } from './repositories/faqs'; +import { FaqsRepository } from './repositories/faqs-mongo'; const IS_PRODUCTION = process.env.NODE_ENV === 'production'; @@ -140,7 +140,7 @@ export class Database { this.scoresheets = new ScoresheetsRepository(this.kysely, this.mongoDb); this.awards = new AwardsRepository(this.kysely); - this.faqs = new FaqsRepository(this.kysely); + this.faqs = new FaqsRepository(this.mongoDb); } async connect(): Promise { diff --git a/libs/database/src/index.ts b/libs/database/src/index.ts index a808fda9b..3272b4fb2 100644 --- a/libs/database/src/index.ts +++ b/libs/database/src/index.ts @@ -2,4 +2,3 @@ export { Database, type DatabaseRawAccess } from './database'; export { ObjectStorage } from './object-storage'; export * from './schema/index'; -export type { FaqWithCreator } from './repositories/faqs'; diff --git a/libs/database/src/repositories/faqs-mongo.ts b/libs/database/src/repositories/faqs-mongo.ts new file mode 100644 index 000000000..1091e93f8 --- /dev/null +++ b/libs/database/src/repositories/faqs-mongo.ts @@ -0,0 +1,147 @@ +import { Db, ObjectId } from 'mongodb'; +import { Faq } from '../schema/documents/faq'; + +const COLLECTION_NAME = 'faqs'; + +interface CreateFaqInput { + seasonId: string; + question: string; + answer: string; + displayOrder: number; + createdBy: { + id: string; + firstName: string; + lastName: string; + }; +} + +interface UpdateFaqInput { + question?: string; + answer?: string; + displayOrder?: number; +} + +class FaqSelector { + constructor( + private db: Db, + private faqId: string + ) {} + + async get(): Promise { + const collection = this.db.collection(COLLECTION_NAME); + const faq = await collection.findOne({ _id: new ObjectId(this.faqId) }); + return faq; + } + + async update(updates: UpdateFaqInput): Promise { + const collection = this.db.collection(COLLECTION_NAME); + + const updateDoc: Partial = { + ...updates, + updatedAt: new Date() + }; + + await collection.updateOne( + { _id: new ObjectId(this.faqId) }, + { $set: updateDoc } + ); + + const updatedFaq = await this.get(); + if (!updatedFaq) { + throw new Error('FAQ not found after update'); + } + return updatedFaq; + } + + async delete(): Promise { + const collection = this.db.collection(COLLECTION_NAME); + await collection.deleteOne({ _id: new ObjectId(this.faqId) }); + } +} + +class FaqsSelector { + constructor( + private db: Db, + private seasonId?: string + ) {} + + private getBaseQuery() { + if (this.seasonId) { + return { seasonId: this.seasonId }; + } + return {}; + } + + async getAll(): Promise { + const collection = this.db.collection(COLLECTION_NAME); + return await collection + .find(this.getBaseQuery()) + .sort({ displayOrder: 1 }) + .toArray(); + } + + async search(searchTerm: string): Promise { + const collection = this.db.collection(COLLECTION_NAME); + const query = { + ...this.getBaseQuery(), + $or: [ + { question: { $regex: searchTerm, $options: 'i' } }, + { answer: { $regex: searchTerm, $options: 'i' } } + ] + }; + + return await collection + .find(query) + .sort({ displayOrder: 1 }) + .toArray(); + } +} + +export class FaqsRepository { + constructor(private db: Db) {} + + byId(faqId: string): FaqSelector { + return new FaqSelector(this.db, faqId); + } + + bySeason(seasonId: string): FaqsSelector { + return new FaqsSelector(this.db, seasonId); + } + + all(): FaqsSelector { + return new FaqsSelector(this.db); + } + + async create(input: CreateFaqInput): Promise { + const collection = this.db.collection(COLLECTION_NAME); + + const faq: Omit = { + seasonId: input.seasonId, + question: input.question, + answer: input.answer, + displayOrder: input.displayOrder, + createdBy: input.createdBy, + createdAt: new Date(), + updatedAt: new Date() + }; + + const result = await collection.insertOne(faq as Faq); + const createdFaq = await collection.findOne({ _id: result.insertedId }); + + if (!createdFaq) { + throw new Error('FAQ not found after creation'); + } + return createdFaq; + } + + async getMaxDisplayOrder(seasonId: string): Promise { + const collection = this.db.collection(COLLECTION_NAME); + const result = await collection + .find({ seasonId }) + .sort({ displayOrder: -1 }) + .limit(1) + .toArray(); + + return result.length > 0 ? result[0].displayOrder : 0; + } +} diff --git a/libs/database/src/repositories/faqs.ts b/libs/database/src/repositories/faqs.ts deleted file mode 100644 index 41988ac25..000000000 --- a/libs/database/src/repositories/faqs.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { Kysely } from 'kysely'; -import { KyselyDatabaseSchema } from '../schema/kysely'; -import { InsertableFaq, Faq, UpdateableFaq } from '../schema/tables/faqs'; - -export interface FaqWithCreator extends Faq { - creator_first_name: string; - creator_last_name: string; -} - -const FAQ_FIELDS = [ - 'faqs.pk', - 'faqs.id', - 'faqs.season_id', - 'faqs.question', - 'faqs.answer', - 'faqs.display_order', - 'faqs.created_by', - 'faqs.created_at', - 'faqs.updated_at', - 'admins.first_name as creator_first_name', - 'admins.last_name as creator_last_name' -] as const; - -class FaqSelector { - constructor( - private db: Kysely, - private faqId: string - ) {} - - private getFaqQuery() { - return this.db - .selectFrom('faqs') - .innerJoin('admins', 'faqs.created_by', 'admins.id') - .select(FAQ_FIELDS) - .where('faqs.id', '=', this.faqId); - } - - async get(): Promise { - const faq = await this.getFaqQuery().executeTakeFirst(); - return faq || null; - } - - async update(updates: UpdateableFaq): Promise { - await this.db - .updateTable('faqs') - .set({ ...updates, updated_at: new Date() }) - .where('id', '=', this.faqId) - .execute(); - - const updatedFaq = await this.get(); - if (!updatedFaq) { - throw new Error('FAQ not found after update'); - } - return updatedFaq; - } - - async delete(): Promise { - await this.db.deleteFrom('faqs').where('id', '=', this.faqId).execute(); - } -} - -class FaqsSelector { - constructor( - private db: Kysely, - private seasonId?: string - ) {} - - private getBaseQuery() { - let query = this.db - .selectFrom('faqs') - .innerJoin('admins', 'faqs.created_by', 'admins.id') - .select(FAQ_FIELDS); - if (this.seasonId) { - query = query.where('season_id', '=', this.seasonId); - } - return query; - } - - async getAll(): Promise { - return await this.getBaseQuery().orderBy('display_order', 'asc').execute(); - } - - async search(searchTerm: string): Promise { - const term = `%${searchTerm}%`; - return await this.getBaseQuery() - .where(eb => - eb.or([ - eb('question', 'ilike', term), - eb('answer', 'ilike', term) - ]) - ) - .orderBy('display_order', 'asc') - .execute(); - } -} - -export class FaqsRepository { - constructor(private db: Kysely) {} - - byId(faqId: string): FaqSelector { - return new FaqSelector(this.db, faqId); - } - - bySeason(seasonId: string): FaqsSelector { - return new FaqsSelector(this.db, seasonId); - } - - all(): FaqsSelector { - return new FaqsSelector(this.db); - } - - async create(faq: InsertableFaq): Promise { - const [createdFaq] = await this.db - .insertInto('faqs') - .values(faq) - .returning('id') - .execute(); - - const faqWithCreator = await this.byId(createdFaq.id).get(); - if (!faqWithCreator) { - throw new Error('FAQ not found after creation'); - } - return faqWithCreator; - } - - async getMaxDisplayOrder(seasonId: string): Promise { - const result = await this.db - .selectFrom('faqs') - .select(eb => eb.fn.max('display_order').as('max_order')) - .where('season_id', '=', seasonId) - .executeTakeFirst(); - return (result?.max_order as number) || 0; - } -} diff --git a/libs/database/src/schema/documents/faq.ts b/libs/database/src/schema/documents/faq.ts new file mode 100644 index 000000000..106b52d1a --- /dev/null +++ b/libs/database/src/schema/documents/faq.ts @@ -0,0 +1,16 @@ +import { ObjectId } from 'mongodb'; + +export interface Faq { + _id?: ObjectId; + seasonId: string; + question: string; + answer: string; + displayOrder: number; + createdBy: { + id: string; + firstName: string; + lastName: string; + }; + createdAt: Date; + updatedAt: Date; +} diff --git a/libs/database/src/schema/index.ts b/libs/database/src/schema/index.ts index 835e177e6..6ce3622c7 100644 --- a/libs/database/src/schema/index.ts +++ b/libs/database/src/schema/index.ts @@ -33,7 +33,7 @@ export * from './documents/robot-game-match-state'; export * from './documents/rubric'; export * from './documents/scoresheet'; export * from './documents/final-deliberation'; +export * from './documents/faq'; export * from './tables/judging-deliberation'; export * from './tables/awards'; -export * from './tables/faqs'; diff --git a/libs/database/src/schema/kysely.ts b/libs/database/src/schema/kysely.ts index b2ebe0403..856610e18 100644 --- a/libs/database/src/schema/kysely.ts +++ b/libs/database/src/schema/kysely.ts @@ -18,7 +18,6 @@ import { RobotGameMatchParticipantsTable } from './tables/robot-game-match-parti import { AwardsTable } from './tables/awards'; import { AgendaEventsTable } from './tables/agenda-events'; import { JudgingDeliberationsTable } from './tables/judging-deliberation'; -import { FaqsTable } from './tables/faqs'; export interface KyselyDatabaseSchema { admins: AdminsTable; @@ -41,5 +40,4 @@ export interface KyselyDatabaseSchema { awards: AwardsTable; agenda_events: AgendaEventsTable; judging_deliberations: JudgingDeliberationsTable; - faqs: FaqsTable; } diff --git a/libs/database/src/schema/tables/faqs.ts b/libs/database/src/schema/tables/faqs.ts deleted file mode 100644 index 40583b2de..000000000 --- a/libs/database/src/schema/tables/faqs.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { ColumnType, Selectable, Insertable, Updateable } from 'kysely'; - -export interface FaqsTable { - pk: ColumnType; // Serial primary key - id: ColumnType; // UUID, generated - season_id: string; // UUID foreign key to seasons.id - question: string; - answer: string; - display_order: number; - created_by: string; // UUID foreign key to admins.id - created_at: ColumnType; // Generated on insert - updated_at: ColumnType; // Updated on modification -} - -export type Faq = Selectable; -export type InsertableFaq = Insertable; -export type UpdateableFaq = Updateable; From ef2502f98ee0d86632ba6018fde6863c2b04482b Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Mon, 19 Jan 2026 22:14:58 +0200 Subject: [PATCH 15/16] no MANAGE_FAQ --- apps/backend/src/routers/admin/faqs.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/apps/backend/src/routers/admin/faqs.ts b/apps/backend/src/routers/admin/faqs.ts index df5f5050c..3d60d1273 100644 --- a/apps/backend/src/routers/admin/faqs.ts +++ b/apps/backend/src/routers/admin/faqs.ts @@ -5,7 +5,6 @@ import { Faq } from '@lems/database'; import db from '../../lib/database'; import { uploadFile } from '../../lib/blob-storage/upload'; import { AdminRequest } from '../../types/express'; -import { requirePermission } from './middleware/require-permission'; const router = express.Router(); @@ -33,7 +32,7 @@ const formatFaqResponse = (faq: Faq) => ({ updatedAt: faq.updatedAt.toISOString() }); -router.get('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { +router.get('/', async (req: AdminRequest, res) => { try { const faqs = await db.faqs.all().getAll(); res.json(faqs.map(formatFaqResponse)); @@ -42,7 +41,7 @@ router.get('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) } }); -router.get('/season/:seasonId', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { +router.get('/season/:seasonId', async (req: AdminRequest, res) => { try { const { seasonId } = req.params; const faqs = await db.faqs.bySeason(seasonId).getAll(); @@ -52,7 +51,7 @@ router.get('/season/:seasonId', requirePermission('MANAGE_FAQ'), async (req: Adm } }); -router.get('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { +router.get('/:id', async (req: AdminRequest, res) => { try { const { id } = req.params; const faq = await db.faqs.byId(id).get(); @@ -68,7 +67,7 @@ router.get('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, re } }); -router.post('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { +router.post('/', async (req: AdminRequest, res) => { try { const validation = CreateFaqRequestSchema.safeParse(req.body); @@ -106,7 +105,7 @@ router.post('/', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) } }); -router.put('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { +router.put('/:id', async (req: AdminRequest, res) => { try { const { id } = req.params; const validation = UpdateFaqRequestSchema.safeParse(req.body); @@ -134,7 +133,7 @@ router.put('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, re } }); -router.delete('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, res) => { +router.delete('/:id', async (req: AdminRequest, res) => { try { const { id } = req.params; @@ -153,7 +152,6 @@ router.delete('/:id', requirePermission('MANAGE_FAQ'), async (req: AdminRequest, router.post( '/upload-image', - requirePermission('MANAGE_FAQ'), fileUpload(), async (req: AdminRequest, res) => { if (!req.files || !req.files.image) { @@ -196,7 +194,6 @@ router.post( router.post( '/upload-video', - requirePermission('MANAGE_FAQ'), fileUpload(), async (req: AdminRequest, res) => { if (!req.files || !req.files.video) { From 33d54b591c0a6c9fa47d9ad74fe8b51c88fe4c07 Mon Sep 17 00:00:00 2001 From: CoolGame8 Date: Mon, 19 Jan 2026 22:21:51 +0200 Subject: [PATCH 16/16] oof. --- .../app/[locale]/(dashboard)/faqs/page.tsx | 11 ------- .../src/app/[locale]/(dashboard)/layout.tsx | 31 +++++++++++++++---- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx b/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx index 7c20cedc7..1cbecdff0 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/faqs/page.tsx @@ -26,14 +26,11 @@ import { useTranslations } from 'next-intl'; import useSWR from 'swr'; import { FaqResponse } from '@lems/types/api/admin'; import { Season } from '@lems/database'; -import { useSession } from '../components/session-context'; import { FaqEditorDialog } from './components/faq-editor-dialog'; import { DeleteConfirmDialog } from './components/delete-confirm-dialog'; export default function FaqsPage() { const t = useTranslations('pages.faqs'); - const { permissions } = useSession(); - const hasPermission = permissions.includes('MANAGE_FAQ'); const [selectedSeasonId, setSelectedSeasonId] = useState('all'); const [editingFaq, setEditingFaq] = useState(null); const [deletingFaq, setDeletingFaq] = useState(null); @@ -44,14 +41,6 @@ export default function FaqsPage() { selectedSeasonId === 'all' ? '/admin/faqs' : `/admin/faqs/season/${selectedSeasonId}` ); - if (!hasPermission) { - return ( - - {t('errors.no-permission')} - - ); - } - const loading = !faqs || !seasons; const error = faqsError || seasonsError; diff --git a/apps/admin/src/app/[locale]/(dashboard)/layout.tsx b/apps/admin/src/app/[locale]/(dashboard)/layout.tsx index 9ee0934cb..3538b8f5e 100644 --- a/apps/admin/src/app/[locale]/(dashboard)/layout.tsx +++ b/apps/admin/src/app/[locale]/(dashboard)/layout.tsx @@ -41,7 +41,13 @@ type Navigator = { }; }; -const navigator: Navigator = { +type NavItem = { + icon: React.ReactNode; + label: string; + route: string; +}; + +const permissionBasedNavigator: Navigator = { MANAGE_SEASONS: { icon: , label: 'seasons', @@ -66,13 +72,16 @@ const navigator: Navigator = { icon: , label: 'users', route: 'users' - }, - MANAGE_FAQ: { + } +}; + +const alwaysVisibleNav: NavItem[] = [ + { icon: , label: 'faqs', route: 'faqs' } -}; +]; interface AppBarProps { width: number; @@ -107,11 +116,11 @@ const AppBar: React.FC = ({ width, permissions, user }) => { - {Object.keys(navigator).map(permissionKey => { + {Object.keys(permissionBasedNavigator).map(permissionKey => { const permission = permissionKey as PermissionType; if (!permissions.includes(permission)) return null; - const navItem = navigator[permission]; + const navItem = permissionBasedNavigator[permission]; if (!navItem) return null; return ( @@ -125,6 +134,16 @@ const AppBar: React.FC = ({ width, permissions, user }) => { ); })} + {alwaysVisibleNav.map(navItem => ( + + + + {navItem.icon} + + + + + ))}