Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,6 @@ VITE_API_BASE_URL=https://api.modl.gg

# Stripe (public key only - safe to expose)
VITE_STRIPE_PUBLISHABLE_KEY=pk_live_xxx

# Realtime WebSocket client. Default ON. Set to false to hard-disable (kill-switch).
# VITE_REALTIME_ENABLED=false
17 changes: 5 additions & 12 deletions .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,9 @@ on:
jobs:
build:
runs-on: ubuntu-latest

permissions:
contents: read
packages: read

steps:
- name: Checkout code
Expand All @@ -24,21 +23,15 @@ jobs:
uses: actions/setup-node@v4
with:
node-version: '23'
registry-url: 'https://npm.pkg.github.com'
scope: '@modl-gg'

- name: Configure npm for GitHub Packages
- name: Configure npm for Nexus
run: |
rm .npmrc
echo "@modl-gg:registry=https://npm.pkg.github.com" >> .npmrc
echo "//npm.pkg.github.com/:_authToken=${{ secrets.GITHUB_TOKEN }}" >> .npmrc
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
echo "//nexus.modl.gg/repository/npm-releases/:_auth=$(echo -n '${{ secrets.NEXUS_USER }}:${{ secrets.NEXUS_PASS }}' | base64)" >> ~/.npmrc
echo "//nexus.modl.gg/repository/npm-releases/:always-auth=true" >> ~/.npmrc
echo "@modl-gg:registry=https://nexus.modl.gg/repository/npm-releases/" >> ~/.npmrc

- name: Install dependencies
run: npm i
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}

