Skip to content
Merged
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
4 changes: 4 additions & 0 deletions backend/src/modules/upload/upload.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
Controller,
Post,
Req,
UseGuards,
UseInterceptors,
UploadedFile,
BadRequestException,
Expand All @@ -12,6 +13,7 @@ import { memoryStorage } from 'multer';
import { join } from 'path';
import { existsSync, mkdirSync, writeFileSync } from 'fs';
import { Request } from 'express';
import { GqlAuthGuard } from '../../common/guards/gql-auth.guard';
import { StorageService } from './storage.service';

const AVATARS_DIR = 'uploads/avatars';
Expand Down Expand Up @@ -49,8 +51,10 @@ export function createImageFileFilter(): (
/**
* Upload controller: saves files to Google Cloud Storage (or disk when GCS not configured)
* and returns public URLs. Used for avatar, board/card background images.
* Protected by JWT (Bearer or cookie auth_token); same auth as GraphQL.
*/
@Controller('api/upload')
@UseGuards(GqlAuthGuard)
export class UploadController {
constructor(private readonly storage: StorageService) {}

Expand Down
18 changes: 17 additions & 1 deletion frontend/app/boards/[id]/cardEventHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
reorderCards,
deleteCard,
archiveCard,
updateCard,
} from '@/lib/actions/cards';
import { List, Card } from './types';
import { boardQueryKey } from './queries';
Expand Down Expand Up @@ -237,7 +238,7 @@ export function createCardEventHandlers(
);
}

function handleCardCompletedUpdate(
async function handleCardCompletedUpdate(
e?: DetailEvent<{ cardId: string; completed: boolean }>
) {
const detail = e?.detail;
Expand All @@ -249,6 +250,21 @@ export function createCardEventHandlers(
cards: (lst.cards || []).map((c) => (c.id === cardId ? { ...c, completed } : c)),
}))
);
try {
await updateCard({ id: cardId, completed });
invalidateBoard();
invalidateActivity();
} catch (err) {
setLists((prevLists) =>
prevLists.map((lst) => ({
...lst,
cards: (lst.cards || []).map((c) =>
c.id === cardId ? { ...c, completed: !completed } : c
),
}))
);
handleAsyncError(err, 'update completed');
}
}

async function handleCardDelete(e?: DetailEvent<{ cardId: string }>) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,21 @@ function getSafeImageSrc(url: string | undefined): string | undefined {
return undefined;
}

