Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
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
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ Icon
!.gitignore
!.git/
!.github/
!.gitattributes

# Files that might appear in the root of a volume
.DocumentRevisions-V100
Expand Down
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
3 changes: 3 additions & 0 deletions client/public/_headers
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin

/assets/*
Cache-Control: public, max-age=31536000, immutable
52 changes: 46 additions & 6 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ 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 { useLanguagePreference } from "@/hooks/use-language-preference";
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 +68,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 +85,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 +102,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 +190,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'];
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 All @@ -197,6 +212,7 @@ function AppContent() {

useDocumentTitle();
useProvisioningStatusCheck();
useLanguagePreference();

useEffect(() => {
const hasSeenModal = localStorage.getItem("hasSeenWelcomeModal");
Expand All @@ -216,7 +232,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 +247,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