- name: Run build
run: npm run build
5 changes: 3 additions & 2 deletions client/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1" />
<title>Loading...</title>
<title>modl</title>
<!-- Default favicon - will be replaced by useDocumentTitle hook if custom icons are available -->
<link rel="icon" type="image/x-icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><text y='18' font-size='18'>⚡</text></svg>" />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&family=Audiowide&display=swap" rel="stylesheet">
<link rel="stylesheet" media="print" onload="this.media='all'" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&family=Audiowide&display=swap">
<noscript><link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;900&family=Audiowide&display=swap"></noscript>
</head>
<body>
<div id="root"></div>
Expand Down
50 changes: 44 additions & 6 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { useIsMobile } from '@modl-gg/shared-web/hooks/use-mobile';
import { useDocumentTitle } from "@/hooks/use-document-title";
import { useProvisioningStatusCheck } from "@/hooks/use-provisioning-status";
import { usePublicSettings } from "@/hooks/use-public-settings";
import { RealtimeProvider } from "@/hooks/use-realtime";
import { PlayerWindowProvider } from "@/contexts/PlayerWindowContext";
import { WelcomeModal } from "@/components/layout/WelcomeModal";
import { Loader2 } from "lucide-react";
Expand Down Expand Up @@ -66,8 +67,9 @@ function Router() {
const isUploadEvidencePage = location.startsWith('/upload-evidence');
const isVerifyPage = location.startsWith('/verify/');
const isReplayPage = location.startsWith('/replay');
const isRateLimitPage = location.startsWith('/rate-limit');

if (!isAdminPanelRoute && !isAuthPage && !isAppealsPage && !isPlayerTicketPage && !isSubmitTicketPage && !isProvisioningPage && !isAcceptInvitationPage && !isVerifyEmailPage && !isUploadEvidencePage && !isVerifyPage && !isReplayPage) {
if (!isAdminPanelRoute && !isAuthPage && !isAppealsPage && !isPlayerTicketPage && !isSubmitTicketPage && !isProvisioningPage && !isAcceptInvitationPage && !isVerifyEmailPage && !isUploadEvidencePage && !isVerifyPage && !isReplayPage && !isRateLimitPage) {
return (
<main className="h-full bg-background">
<Suspense fallback={<PageLoader />}>
Expand All @@ -82,7 +84,7 @@ function Router() {
);
}

if (isAuthPage || isAppealsPage || isPlayerTicketPage || isSubmitTicketPage || isProvisioningPage || isAcceptInvitationPage || isVerifyEmailPage || isUploadEvidencePage || isVerifyPage || isReplayPage) {
if (isAuthPage || isAppealsPage || isPlayerTicketPage || isSubmitTicketPage || isProvisioningPage || isAcceptInvitationPage || isVerifyEmailPage || isUploadEvidencePage || isVerifyPage || isReplayPage || isRateLimitPage) {
return (
<main className="h-full bg-background">
<Suspense fallback={<PageLoader />}>
Expand All @@ -99,6 +101,7 @@ function Router() {
<Route path="/upload-evidence/:token" component={UploadEvidencePage} />
<Route path="/verify/:token" component={VerifyPage} />
<Route path="/replay" component={ReplayPage} />
<Route path="/rate-limit" component={RateLimitPage} />
</Switch>
</Suspense>
</main>
Expand Down Expand Up @@ -186,9 +189,20 @@ function Router() {
);
}

// Recovery/verification flows that must stay reachable even when the tenant is not fully
// provisioned (serverExists===false) or the platform is in maintenance mode.
const ALWAYS_REACHABLE_PATHS = ['/verify-email', '/verify/', '/replay'];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 The /rate-limit page was added to the router but not to ALWAYS_REACHABLE_PATHS. When a user is being rate-limited, the /v1/public/settings request will also fail, so isSettingsError is true and publicSettings is undefined (no prior cache). The guard on the next line then fires and replaces the rate-limit page with "Unable to reach the server" — the exact screen this page is meant to replace.

Suggested change
const ALWAYS_REACHABLE_PATHS = ['/verify-email', '/verify/', '/replay'];
const ALWAYS_REACHABLE_PATHS = ['/verify-email', '/verify/', '/replay', '/rate-limit'];
Prompt To Fix With AI
This is a comment left during a code review.
Path: client/src/App.tsx
Line: 194

Comment:
The `/rate-limit` page was added to the router but not to `ALWAYS_REACHABLE_PATHS`. When a user is being rate-limited, the `/v1/public/settings` request will also fail, so `isSettingsError` is `true` and `publicSettings` is `undefined` (no prior cache). The guard on the next line then fires and replaces the rate-limit page with "Unable to reach the server" — the exact screen this page is meant to replace.

```suggestion
const ALWAYS_REACHABLE_PATHS = ['/verify-email', '/verify/', '/replay', '/rate-limit'];
```

How can I resolve this? If you propose a fix, please make it concise.

const isAlwaysReachablePath = (loc: string) =>
ALWAYS_REACHABLE_PATHS.some((p) => loc.startsWith(p));

function AppContent() {
const { user, isLoading } = useAuth();
const { data: publicSettings, isLoading: isLoadingSettings } = usePublicSettings();
const {
data: publicSettings,
isLoading: isLoadingSettings,
isError: isSettingsError,
refetch: refetchSettings,
} = usePublicSettings();
const [location] = useLocation();
const [isWelcomeModalOpen, setWelcomeModalOpen] = useState(false);

Expand Down Expand Up @@ -216,7 +230,7 @@ function AppContent() {
if (!hasSeenModal && isOnPanelHomePage && !isFromProvisioning && !isOnExcludedPage && !isOnPlayerTicketPage && !isOnAcceptInvitationPage && user?.role === 'Super Admin') {
setWelcomeModalOpen(true);
}
}, [location, user]);
}, [location, user?.role]);

const handleCloseWelcomeModal = () => {
localStorage.setItem("hasSeenWelcomeModal", "true");
Expand All @@ -231,21 +245,45 @@ function AppContent() {
);
}

// A transport/network failure of /v1/public/settings is transient, NOT a real "server not found".
// Only collapse to an error screen when we have no last-good settings to fall back to; otherwise the
// stale-but-valid `publicSettings` keeps the app usable (e.g. an authenticated staff session) and the
// next successful refetch self-heals. Never coerce a fetch failure into serverExists:false.
if (isSettingsError && !publicSettings && !isAlwaysReachablePath(location)) {
return (
<div className="flex flex-col items-center justify-center min-h-screen gap-4 px-4 text-center">
<h1 className="text-xl font-semibold">Unable to reach the server</h1>
<p className="text-muted-foreground max-w-md">
We couldn't load this page right now. This is usually temporary — please check your connection and try again.
</p>
<button
type="button"
onClick={() => { void refetchSettings(); }}
className="inline-flex items-center justify-center rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90"
>
Retry
</button>
</div>
);
}

// Skip server-not-found check for verify-email/verify/replay pages (needed for email verification flow)
if (publicSettings?.serverExists === false && !location.startsWith('/verify-email') && !location.startsWith('/verify/') && !location.startsWith('/replay')) {
if (publicSettings?.serverExists === false && !isAlwaysReachablePath(location)) {
return (
<Suspense fallback={<div className="flex items-center justify-center min-h-screen"><Loader2 className="h-8 w-8 animate-spin text-primary" /></div>}>
<ServerNotFoundPage />
</Suspense>
);
}

if (maintenanceMode) {
// Keep recovery/verification flows reachable during maintenance, matching the serverExists carve-out above.
if (maintenanceMode && !isAlwaysReachablePath(location)) {
return <MaintenancePage message={maintenanceMessage} />;
}

return (
<>
<RealtimeProvider />
<Toaster />
<WelcomeModal isOpen={isWelcomeModalOpen} onClose={handleCloseWelcomeModal} />
<Router />
Expand Down
67 changes: 54 additions & 13 deletions client/src/components/ArticleMediaUpload.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React, { useState } from 'react';
import { Image as ImageIcon, Upload, Copy, Check, Trash2 } from 'lucide-react';
import { Image as ImageIcon, Upload, Copy, Check, Trash2, Loader2 } from 'lucide-react';
import { Button } from '@modl-gg/shared-web/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@modl-gg/shared-web/components/ui/card';
import { Badge } from '@modl-gg/shared-web/components/ui/badge';
import { useToast } from '@modl-gg/shared-web/hooks/use-toast';
import { Input } from '@modl-gg/shared-web/components/ui/input';
import { Label } from '@modl-gg/shared-web/components/ui/label';
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@modl-gg/shared-web/components/ui/alert-dialog';
import MediaUpload from './MediaUpload';
import { useMediaUpload } from '@/hooks/use-media-upload';
import { formatFileSize } from '@/utils/file-utils';
Expand Down Expand Up @@ -42,6 +43,7 @@ export function ArticleMediaUpload({
const { t } = useTranslation();
const [media, setMedia] = useState<ArticleMedia[]>(existingMedia);
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
const [deletingIds, setDeletingIds] = useState<Set<string>>(new Set());
const { config, deleteMedia } = useMediaUpload();
const { toast } = useToast();

Expand All @@ -50,7 +52,7 @@ export function ArticleMediaUpload({
const fileName = file?.name || result.url.split('/').pop() || 'uploaded-file';

const newMedia: ArticleMedia = {
id: Date.now().toString(),
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
url: result.url,
key: result.key,
fileName: fileName,
Expand All @@ -60,9 +62,11 @@ export function ArticleMediaUpload({
altText: fileName.split('.')[0] // Use filename without extension as default alt text
};

const updatedMedia = [...media, newMedia];
setMedia(updatedMedia);
onMediaUpdate?.(updatedMedia);
setMedia(prev => {
const updatedMedia = [...prev, newMedia];
onMediaUpdate?.(updatedMedia);
return updatedMedia;
});

toast({
title: t('upload.mediaUploaded'),
Expand All @@ -71,6 +75,11 @@ export function ArticleMediaUpload({
};

const handleDeleteMedia = async (mediaItem: ArticleMedia) => {
setDeletingIds(prev => {
const next = new Set(prev);
next.add(mediaItem.id);
return next;
});
try {
await deleteMedia(mediaItem.key);
const updatedMedia = media.filter(m => m.id !== mediaItem.id);
Expand All @@ -87,6 +96,12 @@ export function ArticleMediaUpload({
description: t('upload.deleteMediaFailed'),
variant: "destructive",
});
} finally {
setDeletingIds(prev => {
const next = new Set(prev);
next.delete(mediaItem.id);
return next;
});
}
};

Expand Down Expand Up @@ -200,14 +215,40 @@ export function ArticleMediaUpload({
)}

{!readonly && (
<Button
variant="destructive"
size="sm"
className="absolute top-2 right-2"
onClick={() => handleDeleteMedia(item)}
>
<Trash2 className="h-3 w-3" />
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className="absolute top-2 right-2"
aria-label="Delete media"
disabled={deletingIds.has(item.id)}
>
{deletingIds.has(item.id) ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Trash2 className="h-3 w-3" />
)}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t('upload.deleteMediaTitle', 'Delete media?')}</AlertDialogTitle>
<AlertDialogDescription>
{t('upload.deleteMediaConfirm', { name: item.fileName, defaultValue: 'This will permanently remove "{{name}}". Articles referencing this media will show a broken image.' })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t('common.cancel')}</AlertDialogCancel>
<AlertDialogAction
onClick={() => handleDeleteMedia(item)}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{t('common.delete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
</div>

Expand Down
Loading
Loading