mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:21:10 +02:00
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:
parent
607ca19d4a
commit
1c64359b18
8 changed files with 736 additions and 15 deletions
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
56
apps/presi/apps/backend/src/share/share.controller.ts
Normal file
56
apps/presi/apps/backend/src/share/share.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
7
apps/presi/apps/backend/src/share/share.dto.ts
Normal file
7
apps/presi/apps/backend/src/share/share.dto.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { IsOptional, IsDateString } from 'class-validator';
|
||||
|
||||
export class CreateShareDto {
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
expiresAt?: string;
|
||||
}
|
||||
12
apps/presi/apps/backend/src/share/share.module.ts
Normal file
12
apps/presi/apps/backend/src/share/share.module.ts
Normal 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 {}
|
||||
116
apps/presi/apps/backend/src/share/share.service.ts
Normal file
116
apps/presi/apps/backend/src/share/share.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
297
apps/presi/apps/web/src/routes/shared/[code]/+page.svelte
Normal file
297
apps/presi/apps/web/src/routes/shared/[code]/+page.svelte
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue