Merge branch 'main' into narl.io

This commit is contained in:
nvrl committed 2026-02-18 03:58:44 +01:00
commit f794529636
7 files changed
+165 -62

No files matched your search

@@ -0,0 +1,24 @@
# Generated by Django 6.0.2 on 2026-02-18 02:55
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('api', '0015_ticket_ticketmessage_userip'),
]
operations = [
migrations.AddField(
model_name='notification',
name='ticket',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='api.ticket'),
),
migrations.AlterField(
model_name='notification',
name='notification_type',
field=models.CharField(choices=[('reply', 'Reply'), ('mention', 'Mention'), ('ticket_new', 'New Ticket'), ('ticket_reply', 'Ticket Reply')], max_length=20),
),
]
+3 -2
View File
@@ -124,11 +124,12 @@ class Notification(models.Model):
TYPE_CHOICES = [ TYPE_CHOICES = [
('reply', 'Reply'), ('reply', 'Reply'),
('mention', 'Mention'), ('mention', 'Mention'),
('ticket', 'Ticket Update'), ('ticket_new', 'New Ticket'),
('ticket_reply', 'Ticket Reply'),
] ]
recipient = models.ForeignKey(User, related_name='notifications', on_delete=models.CASCADE) recipient = models.ForeignKey(User, related_name='notifications', on_delete=models.CASCADE)
actor = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE) actor = models.ForeignKey(User, related_name='actions', on_delete=models.CASCADE)
notification_type = models.CharField(max_length=10, choices=TYPE_CHOICES) notification_type = models.CharField(max_length=20, choices=TYPE_CHOICES)
rating = models.ForeignKey(Rating, on_delete=models.CASCADE, null=True, blank=True) rating = models.ForeignKey(Rating, on_delete=models.CASCADE, null=True, blank=True)
reply = models.ForeignKey(Reply, on_delete=models.CASCADE, null=True, blank=True) reply = models.ForeignKey(Reply, on_delete=models.CASCADE, null=True, blank=True)
ticket = models.ForeignKey('Ticket', on_delete=models.CASCADE, null=True, blank=True) ticket = models.ForeignKey('Ticket', on_delete=models.CASCADE, null=True, blank=True)
+3 -3
View File
@@ -583,7 +583,7 @@ class TicketViewSet(viewsets.ModelViewSet):
Notification.objects.create( Notification.objects.create(
recipient=admin, recipient=admin,
actor=self.request.user, actor=self.request.user,
notification_type='ticket', notification_type='ticket_new',
ticket=ticket ticket=ticket
) )
@@ -603,7 +603,7 @@ class TicketViewSet(viewsets.ModelViewSet):
Notification.objects.create( Notification.objects.create(
recipient=ticket.user, recipient=ticket.user,
actor=request.user, actor=request.user,
notification_type='ticket', notification_type='ticket_reply',
ticket=ticket ticket=ticket
) )
else: else:
@@ -613,7 +613,7 @@ class TicketViewSet(viewsets.ModelViewSet):
Notification.objects.create( Notification.objects.create(
recipient=admin, recipient=admin,
actor=request.user, actor=request.user,
notification_type='ticket', notification_type='ticket_reply',
ticket=ticket ticket=ticket
) )
+60 -44
View File
@@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo, Suspense, lazy } from 'react'; import React, { useState, useEffect, useMemo, Suspense, lazy } from 'react';
import { BrowserRouter as Router, Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom'; import { Routes, Route, Link, useNavigate, useLocation } from 'react-router-dom';
import { import {
ThemeProvider, ThemeProvider,
CssBaseline, CssBaseline,
@@ -206,6 +206,7 @@ const GlobalSearch = () => {
const App: React.FC = () => { const App: React.FC = () => {
const { i18n, t } = useTranslation(); const { i18n, t } = useTranslation();
const navigate = useNavigate();
const [themeName, setThemeName] = useState<CatppuccinTheme>((localStorage.getItem('theme') as CatppuccinTheme) || 'holy_light'); const [themeName, setThemeName] = useState<CatppuccinTheme>((localStorage.getItem('theme') as CatppuccinTheme) || 'holy_light');
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [notifAnchorEl, setNotifAnchorEl] = useState<null | HTMLElement>(null); const [notifAnchorEl, setNotifAnchorEl] = useState<null | HTMLElement>(null);
@@ -492,35 +493,52 @@ const App: React.FC = () => {
<Typography variant="body2" color="text.secondary">No notifications yet</Typography> <Typography variant="body2" color="text.secondary">No notifications yet</Typography>
</Box> </Box>
) : ( ) : (
notifications.map(n => ( notifications.map(n => {
<MenuItem let message = "";
key={n.id} if (n.notification_type === 'reply') message = `replied to your review on ${n.flavor_name}`;
onClick={() => handleNotificationClick(n)} else if (n.notification_type === 'mention') message = `mentioned you on ${n.flavor_name}`;
sx={{ else if (n.notification_type === 'ticket_new') message = t('community.notifTicketNew');
py: 1.5, else if (n.notification_type === 'ticket_reply') message = user?.is_superuser ? t('community.notifTicketReplyAdmin') : t('community.notifTicketReply');
px: 2,
borderBottom: '1px solid', const handleClick = () => {
borderColor: 'divider', if (n.notification_type.startsWith('ticket')) {
bgcolor: n.is_read ? 'transparent' : alpha(theme.palette.primary.main, 0.05), handleNotificationClick(n);
whiteSpace: 'normal', navigate('/support');
display: 'flex', } else {
gap: 2, handleNotificationClick(n);
alignItems: 'flex-start' }
}} };
>
<Avatar src={n.actor_avatar || undefined} sx={{ width: 32, height: 32 }}> return (
{n.actor_username.charAt(0).toUpperCase()} <MenuItem
</Avatar> key={n.id}
<Box sx={{ flex: 1 }}> onClick={handleClick}
<Typography variant="body2" sx={{ lineHeight: 1.4 }}> sx={{
<strong>{n.actor_username}</strong> {n.notification_type === 'reply' ? 'replied' : 'mentioned you'} on <strong>{n.flavor_name}</strong> py: 1.5,
</Typography> px: 2,
<Typography variant="caption" color="text.secondary"> borderBottom: '1px solid',
{formatDate(n.created_at)} borderColor: 'divider',
</Typography> bgcolor: n.is_read ? 'transparent' : alpha(theme.palette.primary.main, 0.05),
</Box> whiteSpace: 'normal',
</MenuItem> display: 'flex',
)) gap: 2,
alignItems: 'flex-start'
}}
>
<Avatar src={n.actor_avatar || undefined} sx={{ width: 32, height: 32 }}>
{n.actor_username.charAt(0).toUpperCase()}
</Avatar>
<Box sx={{ flex: 1 }}>
<Typography variant="body2" sx={{ lineHeight: 1.4 }}>
<strong>{n.actor_username}</strong> {message}
</Typography>
<Typography variant="caption" color="text.secondary">
{formatDate(n.created_at)}
</Typography>
</Box>
</MenuItem>
);
})
)} )}
</Menu> </Menu>
</> </>
@@ -529,19 +547,18 @@ const App: React.FC = () => {
return ( return (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<CssBaseline /> <CssBaseline />
<Router> <Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', width: '100%', overflowX: 'hidden' }}>
<Box sx={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', width: '100%', overflowX: 'hidden' }}> <AppBar
<AppBar position="sticky"
position="sticky" elevation={0}
elevation={0} sx={{
sx={{ borderBottom: '1px solid',
borderBottom: '1px solid', borderColor: 'divider',
borderColor: 'divider', bgcolor: (theme) => alpha(theme.palette.background.paper, 0.8),
bgcolor: (theme) => alpha(theme.palette.background.paper, 0.8), backdropFilter: 'blur(12px)',
backdropFilter: 'blur(12px)', color: 'text.primary'
color: 'text.primary' }}
}} >
>
<Toolbar sx={{ minHeight: { xs: 56, sm: 64 }, px: { xs: 1, sm: 4, md: 6 } }}> <Toolbar sx={{ minHeight: { xs: 56, sm: 64 }, px: { xs: 1, sm: 4, md: 6 } }}>
<Typography variant="h6" component="div" sx={{ fontWeight: 'bold', mr: 2 }}> <Typography variant="h6" component="div" sx={{ fontWeight: 'bold', mr: 2 }}>
<Link to="/" style={{ color: 'inherit', textDecoration: 'none' }}> <Link to="/" style={{ color: 'inherit', textDecoration: 'none' }}>
@@ -678,7 +695,6 @@ const App: React.FC = () => {
<Footer /> <Footer />
</Box> </Box>
<CookieBanner onThemeChange={handleThemeChange} currentTheme={themeName} /> <CookieBanner onThemeChange={handleThemeChange} currentTheme={themeName} />
</Router>
</ThemeProvider> </ThemeProvider>
); );
}; };
+10 -2
View File
@@ -125,6 +125,9 @@ const resources = {
"notifications": "Recent Notifications", "notifications": "Recent Notifications",
"notifReply": "replied", "notifReply": "replied",
"notifMention": "mentioned you", "notifMention": "mentioned you",
"notifTicketNew": "opened a new support ticket",
"notifTicketReply": "replied to your support ticket",
"notifTicketReplyAdmin": "replied to a support ticket",
"noNotifications": "No notifications.", "noNotifications": "No notifications.",
"searchFriends": "Search friends...", "searchFriends": "Search friends...",
"writeReply": "Add a reply...", "writeReply": "Add a reply...",
@@ -143,7 +146,8 @@ const resources = {
"in_progress": "In Progress", "in_progress": "In Progress",
"noTickets": "You haven't opened any tickets yet.", "noTickets": "You haven't opened any tickets yet.",
"reply": "Send Message", "reply": "Send Message",
"ticketDetail": "Ticket #{{id}}" "ticketDetail": "Ticket #{{id}}",
"unread": "New message"
}, },
"admin": { "admin": {
"title": "Admin Control Panel", "title": "Admin Control Panel",
@@ -315,6 +319,9 @@ const resources = {
"notifications": "Neueste Benachrichtigungen", "notifications": "Neueste Benachrichtigungen",
"notifReply": "hat geantwortet", "notifReply": "hat geantwortet",
"notifMention": "hat dich erwähnt", "notifMention": "hat dich erwähnt",
"notifTicketNew": "hat ein neues Support-Ticket eröffnet",
"notifTicketReply": "hat auf dein Support-Ticket geantwortet",
"notifTicketReplyAdmin": "hat auf ein Support-Ticket geantwortet",
"noNotifications": "Keine Benachrichtigungen.", "noNotifications": "Keine Benachrichtigungen.",
"searchFriends": "Freunde suchen...", "searchFriends": "Freunde suchen...",
"writeReply": "Eine Antwort hinzufügen...", "writeReply": "Eine Antwort hinzufügen...",
@@ -333,7 +340,8 @@ const resources = {
"in_progress": "In Bearbeitung", "in_progress": "In Bearbeitung",
"noTickets": "Du hast noch keine Tickets geöffnet.", "noTickets": "Du hast noch keine Tickets geöffnet.",
"reply": "Nachricht senden", "reply": "Nachricht senden",
"ticketDetail": "Ticket #{{id}}" "ticketDetail": "Ticket #{{id}}",
"unread": "Neue Nachricht"
}, },
"admin": { "admin": {
"title": "Admin-Kontrollzentrum", "title": "Admin-Kontrollzentrum",
+4 -1
View File
@@ -1,10 +1,13 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { BrowserRouter as Router } from 'react-router-dom'
import './index.css' import './index.css'
import App from './App.tsx' import App from './App.tsx'
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <Router>
<App />
</Router>
</StrictMode>, </StrictMode>,
) )
+61 -10
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react';
import { import {
Container, Typography, Box, Card, CardContent, Button, Container, Typography, Box, Card, CardContent, Button,
TextField, Stack, Chip, Divider, Collapse, Paper, alpha, CircularProgress, TextField, Stack, Chip, Divider, Collapse, Paper, alpha, CircularProgress,
IconButton, Avatar IconButton, Avatar, useTheme
} from '@mui/material'; } from '@mui/material';
import SendIcon from '@mui/icons-material/Send'; import SendIcon from '@mui/icons-material/Send';
import AddIcon from '@mui/icons-material/Add'; import AddIcon from '@mui/icons-material/Add';
@@ -37,6 +37,7 @@ interface Ticket {
const Support: React.FC = () => { const Support: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const theme = useTheme();
const navigate = useNavigate(); const navigate = useNavigate();
useTitle(t('support.title')); useTitle(t('support.title'));
const [tickets, setTickets] = useState<Ticket[]>([]); const [tickets, setTickets] = useState<Ticket[]>([]);
@@ -51,15 +52,25 @@ const Support: React.FC = () => {
const [replyText, setReplyText] = useState<{[key: number]: string}>({}); const [replyText, setReplyText] = useState<{[key: number]: string}>({});
const [expandedId, setExpandedId] = useState<number | null>(null); const [expandedId, setExpandedId] = useState<number | null>(null);
const [currentUser, setCurrentUser] = useState<any>(null); const [currentUser, setCurrentUser] = useState<any>(null);
const [unreadTicketIds, setUnreadTicketIds] = useState<Set<number>>(new Set());
const fetchTickets = async () => { const fetchTickets = async () => {
try { try {
const [ticketRes, userRes] = await Promise.all([ const [ticketRes, userRes, notifRes] = await Promise.all([
api.get('tickets/'), api.get('tickets/'),
api.get('users/me/') api.get('users/me/'),
api.get('notifications/')
]); ]);
setTickets(ticketRes.data.results || ticketRes.data); setTickets(ticketRes.data.results || ticketRes.data);
setCurrentUser(userRes.data); setCurrentUser(userRes.data);
const notifs = notifRes.data.results || notifRes.data;
const unreadIds = new Set<number>(
notifs
.filter((n: any) => !n.is_read && n.notification_type.startsWith('ticket') && n.ticket)
.map((n: any) => n.ticket)
);
setUnreadTicketIds(unreadIds);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
@@ -71,6 +82,26 @@ const Support: React.FC = () => {
fetchTickets(); fetchTickets();
}, []); }, []);
const handleExpand = async (ticketId: number) => {
if (expandedId === ticketId) {
setExpandedId(null);
return;
}
setExpandedId(ticketId);
// Mark notifications for this ticket as read
if (unreadTicketIds.has(ticketId)) {
try {
// We'd need an endpoint to mark all for a specific ticket,
// or just rely on the user clicking the notification.
// For now, let's just clear it from the local state.
const newUnread = new Set(unreadTicketIds);
newUnread.delete(ticketId);
setUnreadTicketIds(newUnread);
} catch (e) {}
}
};
const handleCreate = async (e: React.FormEvent) => { const handleCreate = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
@@ -177,18 +208,38 @@ const Support: React.FC = () => {
) : ( ) : (
<Stack spacing={2}> <Stack spacing={2}>
{tickets.map((ticket: Ticket) => ( {tickets.map((ticket: Ticket) => (
<Card key={ticket.id} sx={{ borderRadius: 3, border: '1px solid', borderColor: 'divider' }}> <Card key={ticket.id} sx={{
borderRadius: 3,
border: '1px solid',
borderColor: unreadTicketIds.has(ticket.id) ? 'primary.main' : 'divider',
bgcolor: unreadTicketIds.has(ticket.id) ? alpha(theme.palette.primary.main, 0.02) : 'inherit'
}}>
<CardContent sx={{ p: 0 }}> <CardContent sx={{ p: 0 }}>
<Box <Box
onClick={() => setExpandedId(expandedId === ticket.id ? null : ticket.id)} onClick={() => handleExpand(ticket.id)}
sx={{ p: 2, cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }} sx={{ p: 2, cursor: 'pointer', '&:hover': { bgcolor: 'action.hover' } }}
> >
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}> <Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', mb: 1 }}>
<Box> <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>{ticket.subject}</Typography> {unreadTicketIds.has(ticket.id) && (
<Typography variant="caption" color="text.secondary">{formatDate(ticket.created_at)}</Typography> <Box sx={{ width: 10, height: 10, bgcolor: 'primary.main', borderRadius: '50%' }} />
)}
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 'bold' }}>
{ticket.subject}
{currentUser?.is_superuser && (
<Typography component="span" variant="caption" sx={{ ml: 1, color: 'primary.main' }}>
({ticket.username})
</Typography>
)}
</Typography>
<Typography variant="caption" color="text.secondary">{formatDate(ticket.created_at)}</Typography>
</Box>
</Box> </Box>
<Stack direction="row" spacing={1} alignItems="center"> <Stack direction="row" spacing={1} alignItems="center">
{unreadTicketIds.has(ticket.id) && (
<Chip label={t('support.unread')} color="primary" size="small" sx={{ fontWeight: 'bold', height: 20, fontSize: '0.6rem' }} />
)}
<Chip label={t(`support.${ticket.status}`)} color={getStatusColor(ticket.status) as any} size="small" sx={{ fontWeight: 'bold' }} /> <Chip label={t(`support.${ticket.status}`)} color={getStatusColor(ticket.status) as any} size="small" sx={{ fontWeight: 'bold' }} />
{currentUser?.is_superuser && ( {currentUser?.is_superuser && (
<IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDeleteTicket(ticket.id); }}> <IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); handleDeleteTicket(ticket.id); }}>
@@ -204,7 +255,7 @@ const Support: React.FC = () => {
{ticket.username.charAt(0).toUpperCase()} {ticket.username.charAt(0).toUpperCase()}
</Avatar> </Avatar>
<Box sx={{ flex: 1, minWidth: 0 }}> <Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block' }}> <Typography variant="caption" sx={{ fontWeight: 'bold', display: 'block', overflow: 'hidden', textOverflow: 'ellipsis' }}>
{ticket.username} • <span style={{ opacity: 0.7 }}>{ticket.user_email}</span> {ticket.username} • <span style={{ opacity: 0.7 }}>{ticket.user_email}</span>
</Typography> </Typography>
</Box> </Box>
@@ -220,7 +271,7 @@ const Support: React.FC = () => {
onClick={(e) => { e.stopPropagation(); navigate(`/admin-panel/user/${ticket.user}`); }} onClick={(e) => { e.stopPropagation(); navigate(`/admin-panel/user/${ticket.user}`); }}
sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }} sx={{ minWidth: 0, py: 0, textTransform: 'none', fontSize: '0.7rem' }}
> >
Admin User {t('admin.manageUser')}
</Button> </Button>
</Box> </Box>
)} )}