From 1c64359b18090c1eea71c7e7989ce5e6cf11bc11 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Thu, 27 Nov 2025 15:38:25 +0100 Subject: [PATCH] feat(presi): add share deck feature for public presentation viewing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/presi/CLAUDE.md | 44 ++- .../backend/src/share/share.controller.ts | 56 ++++ .../presi/apps/backend/src/share/share.dto.ts | 7 + .../apps/backend/src/share/share.module.ts | 12 + .../apps/backend/src/share/share.service.ts | 116 +++++++ apps/presi/apps/web/src/lib/api/client.ts | 46 +++ .../web/src/routes/deck/[id]/+page.svelte | 173 +++++++++- .../web/src/routes/shared/[code]/+page.svelte | 297 ++++++++++++++++++ 8 files changed, 736 insertions(+), 15 deletions(-) create mode 100644 apps/presi/apps/backend/src/share/share.controller.ts create mode 100644 apps/presi/apps/backend/src/share/share.dto.ts create mode 100644 apps/presi/apps/backend/src/share/share.module.ts create mode 100644 apps/presi/apps/backend/src/share/share.service.ts create mode 100644 apps/presi/apps/web/src/routes/shared/[code]/+page.svelte diff --git a/apps/presi/CLAUDE.md b/apps/presi/CLAUDE.md index 95623e158..534aa46ef 100644 --- a/apps/presi/CLAUDE.md +++ b/apps/presi/CLAUDE.md @@ -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 ``` diff --git a/apps/presi/apps/backend/src/share/share.controller.ts b/apps/presi/apps/backend/src/share/share.controller.ts new file mode 100644 index 000000000..87c841130 --- /dev/null +++ b/apps/presi/apps/backend/src/share/share.controller.ts @@ -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); + } +} diff --git a/apps/presi/apps/backend/src/share/share.dto.ts b/apps/presi/apps/backend/src/share/share.dto.ts new file mode 100644 index 000000000..61324ffa9 --- /dev/null +++ b/apps/presi/apps/backend/src/share/share.dto.ts @@ -0,0 +1,7 @@ +import { IsOptional, IsDateString } from 'class-validator'; + +export class CreateShareDto { + @IsOptional() + @IsDateString() + expiresAt?: string; +} diff --git a/apps/presi/apps/backend/src/share/share.module.ts b/apps/presi/apps/backend/src/share/share.module.ts new file mode 100644 index 000000000..ea53dd369 --- /dev/null +++ b/apps/presi/apps/backend/src/share/share.module.ts @@ -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 {} diff --git a/apps/presi/apps/backend/src/share/share.service.ts b/apps/presi/apps/backend/src/share/share.service.ts new file mode 100644 index 000000000..3e0c8a180 --- /dev/null +++ b/apps/presi/apps/backend/src/share/share.service.ts @@ -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 }; + } +} diff --git a/apps/presi/apps/web/src/lib/api/client.ts b/apps/presi/apps/web/src/lib/api/client.ts index 4465132e8..c94eccd82 100644 --- a/apps/presi/apps/web/src/lib/api/client.ts +++ b/apps/presi/apps/web/src/lib/api/client.ts @@ -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 { + 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 { + 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 { + const response = await fetchWithAuth(`${API_URL}/share/${shareId}`, { + method: 'DELETE' + }); + if (!response.ok) throw new Error('Failed to delete share link'); + } +}; diff --git a/apps/presi/apps/web/src/routes/deck/[id]/+page.svelte b/apps/presi/apps/web/src/routes/deck/[id]/+page.svelte index 9046e9dcb..4967ffe83 100644 --- a/apps/presi/apps/web/src/routes/deck/[id]/+page.svelte +++ b/apps/presi/apps/web/src/routes/deck/[id]/+page.svelte @@ -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(null); + // Share modal state + let showShareModal = $state(false); + let shareLinks = $state([]); + let isLoadingShares = $state(false); + let isCreatingShare = $state(false); + let copiedLinkId = $state(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); + } + } @@ -154,6 +215,13 @@ Add Slide + {#if decksStore.currentSlides.length > 0} {/if} + + +{#if showShareModal} +
+
+
+
+
+ +
+

Share Presentation

+
+ +
+ +
+ {#if isLoadingShares} +
+
+
+ {:else} +
+ + + + + {#if shareLinks.length > 0} +
+

Active Links

+ {#each shareLinks as share (share.id)} +
+
+
+ + {getShareUrl(share.shareCode)} +
+
+ Created {new Date(share.createdAt).toLocaleDateString()} + {#if share.expiresAt} + ยท Expires {new Date(share.expiresAt).toLocaleDateString()} + {/if} +
+
+
+ + + + + +
+
+ {/each} +
+ {:else} +

+ No share links yet. Create one to share this presentation. +

+ {/if} +
+ {/if} +
+ +
+

+ Anyone with the link can view this presentation without signing in. +

+
+
+
+{/if} diff --git a/apps/presi/apps/web/src/routes/shared/[code]/+page.svelte b/apps/presi/apps/web/src/routes/shared/[code]/+page.svelte new file mode 100644 index 000000000..ebe454b20 --- /dev/null +++ b/apps/presi/apps/web/src/routes/shared/[code]/+page.svelte @@ -0,0 +1,297 @@ + + + + {deck?.title || 'Shared Presentation'} - Presi + + +
+ {#if isLoading} +
+
+
+ {:else if error} +
+
+ +
+

Unable to load presentation

+

{error}

+ + Sign in to Presi + +
+ {:else if currentSlide} + +
+
+
+ + Presi +
+

{deck?.title}

+ + Slide {currentSlideIndex + 1} of {slides.length} + +
+ + Sign in + +
+ + +
+
+ {#if currentSlide.content.imageUrl} + {currentSlide.content.title + {:else} +
+ {#if currentSlide.content.title} +

{currentSlide.content.title}

+ {/if} + {#if currentSlide.content.body} +

{currentSlide.content.body}

+ {/if} + {#if currentSlide.content.bulletPoints?.length} +
    + {#each currentSlide.content.bulletPoints as point} +
  • + โ€ข + {point} +
  • + {/each} +
+ {/if} +
+ {/if} +
+
+ + +
+
+ +
+ +
+ + {formatTime(elapsedSeconds)} +
+
+ + +
+ + + +
+ {#each slides as _, index} + + {/each} +
+ + +
+ + +
+ +
+
+
+ {:else} +
+

No slides in this presentation

+ + Sign in to Presi + +
+ {/if} +