function BackgroundPreviewImg({ src }: { src: string }) {
function BackgroundPreviewImg({
src,
onError,
}: {
src: string;
onError?: () => void;
}) {
return (
<Image
src={src}
alt='Background preview'
fill
className='object-cover'
unoptimized
onError={onError}
/>
);
}
Expand All @@ -64,14 +71,16 @@ export function ChangeBackgroundDialog({
}: ChangeBackgroundDialogProps) {
const [backgroundInput, setBackgroundInput] = useState('');
const [localBackground, setLocalBackground] = useState<string | undefined>(
undefined
undefined,
);
const [previewError, setPreviewError] = useState(false);
const [updating, setUpdating] = useState(false);

useEffect(() => {
if (board) {
setBackgroundInput(board.background || '');
setLocalBackground(board.background || undefined);
setPreviewError(false);
}
}, [board, open]);

Expand All @@ -89,7 +98,9 @@ export function ChangeBackgroundDialog({
const { url } = await uploadBackground(file);
await saveBoardBackground(url);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to upload image');
toast.error(
err instanceof Error ? err.message : 'Failed to upload image',
);
} finally {
setUpdating(false);
e.target.value = '';
Expand Down Expand Up @@ -141,6 +152,7 @@ export function ChangeBackgroundDialog({
};

const safePreviewUrl = getSafeImageSrc(localBackground);
const showPreview = safePreviewUrl && !previewError;

return (
<Dialog open={open} onOpenChange={onOpenChange}>
Expand Down Expand Up @@ -174,9 +186,12 @@ export function ChangeBackgroundDialog({
</Button>
</Label>
</div>
{safePreviewUrl && (
{showPreview && (
<div className='relative w-full h-32 rounded-lg overflow-hidden border border-accent'>
<BackgroundPreviewImg src={safePreviewUrl} />
<BackgroundPreviewImg
src={safePreviewUrl}
onError={() => setPreviewError(true)}
/>
<Button
variant='destructive'
size='icon'
Expand Down
8 changes: 6 additions & 2 deletions frontend/app/boards/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,15 +144,19 @@ export default function BoardPage({
board.background.startsWith('http') ||
board.background.startsWith('https'));

const safeBackgroundUrl = isImageBackground && board.background
? `url("${String(board.background).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")`
: undefined;

return (
<div
className={`h-screen w-full ${
!isImageBackground ? board.background || 'bg-accent' : 'bg-muted/30'
}`}
style={
isImageBackground
safeBackgroundUrl
? {
backgroundImage: `url(${board.background})`,
backgroundImage: safeBackgroundUrl,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
Expand Down
2 changes: 1 addition & 1 deletion frontend/app/cards/CardsTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ export function CardsTable({
card.boardBackground?.startsWith('http') ||
card.boardBackground?.startsWith('/')
? {
backgroundImage: `url(${card.boardBackground})`,
backgroundImage: `url("${String(card.boardBackground).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
Expand Down
44 changes: 16 additions & 28 deletions frontend/app/settings/components/AvatarPicker.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import React, { useState, useEffect, useRef } from 'react';
import Image from 'next/image';
import { User, Upload } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
Expand Down Expand Up @@ -43,27 +44,9 @@ export function AvatarPicker({
const fileInputRef = useRef<HTMLInputElement>(null);
const url = (value ?? '').trim();

// Reset error when URL changes so we retry display (preload was hiding valid URLs on race/Strict Mode)
useEffect(() => {
if (!url) {
setPreviewUrl(null);
return;
}
let cancelled = false;
const img = new window.Image();
img.onload = () => {
if (!cancelled) {
setImgError(false);
setPreviewUrl(null);
}
};
img.onerror = () => {
if (!cancelled) setImgError(true);
};
img.src = url;
return () => {
cancelled = true;
img.src = '';
};
setImgError(false);
}, [url]);

const displayUrl = previewUrl ?? (isValidHttpUrl(url) && !imgError ? url : null);
Expand Down Expand Up @@ -119,23 +102,28 @@ export function AvatarPicker({
>
<div
className={cn(
'flex shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-border bg-muted bg-cover bg-center',
'flex shrink-0 items-center justify-center overflow-hidden rounded-full border-2 border-border bg-muted',
isLarge ? 'h-40 w-40' : 'h-24 w-24',
)}
style={
displayUrl && !showPlaceholder
? { backgroundImage: `url(${displayUrl})` }
: undefined
}
role="img"
aria-label={displayUrl ? 'Avatar preview' : 'Avatar placeholder'}
>
{showPlaceholder && (
{showPlaceholder ? (
<User
className={cn('text-muted-foreground', isLarge ? 'h-20 w-20' : 'h-10 w-10')}
aria-hidden
/>
)}
) : displayUrl ? (
<Image
src={displayUrl}
alt="Avatar preview"
width={isLarge ? 160 : 96}
height={isLarge ? 160 : 96}
className={cn('h-full w-full object-cover', isLarge ? 'h-40 w-40' : 'h-24 w-24')}
unoptimized
onError={() => setImgError(true)}
/>
) : null}
</div>
<div className={cn('flex flex-col gap-2', isLarge && 'items-center')}>
<input
Expand Down
8 changes: 6 additions & 2 deletions frontend/components/CardModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,7 @@ export default function CardModal({

const [headerBackground, setHeaderBackground] = useState<string | null>(null);
const [showBackgroundPicker, setShowBackgroundPicker] = useState(false);
const [backgroundUploading, setBackgroundUploading] = useState(false);

useEffect(() => {
if (
Expand Down Expand Up @@ -1044,13 +1045,15 @@ export default function CardModal({
return;
}

setBackgroundUploading(true);
try {
const { url } = await uploadBackground(file);
await saveBackground(url);
setHeaderBackground(null);
} catch (err) {
toast.error(err instanceof Error ? err.message : 'Failed to upload image');
} finally {
setBackgroundUploading(false);
e.target.value = '';
}
};
Expand Down Expand Up @@ -1280,9 +1283,9 @@ export default function CardModal({
: 'bg-background'
}`}
style={
isImageHeaderBackground
isImageHeaderBackground && background
? {
backgroundImage: `url(${background})`,
backgroundImage: `url("${String(background).replace(/\\/g, '\\\\').replace(/"/g, '\\"')}")`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
Expand Down Expand Up @@ -1369,6 +1372,7 @@ export default function CardModal({
removeBackground={removeBackground}
onImageUpload={handleImageUpload}
onClose={() => setShowBackgroundPicker(false)}
uploadingImage={backgroundUploading}
/>
</PopoverContent>
</Popover>
Expand Down
10 changes: 7 additions & 3 deletions frontend/components/CardModal/CardModalBackgroundPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export type CardModalBackgroundPickerProps = {
removeBackground: () => Promise<void>;
onImageUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
onClose: () => void;
/** True while an image is being uploaded (disables file input, shows loading). */
uploadingImage?: boolean;
};

export function CardModalBackgroundPicker({
Expand All @@ -25,6 +27,7 @@ export function CardModalBackgroundPicker({
removeBackground,
onImageUpload,
onClose,
uploadingImage = false,
}: CardModalBackgroundPickerProps) {
return (
<div className='space-y-4'>
Expand All @@ -47,15 +50,16 @@ export function CardModalBackgroundPicker({
type='file'
accept='image/*'
onChange={onImageUpload}
disabled={uploadingImage}
className='flex-1'
id='background-image-upload-header'
/>
<LabelComponent
htmlFor='background-image-upload-header'
className='cursor-pointer'
className={uploadingImage ? 'pointer-events-none opacity-70' : 'cursor-pointer'}
>
<Button variant='outline' size='sm' asChild>
<span>Choose File</span>
<Button variant='outline' size='sm' asChild disabled={uploadingImage}>
<span>{uploadingImage ? 'Chargement…' : 'Choose File'}</span>
</Button>
</LabelComponent>
</div>
Expand Down
85 changes: 0 additions & 85 deletions frontend/e2e/playwright-report/index.html

This file was deleted.

Loading
Loading