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
46 changes: 44 additions & 2 deletions frontend/src/pages/Album/AlbumDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,24 @@ import { useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useParams, useNavigate, useLocation } from 'react-router';
import { Button } from '@/components/ui/button';
import { ArrowLeft, Plus, Trash2 } from 'lucide-react';
import { ArrowLeft, Plus, Share2, Trash2 } from 'lucide-react';
import { ImageCard } from '@/components/Media/ImageCard';
import { MediaView } from '@/components/Media/MediaView';
import { AddImagesToAlbumDialog } from '@/components/Albums/AddImagesToAlbumDialog';
import { usePictoQuery, usePictoMutation } from '@/hooks/useQueryExtension';
import { ShareAlbumDialog } from '@/components/Albums/ShareAlbumDialog';
import {
usePictoQuery,
usePictoMutation,
type BackendRes,
} from '@/hooks/useQueryExtension';
import {
getAlbumById,
getAlbumImages,
getShares,
removeMultipleImagesFromAlbum,
fetchAllImages,
} from '@/api/api-functions';
import { Share } from '@/types/Share';
import {
setSelectedAlbum,
setAlbumImages,
Expand Down Expand Up @@ -52,6 +59,7 @@ export const AlbumDetail = () => {
const isImageViewOpen = useSelector(selectIsImageViewOpen);

const [isAddImagesDialogOpen, setIsAddImagesDialogOpen] = useState(false);
const [isShareDialogOpen, setIsShareDialogOpen] = useState(false);
const [selectedImages, setSelectedImages] = useState<Set<string>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);

Expand Down Expand Up @@ -88,6 +96,22 @@ export const AlbumDetail = () => {
// carries their details.
const isLoadingContent = isLoadingImages || isLoadingAllImages;

// Shares are held in memory on the backend, so this is the source of truth
// for whether this album is currently being served on the local network.
const {
successData: allShares,
isLoading: isLoadingShares,
isError: isSharesError,
refetch: refetchShares,
} = usePictoQuery<BackendRes<Share[]>, unknown, Share[]>({
queryKey: ['shares'],
queryFn: () => getShares(),
});

const albumShares = (allShares ?? []).filter(
(share) => share.album_id === albumId,
);

const removeImagesMutation = usePictoMutation({
mutationFn: ({
albumId,
Expand Down Expand Up @@ -286,6 +310,15 @@ export const AlbumDetail = () => {
Select Images
</Button>
)}
<Button
variant="outline"
size="sm"
onClick={() => setIsShareDialogOpen(true)}
disabled={isLoadingShares || isSharesError}
>
<Share2 className="mr-2 h-4 w-4" />
{albumShares.length > 0 ? 'Manage Share' : 'Share'}
</Button>
<Button
size="sm"
onClick={() => setIsAddImagesDialogOpen(true)}
Expand Down Expand Up @@ -359,6 +392,15 @@ export const AlbumDetail = () => {
albumId={albumId!}
albumName={album.name}
/>

{/* Share Dialog */}
<ShareAlbumDialog
album={album}
shares={albumShares}
isOpen={isShareDialogOpen}
onClose={() => setIsShareDialogOpen(false)}
onChanged={refetchShares}
/>
</div>
);
};
Expand Down
58 changes: 58 additions & 0 deletions frontend/src/pages/__tests__/AlbumDetail.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import AlbumDetail from '../Album/AlbumDetail';
import {
getAlbumById,
getAlbumImages,
getShares,
fetchAllImages,
} from '@/api/api-functions';

Expand All @@ -21,13 +22,15 @@ jest.mock('@tauri-apps/api/core', () => ({
jest.mock('@/api/api-functions', () => ({
getAlbumById: jest.fn(),
getAlbumImages: jest.fn(),
getShares: jest.fn(),
fetchAllImages: jest.fn(),
removeMultipleImagesFromAlbum: jest.fn(),
addImagesToAlbum: jest.fn(),
}));

const mockGetAlbumById = getAlbumById as jest.Mock;
const mockGetAlbumImages = getAlbumImages as jest.Mock;
const mockGetShares = getShares as jest.Mock;
const mockFetchAllImages = fetchAllImages as jest.Mock;

const AlbumDetailWithLoader = () => {
Expand Down Expand Up @@ -63,12 +66,67 @@ describe('AlbumDetail', () => {
},
});
mockGetAlbumImages.mockResolvedValue({ success: true, image_ids: ['i1'] });
mockGetShares.mockResolvedValue({ success: true, data: [] });
mockFetchAllImages.mockResolvedValue({
success: true,
data: [{ id: 'i1', path: '/p/i1.jpg', thumbnailPath: '/p/i1.jpg' }],
});
});

test('disables the share action until active shares resolve', async () => {
let releaseShares: (value: unknown) => void = () => {};
mockGetShares.mockImplementation(
() =>
new Promise((resolve) => {
releaseShares = resolve;
}),
);

renderDetail();

await screen.findByText('Trip');
const shareButton = screen.getByRole('button', { name: /share/i });
expect(shareButton).toBeDisabled();

releaseShares({ success: true, data: [] });

await waitFor(() => expect(shareButton).toBeEnabled());
});

test('displays "Manage Share" when an active share exists for this album', async () => {
mockGetShares.mockResolvedValue({
success: true,
data: [
{
token: 'tok-123',
album_id: 'a1',
album_name: 'Trip',
image_count: 1,
port: 52125,
created_at: '2026-08-19T00:00:00Z',
expires_at: null,
is_protected: false,
urls: [
{
interface: 'Wi-Fi',
ip: '192.168.1.5',
url: 'http://192.168.1.5:52125/s/tok-123',
},
],
},
],
});

renderDetail();

await screen.findByText('Trip');
await waitFor(() =>
expect(
screen.getByRole('button', { name: /manage share/i }),
).toBeInTheDocument(),
);
});

test('shows skeletons while images load instead of a blocking loader', async () => {
let releaseImages: (value: unknown) => void = () => {};
mockGetAlbumImages.mockImplementation(
Expand Down
Loading