feat(presi): add share deck feature for public presentation viewing

Add ability to create shareable links for presentations that can be
viewed without authentication. Includes backend share module with
NestJS controller/service and web app routes for creating/managing
share links and viewing shared decks.

- Add ShareModule with public GET endpoint and authenticated CRUD
- Add shareApi methods to web API client
- Add /shared/[code] route for public deck viewing
- Add Share button and modal to deck editor
- Update CLAUDE.md with new endpoints and features

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-27 15:38:25 +01:00
parent 607ca19d4a
commit 1c64359b18
8 changed files with 736 additions and 15 deletions

View file

@ -71,19 +71,23 @@ pnpm db:seed # Seed database
### Backend API Endpoints
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/health` | GET | Health check |
| `/api/decks` | GET | Get user's decks |
| `/api/decks` | POST | Create new deck |
| `/api/decks/:id` | GET | Get deck details |
| `/api/decks/:id` | PUT | Update deck |
| `/api/decks/:id` | DELETE | Delete deck |
| `/api/decks/:id/slides` | GET | Get slides for deck |
| `/api/decks/:id/slides` | POST | Add slide to deck |
| `/api/slides/:id` | PUT | Update slide |
| `/api/slides/:id` | DELETE | Delete slide |
| `/api/slides/reorder` | POST | Reorder slides |
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/api/health` | GET | No | Health check |
| `/api/decks` | GET | Yes | Get user's decks |
| `/api/decks` | POST | Yes | Create new deck |
| `/api/decks/:id` | GET | Yes | Get deck details |
| `/api/decks/:id` | PUT | Yes | Update deck |
| `/api/decks/:id` | DELETE | Yes | Delete deck |
| `/api/decks/:id/slides` | GET | Yes | Get slides for deck |
| `/api/decks/:id/slides` | POST | Yes | Add slide to deck |
| `/api/slides/:id` | PUT | Yes | Update slide |
| `/api/slides/:id` | DELETE | Yes | Delete slide |
| `/api/slides/reorder` | PUT | Yes | Reorder slides |
| `/api/share/:code` | GET | No | Get shared deck (public) |
| `/api/share/deck/:id` | POST | Yes | Create share link |
| `/api/share/deck/:id/links` | GET | Yes | Get share links for deck |
| `/api/share/:shareId` | DELETE | Yes | Delete share link |
### Data Models
@ -110,6 +114,13 @@ pnpm db:seed # Seed database
**Theme** - Visual theme
- `id`, `name`, `colors`, `fonts`, `isDefault`
**SharedDeck** - Share link for deck
- `id` (string) - Unique identifier
- `deckId` (string) - Reference to deck
- `shareCode` (string) - Unique share code (12 chars)
- `expiresAt` (timestamp?) - Optional expiration
- `createdAt` (timestamp)
### Environment Variables
#### Backend (.env)
@ -161,7 +172,7 @@ Located at `packages/shared/`
The SvelteKit web app provides feature parity with the mobile app:
- **Authentication**: Login/Register with Mana Core Auth
- **Authentication**: Login/Register/Forgot Password with Mana Core Auth
- **Deck Management**: Create, edit, delete presentation decks
- **Slide Editor**: Create slides with title, body, bullet points, images
- **Presentation Mode**: Fullscreen presentation with keyboard navigation
@ -170,6 +181,8 @@ The SvelteKit web app provides feature parity with the mobile app:
- ESC to exit
- Timer with start/pause
- Speaker notes toggle
- **Sharing**: Create share links for decks, public view without auth
- **Profile**: View user info and deck statistics
- **Settings**: Theme switching (light/dark/system), account info
### Web App Structure
@ -185,8 +198,11 @@ src/
│ ├── +page.svelte # Deck list (home)
│ ├── login/ # Login page
│ ├── register/ # Register page
│ ├── forgot-password/ # Password reset page
│ ├── deck/[id]/ # Deck editor with slides
│ ├── present/[id]/ # Presentation mode
│ ├── shared/[code]/ # Public shared deck view
│ ├── profile/ # User profile page
│ └── settings/ # Settings page
└── app.css # Global styles
```

View file

@ -0,0 +1,56 @@
import {
Controller,
Get,
Post,
Delete,
Body,
Param,
UseGuards,
Request,
} from '@nestjs/common';
import { ShareService } from './share.service';
import { CreateShareDto } from './share.dto';
import { AuthGuard } from '../auth/auth.guard';
@Controller('share')
export class ShareController {
constructor(private readonly shareService: ShareService) {}
// Public endpoint - no auth required
@Get(':code')
async getSharedDeck(@Param('code') code: string) {
return this.shareService.findByShareCode(code);
}
// Authenticated endpoints
@Post('deck/:deckId')
@UseGuards(AuthGuard)
async createShare(
@Param('deckId') deckId: string,
@Body() createShareDto: CreateShareDto,
@Request() req: { user: { sub: string } },
) {
const expiresAt = createShareDto.expiresAt
? new Date(createShareDto.expiresAt)
: undefined;
return this.shareService.createShare(deckId, req.user.sub, expiresAt);
}
@Get('deck/:deckId/links')
@UseGuards(AuthGuard)
async getSharesForDeck(
@Param('deckId') deckId: string,
@Request() req: { user: { sub: string } },
) {
return this.shareService.getSharesForDeck(deckId, req.user.sub);
}
@Delete(':shareId')
@UseGuards(AuthGuard)
async deleteShare(
@Param('shareId') shareId: string,
@Request() req: { user: { sub: string } },
) {
return this.shareService.deleteShare(shareId, req.user.sub);
}
}

View file

@ -0,0 +1,7 @@
import { IsOptional, IsDateString } from 'class-validator';
export class CreateShareDto {
@IsOptional()
@IsDateString()
expiresAt?: string;
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ShareController } from './share.controller';
import { ShareService } from './share.service';
import { DeckModule } from '../deck/deck.module';
@Module({
imports: [DeckModule],
controllers: [ShareController],
providers: [ShareService],
exports: [ShareService],
})
export class ShareModule {}

View file

@ -0,0 +1,116 @@
import {
Injectable,
Inject,
NotFoundException,
ForbiddenException,
} from '@nestjs/common';
import { eq, and, gt, or, isNull } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { sharedDecks, decks, slides } from '../db/schema';
import { DeckService } from '../deck/deck.service';
import { randomBytes } from 'crypto';
@Injectable()
export class ShareService {
constructor(
@Inject(DATABASE_CONNECTION)
private readonly db: Database,
private readonly deckService: DeckService,
) {}
private generateShareCode(): string {
return randomBytes(6).toString('hex'); // 12 character code
}
async createShare(deckId: string, userId: string, expiresAt?: Date) {
// Verify ownership
const isOwner = await this.deckService.verifyOwnership(deckId, userId);
if (!isOwner) {
throw new ForbiddenException('You do not own this deck');
}
// Check if there's already a valid share
const existingShare = await this.db.query.sharedDecks.findFirst({
where: and(
eq(sharedDecks.deckId, deckId),
or(isNull(sharedDecks.expiresAt), gt(sharedDecks.expiresAt, new Date())),
),
});
if (existingShare) {
return existingShare;
}
// Create new share
const [share] = await this.db
.insert(sharedDecks)
.values({
deckId,
shareCode: this.generateShareCode(),
expiresAt: expiresAt || null,
})
.returning();
return share;
}
async findByShareCode(shareCode: string) {
const share = await this.db.query.sharedDecks.findFirst({
where: and(
eq(sharedDecks.shareCode, shareCode),
or(isNull(sharedDecks.expiresAt), gt(sharedDecks.expiresAt, new Date())),
),
with: {
deck: {
with: {
slides: {
orderBy: [slides.order],
},
theme: true,
},
},
},
});
if (!share) {
throw new NotFoundException('Shared deck not found or link has expired');
}
return share.deck;
}
async getSharesForDeck(deckId: string, userId: string) {
// Verify ownership
const isOwner = await this.deckService.verifyOwnership(deckId, userId);
if (!isOwner) {
throw new ForbiddenException('You do not own this deck');
}
return this.db.query.sharedDecks.findMany({
where: eq(sharedDecks.deckId, deckId),
});
}
async deleteShare(shareId: string, userId: string) {
const share = await this.db.query.sharedDecks.findFirst({
where: eq(sharedDecks.id, shareId),
with: {
deck: true,
},
});
if (!share) {
throw new NotFoundException('Share not found');
}
// Verify ownership of the deck
if (share.deck.userId !== userId) {
throw new ForbiddenException('You do not own this deck');
}
await this.db.delete(sharedDecks).where(eq(sharedDecks.id, shareId));
return { success: true };
}
}

View file

@ -207,3 +207,49 @@ export const slidesApi = {
if (!response.ok) throw new Error('Failed to reorder slides');
}
};
// Share API
export interface ShareLink {
id: string;
deckId: string;
shareCode: string;
expiresAt: string | null;
createdAt: string;
}
export const shareApi = {
// Public - no auth required
async getByCode(code: string): Promise<{ deck: any; slides: any[] }> {
const response = await fetch(`${API_URL}/share/${code}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Shared deck not found or link has expired');
}
throw new Error('Failed to fetch shared deck');
}
return response.json();
},
// Authenticated endpoints
async createShare(deckId: string, expiresAt?: string): Promise<ShareLink> {
const response = await fetchWithAuth(`${API_URL}/share/deck/${deckId}`, {
method: 'POST',
body: JSON.stringify({ expiresAt })
});
if (!response.ok) throw new Error('Failed to create share link');
return response.json();
},
async getSharesForDeck(deckId: string): Promise<ShareLink[]> {
const response = await fetchWithAuth(`${API_URL}/share/deck/${deckId}/links`);
if (!response.ok) throw new Error('Failed to get share links');
return response.json();
},
async deleteShare(shareId: string): Promise<void> {
const response = await fetchWithAuth(`${API_URL}/share/${shareId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete share link');
}
};

View file

@ -2,11 +2,13 @@
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { decksStore } from '$lib/stores/decks.svelte';
import { shareApi, type ShareLink } from '$lib/api/client';
import type { Slide, SlideContent } from '@presi/shared';
import {
ArrowLeft, Play, Plus, Trash2, GripVertical, ChevronUp, ChevronDown,
Image, Type, List, Edit3, X, Save
Image, Type, List, Edit3, X, Save, Share2, Link, Copy, Check, ExternalLink
} from 'lucide-svelte';
let showSlideModal = $state(false);
@ -14,6 +16,13 @@
let showDeleteModal = $state(false);
let slideToDelete = $state<Slide | null>(null);
// Share modal state
let showShareModal = $state(false);
let shareLinks = $state<ShareLink[]>([]);
let isLoadingShares = $state(false);
let isCreatingShare = $state(false);
let copiedLinkId = $state<string | null>(null);
// Slide form state
let slideTitle = $state('');
let slideBody = $state('');
@ -117,6 +126,58 @@
function updateBulletPoint(index: number, value: string) {
slideBulletPoints[index] = value;
}
// Share functions
async function openShareModal() {
showShareModal = true;
isLoadingShares = true;
try {
shareLinks = await shareApi.getSharesForDeck(deckId);
} catch (e) {
console.error('Failed to load share links:', e);
} finally {
isLoadingShares = false;
}
}
async function createShareLink() {
isCreatingShare = true;
try {
const newShare = await shareApi.createShare(deckId);
shareLinks = [newShare, ...shareLinks];
} catch (e) {
console.error('Failed to create share link:', e);
} finally {
isCreatingShare = false;
}
}
async function deleteShareLink(shareId: string) {
try {
await shareApi.deleteShare(shareId);
shareLinks = shareLinks.filter(s => s.id !== shareId);
} catch (e) {
console.error('Failed to delete share link:', e);
}
}
function getShareUrl(shareCode: string): string {
if (!browser) return '';
return `${window.location.origin}/shared/${shareCode}`;
}
async function copyShareLink(share: ShareLink) {
const url = getShareUrl(share.shareCode);
try {
await navigator.clipboard.writeText(url);
copiedLinkId = share.id;
setTimeout(() => {
copiedLinkId = null;
}, 2000);
} catch (e) {
console.error('Failed to copy link:', e);
}
}
</script>
<svelte:head>
@ -154,6 +215,13 @@
<Plus class="w-5 h-5" />
Add Slide
</button>
<button
onclick={openShareModal}
class="flex items-center gap-2 px-4 py-2 bg-slate-100 dark:bg-slate-700 hover:bg-slate-200 dark:hover:bg-slate-600 text-slate-700 dark:text-slate-200 font-medium rounded-lg transition-colors"
>
<Share2 class="w-5 h-5" />
Share
</button>
{#if decksStore.currentSlides.length > 0}
<a
href="/present/{deckId}"
@ -426,3 +494,106 @@
</div>
</div>
{/if}
<!-- Share Modal -->
{#if showShareModal}
<div class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div class="bg-white dark:bg-slate-800 rounded-xl shadow-xl w-full max-w-lg">
<div class="p-6 border-b border-slate-200 dark:border-slate-700 flex items-center justify-between">
<div class="flex items-center gap-3">
<div class="p-2 bg-primary-100 dark:bg-primary-900/30 rounded-lg">
<Share2 class="w-5 h-5 text-primary-600 dark:text-primary-400" />
</div>
<h2 class="text-xl font-semibold text-slate-900 dark:text-white">Share Presentation</h2>
</div>
<button
onclick={() => showShareModal = false}
class="p-2 hover:bg-slate-100 dark:hover:bg-slate-700 rounded-lg"
>
<X class="w-5 h-5 text-slate-600 dark:text-slate-400" />
</button>
</div>
<div class="p-6">
{#if isLoadingShares}
<div class="flex items-center justify-center py-8">
<div class="animate-spin rounded-full h-8 w-8 border-4 border-primary-500 border-t-transparent"></div>
</div>
{:else}
<div class="space-y-4">
<!-- Create new link button -->
<button
onclick={createShareLink}
disabled={isCreatingShare}
class="w-full flex items-center justify-center gap-2 px-4 py-3 border-2 border-dashed border-slate-300 dark:border-slate-600 rounded-lg text-slate-600 dark:text-slate-400 hover:border-primary-500 hover:text-primary-600 dark:hover:text-primary-400 transition-colors disabled:opacity-50"
>
<Link class="w-5 h-5" />
{isCreatingShare ? 'Creating...' : 'Create Share Link'}
</button>
<!-- Existing links -->
{#if shareLinks.length > 0}
<div class="space-y-3 mt-4">
<h3 class="text-sm font-medium text-slate-700 dark:text-slate-300">Active Links</h3>
{#each shareLinks as share (share.id)}
<div class="flex items-center gap-3 p-3 bg-slate-50 dark:bg-slate-700/50 rounded-lg">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 text-sm font-mono text-slate-600 dark:text-slate-400">
<Link class="w-4 h-4 flex-shrink-0" />
<span class="truncate">{getShareUrl(share.shareCode)}</span>
</div>
<div class="text-xs text-slate-500 dark:text-slate-500 mt-1">
Created {new Date(share.createdAt).toLocaleDateString()}
{#if share.expiresAt}
· Expires {new Date(share.expiresAt).toLocaleDateString()}
{/if}
</div>
</div>
<div class="flex items-center gap-1">
<a
href="/shared/{share.shareCode}"
target="_blank"
class="p-2 hover:bg-slate-200 dark:hover:bg-slate-600 rounded-lg transition-colors"
title="Open in new tab"
>
<ExternalLink class="w-4 h-4 text-slate-600 dark:text-slate-400" />
</a>
<button
onclick={() => copyShareLink(share)}
class="p-2 hover:bg-slate-200 dark:hover:bg-slate-600 rounded-lg transition-colors"
title="Copy link"
>
{#if copiedLinkId === share.id}
<Check class="w-4 h-4 text-green-600" />
{:else}
<Copy class="w-4 h-4 text-slate-600 dark:text-slate-400" />
{/if}
</button>
<button
onclick={() => deleteShareLink(share.id)}
class="p-2 hover:bg-red-50 dark:hover:bg-red-900/30 rounded-lg transition-colors"
title="Delete link"
>
<Trash2 class="w-4 h-4 text-red-500" />
</button>
</div>
</div>
{/each}
</div>
{:else}
<p class="text-center text-sm text-slate-500 dark:text-slate-400 py-4">
No share links yet. Create one to share this presentation.
</p>
{/if}
</div>
{/if}
</div>
<div class="px-6 py-4 bg-slate-50 dark:bg-slate-900/50 rounded-b-xl">
<p class="text-xs text-slate-500 dark:text-slate-400 text-center">
Anyone with the link can view this presentation without signing in.
</p>
</div>
</div>
</div>
{/if}

View file

@ -0,0 +1,297 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { page } from '$app/stores';
import { shareApi } from '$lib/api/client';
import type { Slide } from '@presi/shared';
import {
ChevronLeft, ChevronRight, Play, Pause,
Maximize, Minimize, Clock, Presentation, AlertCircle
} from 'lucide-svelte';
let deck = $state<any>(null);
let slides = $state<Slide[]>([]);
let isLoading = $state(true);
let error = $state('');
let currentSlideIndex = $state(0);
let isFullscreen = $state(false);
let isTimerRunning = $state(false);
let elapsedSeconds = $state(0);
let showControls = $state(true);
let hideControlsTimeout: ReturnType<typeof setTimeout> | null = null;
let timerInterval: ReturnType<typeof setInterval> | null = null;
const shareCode = $page.params.code;
onMount(async () => {
try {
const data = await shareApi.getByCode(shareCode);
deck = data;
slides = data.slides || [];
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load shared deck';
} finally {
isLoading = false;
}
// Keyboard navigation
window.addEventListener('keydown', handleKeydown);
window.addEventListener('mousemove', handleMouseMove);
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => {
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('fullscreenchange', handleFullscreenChange);
if (timerInterval) clearInterval(timerInterval);
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
};
});
function handleKeydown(e: KeyboardEvent) {
switch (e.key) {
case 'ArrowLeft':
case 'a':
prevSlide();
break;
case 'ArrowRight':
case 'd':
case ' ':
nextSlide();
break;
case 'f':
toggleFullscreen();
break;
}
resetHideControlsTimer();
}
function handleMouseMove() {
showControls = true;
resetHideControlsTimer();
}
function resetHideControlsTimer() {
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
hideControlsTimeout = setTimeout(() => {
showControls = false;
}, 3000);
}
function handleFullscreenChange() {
isFullscreen = !!document.fullscreenElement;
}
function prevSlide() {
if (currentSlideIndex > 0) {
currentSlideIndex--;
}
}
function nextSlide() {
if (currentSlideIndex < slides.length - 1) {
currentSlideIndex++;
}
}
function goToSlide(index: number) {
currentSlideIndex = index;
}
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen();
} else {
document.exitFullscreen();
}
}
function toggleTimer() {
isTimerRunning = !isTimerRunning;
if (isTimerRunning) {
timerInterval = setInterval(() => {
elapsedSeconds++;
}, 1000);
} else if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
}
function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, '0')}`;
}
const currentSlide = $derived(slides[currentSlideIndex]);
</script>
<svelte:head>
<title>{deck?.title || 'Shared Presentation'} - Presi</title>
</svelte:head>
<div class="fixed inset-0 bg-slate-900 text-white flex flex-col">
{#if isLoading}
<div class="flex-1 flex items-center justify-center">
<div class="animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"></div>
</div>
{:else if error}
<div class="flex-1 flex flex-col items-center justify-center px-4">
<div class="p-4 bg-red-900/30 rounded-full mb-4">
<AlertCircle class="w-12 h-12 text-red-400" />
</div>
<h1 class="text-2xl font-bold mb-2">Unable to load presentation</h1>
<p class="text-slate-400 text-center max-w-md mb-6">{error}</p>
<a
href="/login"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg font-medium transition-colors"
>
Sign in to Presi
</a>
</div>
{:else if currentSlide}
<!-- Top Bar -->
<div
class="absolute top-0 left-0 right-0 z-10 p-4 flex items-center justify-between bg-gradient-to-b from-black/50 to-transparent transition-opacity duration-300"
class:opacity-0={!showControls}
class:pointer-events-none={!showControls}
>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 text-primary-400">
<Presentation class="w-5 h-5" />
<span class="text-sm font-medium">Presi</span>
</div>
<h1 class="text-lg font-medium truncate max-w-xs">{deck?.title}</h1>
<span class="text-sm text-slate-400">
Slide {currentSlideIndex + 1} of {slides.length}
</span>
</div>
<a
href="/login"
class="px-4 py-1.5 bg-primary-600 hover:bg-primary-700 rounded-lg text-sm font-medium transition-colors"
>
Sign in
</a>
</div>
<!-- Main Slide Area -->
<div class="flex-1 flex items-center justify-center p-8 pt-20 pb-32">
<div class="w-full max-w-6xl aspect-video bg-slate-800 rounded-2xl shadow-2xl overflow-hidden flex flex-col items-center justify-center p-12">
{#if currentSlide.content.imageUrl}
<img
src={currentSlide.content.imageUrl}
alt={currentSlide.content.title || 'Slide image'}
class="max-w-full max-h-full object-contain"
/>
{:else}
<div class="text-center max-w-4xl">
{#if currentSlide.content.title}
<h2 class="text-4xl md:text-5xl lg:text-6xl font-bold mb-8">{currentSlide.content.title}</h2>
{/if}
{#if currentSlide.content.body}
<p class="text-xl md:text-2xl text-slate-300 mb-8">{currentSlide.content.body}</p>
{/if}
{#if currentSlide.content.bulletPoints?.length}
<ul class="text-left text-xl md:text-2xl space-y-4 mx-auto max-w-2xl">
{#each currentSlide.content.bulletPoints as point}
<li class="flex items-start gap-4">
<span class="text-primary-400 mt-1"></span>
<span>{point}</span>
</li>
{/each}
</ul>
{/if}
</div>
{/if}
</div>
</div>
<!-- Bottom Controls -->
<div
class="absolute bottom-0 left-0 right-0 z-10 p-4 bg-gradient-to-t from-black/50 to-transparent transition-opacity duration-300"
class:opacity-0={!showControls}
class:pointer-events-none={!showControls}
>
<div class="max-w-4xl mx-auto flex items-center justify-between">
<!-- Left: Timer -->
<div class="flex items-center gap-4">
<button
onclick={toggleTimer}
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
aria-label={isTimerRunning ? 'Pause timer' : 'Start timer'}
>
{#if isTimerRunning}
<Pause class="w-5 h-5" />
{:else}
<Play class="w-5 h-5" />
{/if}
</button>
<div class="flex items-center gap-2 text-slate-300">
<Clock class="w-4 h-4" />
<span class="font-mono">{formatTime(elapsedSeconds)}</span>
</div>
</div>
<!-- Center: Navigation -->
<div class="flex items-center gap-2">
<button
onclick={prevSlide}
disabled={currentSlideIndex === 0}
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
aria-label="Previous slide"
>
<ChevronLeft class="w-6 h-6" />
</button>
<!-- Slide Dots -->
<div class="flex items-center gap-2 px-4">
{#each slides as _, index}
<button
onclick={() => goToSlide(index)}
class="w-2 h-2 rounded-full transition-all"
class:bg-primary-500={index === currentSlideIndex}
class:w-4={index === currentSlideIndex}
class:bg-slate-500={index !== currentSlideIndex}
aria-label="Go to slide {index + 1}"
></button>
{/each}
</div>
<button
onclick={nextSlide}
disabled={currentSlideIndex === slides.length - 1}
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
aria-label="Next slide"
>
<ChevronRight class="w-6 h-6" />
</button>
</div>
<!-- Right: Fullscreen -->
<div class="flex items-center gap-2">
<button
onclick={toggleFullscreen}
class="p-2 hover:bg-white/10 rounded-lg transition-colors"
aria-label={isFullscreen ? 'Exit fullscreen' : 'Enter fullscreen'}
>
{#if isFullscreen}
<Minimize class="w-5 h-5" />
{:else}
<Maximize class="w-5 h-5" />
{/if}
</button>
</div>
</div>
</div>
{:else}
<div class="flex-1 flex flex-col items-center justify-center">
<p class="text-slate-400 mb-4">No slides in this presentation</p>
<a
href="/login"
class="px-6 py-2 bg-primary-600 hover:bg-primary-700 rounded-lg font-medium transition-colors"
>
Sign in to Presi
</a>
</div>
{/if}
</div>