This commit is contained in:
Danilo Reyes
2025-11-02 15:05:18 -06:00
parent 948fe591dc
commit c68a6a7d01
14 changed files with 1599 additions and 74 deletions

View File

@@ -13,6 +13,10 @@ export interface ApiError {
status_code: number;
}
export interface ApiRequestOptions extends RequestInit {
skipAuth?: boolean;
}
export class ApiClient {
private baseUrl: string;
@@ -20,16 +24,17 @@ export class ApiClient {
this.baseUrl = baseUrl;
}
private async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
private async request<T>(endpoint: string, options: ApiRequestOptions = {}): Promise<T> {
const { token } = get(authStore);
const { skipAuth, ...fetchOptions } = options;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...((options.headers as Record<string, string>) || {}),
...((fetchOptions.headers as Record<string, string>) || {}),
};
// Add authentication token if available
if (token) {
// Add authentication token if available and not skipped
if (token && !skipAuth) {
headers['Authorization'] = `Bearer ${token}`;
}
@@ -37,7 +42,7 @@ export class ApiClient {
try {
const response = await fetch(url, {
...options,
...fetchOptions,
headers,
});
@@ -74,11 +79,11 @@ export class ApiClient {
}
}
async get<T>(endpoint: string, options?: RequestInit): Promise<T> {
async get<T>(endpoint: string, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, { ...options, method: 'GET' });
}
async post<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
async post<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'POST',
@@ -86,7 +91,7 @@ export class ApiClient {
});
}
async put<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
async put<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'PUT',
@@ -94,7 +99,7 @@ export class ApiClient {
});
}
async patch<T>(endpoint: string, data?: unknown, options?: RequestInit): Promise<T> {
async patch<T>(endpoint: string, data?: unknown, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, {
...options,
method: 'PATCH',
@@ -102,7 +107,7 @@ export class ApiClient {
});
}
async delete<T>(endpoint: string, options?: RequestInit): Promise<T> {
async delete<T>(endpoint: string, options?: ApiRequestOptions): Promise<T> {
return this.request<T>(endpoint, { ...options, method: 'DELETE' });
}

View File

@@ -0,0 +1,142 @@
/**
* Sharing API client for board sharing and comments.
*/
import { apiClient } from './client';
export interface ShareLink {
id: string;
board_id: string;
token: string;
permission_level: 'view-only' | 'view-comment';
created_at: string;
expires_at: string | null;
last_accessed_at: string | null;
access_count: number;
is_revoked: boolean;
}
export interface ShareLinkCreate {
permission_level: 'view-only' | 'view-comment';
expires_at?: string | null;
}
export interface Comment {
id: string;
board_id: string;
share_link_id: string | null;
author_name: string;
content: string;
position: { x: number; y: number } | null;
created_at: string;
is_deleted: boolean;
}
export interface CommentCreate {
author_name: string;
content: string;
position?: { x: number; y: number } | null;
}
/**
* Create a new share link for a board.
*
* @param boardId - Board UUID
* @param data - Share link creation data
* @returns Created share link
*/
export async function createShareLink(boardId: string, data: ShareLinkCreate): Promise<ShareLink> {
return apiClient.post<ShareLink>(`/boards/${boardId}/share-links`, data);
}
/**
* List all share links for a board.
*
* @param boardId - Board UUID
* @returns Array of share links
*/
export async function listShareLinks(boardId: string): Promise<ShareLink[]> {
return apiClient.get<ShareLink[]>(`/boards/${boardId}/share-links`);
}
/**
* Revoke a share link.
*
* @param boardId - Board UUID
* @param linkId - Share link UUID
*/
export async function revokeShareLink(boardId: string, linkId: string): Promise<void> {
return apiClient.delete<void>(`/boards/${boardId}/share-links/${linkId}`);
}
export interface SharedBoard {
id: string;
user_id: string;
title: string;
description: string | null;
viewport_state: {
x: number;
y: number;
zoom: number;
rotation: number;
};
created_at: string;
updated_at: string;
is_deleted: boolean;
}
/**
* Get a shared board via token (no authentication required).
*
* @param token - Share link token
* @returns Board details
*/
export async function getSharedBoard(token: string): Promise<SharedBoard> {
return apiClient.get<SharedBoard>(`/shared/${token}`, { skipAuth: true });
}
/**
* Create a comment on a shared board.
*
* @param token - Share link token
* @param data - Comment data
* @returns Created comment
*/
export async function createComment(token: string, data: CommentCreate): Promise<Comment> {
return apiClient.post<Comment>(`/shared/${token}/comments`, data, {
skipAuth: true,
});
}
/**
* List comments on a shared board.
*
* @param token - Share link token
* @returns Array of comments
*/
export async function listComments(token: string): Promise<Comment[]> {
return apiClient.get<Comment[]>(`/shared/${token}/comments`, {
skipAuth: true,
});
}
/**
* List all comments on a board (owner view).
*
* @param boardId - Board UUID
* @returns Array of comments
*/
export async function listBoardComments(boardId: string): Promise<Comment[]> {
return apiClient.get<Comment[]>(`/boards/${boardId}/comments`);
}
/**
* Generate a shareable URL for a given token.
*
* @param token - Share link token
* @returns Full shareable URL
*/
export function getShareUrl(token: string): string {
const baseUrl = typeof window !== 'undefined' ? window.location.origin : '';
return `${baseUrl}/shared/${token}`;
}

View File

@@ -0,0 +1,314 @@
<script lang="ts">
import {
createShareLink,
listShareLinks,
revokeShareLink,
getShareUrl,
type ShareLink,
} from '$lib/api/sharing';
import { onMount } from 'svelte';
export let boardId: string;
export let onClose: () => void;
let shareLinks: ShareLink[] = [];
let permissionLevel: 'view-only' | 'view-comment' = 'view-only';
let loading = false;
let error = '';
onMount(async () => {
await loadShareLinks();
});
async function loadShareLinks() {
try {
loading = true;
shareLinks = await listShareLinks(boardId);
} catch (err) {
error = `Failed to load share links: ${err}`;
} finally {
loading = false;
}
}
async function handleCreateLink() {
try {
loading = true;
error = '';
await createShareLink(boardId, { permission_level: permissionLevel });
await loadShareLinks();
} catch (err) {
error = `Failed to create share link: ${err}`;
} finally {
loading = false;
}
}
async function handleRevokeLink(linkId: string) {
try {
loading = true;
error = '';
await revokeShareLink(boardId, linkId);
await loadShareLinks();
} catch (err) {
error = `Failed to revoke share link: ${err}`;
} finally {
loading = false;
}
}
function copyToClipboard(token: string) {
const url = getShareUrl(token);
navigator.clipboard.writeText(url);
}
function handleOverlayClick(event: MouseEvent) {
// Only close if clicking directly on the overlay, not its children
if (event.target === event.currentTarget) {
onClose();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
onClose();
}
}
</script>
<div
class="modal-overlay"
role="button"
tabindex="0"
on:click={handleOverlayClick}
on:keydown={handleKeyDown}
>
<div class="modal" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Share Board</h2>
<button class="close-btn" on:click={onClose}>×</button>
</div>
<div class="modal-body">
{#if error}
<div class="error-message">{error}</div>
{/if}
<div class="create-section">
<h3>Create New Share Link</h3>
<div class="form-group">
<label for="permission">Permission Level:</label>
<select id="permission" bind:value={permissionLevel}>
<option value="view-only">View Only</option>
<option value="view-comment">View + Comment</option>
</select>
</div>
<button class="btn-primary" on:click={handleCreateLink} disabled={loading}>
Create Link
</button>
</div>
<div class="links-section">
<h3>Existing Share Links</h3>
{#if loading}
<p>Loading...</p>
{:else if shareLinks.length === 0}
<p>No share links yet.</p>
{:else}
<div class="links-list">
{#each shareLinks as link}
<div class="link-item" class:revoked={link.is_revoked}>
<div class="link-info">
<span class="permission-badge">{link.permission_level}</span>
<span class="access-count">{link.access_count} views</span>
{#if link.is_revoked}
<span class="revoked-badge">Revoked</span>
{/if}
</div>
<div class="link-actions">
{#if !link.is_revoked}
<button class="btn-copy" on:click={() => copyToClipboard(link.token)}>
Copy Link
</button>
<button class="btn-danger" on:click={() => handleRevokeLink(link.id)}>
Revoke
</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
<style>
.modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
background: white;
border-radius: 8px;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.5rem;
}
.close-btn {
background: none;
border: none;
font-size: 2rem;
cursor: pointer;
color: #6b7280;
}
.modal-body {
padding: 1.5rem;
}
.error-message {
background: #fee2e2;
color: #991b1b;
padding: 0.75rem;
border-radius: 4px;
margin-bottom: 1rem;
}
.create-section,
.links-section {
margin-bottom: 2rem;
}
h3 {
font-size: 1.125rem;
margin-bottom: 1rem;
}
.form-group {
margin-bottom: 1rem;
}
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
select {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
}
.btn-primary {
background: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.links-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.link-item {
border: 1px solid #e5e7eb;
padding: 1rem;
border-radius: 4px;
}
.link-item.revoked {
opacity: 0.6;
background: #f3f4f6;
}
.link-info {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
}
.permission-badge,
.revoked-badge {
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.875rem;
}
.permission-badge {
background: #dbeafe;
color: #1e40af;
}
.revoked-badge {
background: #fee2e2;
color: #991b1b;
}
.access-count {
color: #6b7280;
font-size: 0.875rem;
}
.link-actions {
display: flex;
gap: 0.5rem;
}
.btn-copy,
.btn-danger {
padding: 0.375rem 0.75rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.btn-copy {
background: #10b981;
color: white;
}
.btn-danger {
background: #ef4444;
color: white;
}
</style>

View File

@@ -0,0 +1,263 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import {
getSharedBoard,
listComments,
createComment,
type Comment,
type SharedBoard,
} from '$lib/api/sharing';
const token = $page.params.token;
let board: SharedBoard | null = null;
let comments: Comment[] = [];
let loading = true;
let error = '';
let showCommentForm = false;
let commentAuthor = '';
let commentContent = '';
onMount(async () => {
await loadBoard();
await loadComments();
});
async function loadBoard() {
try {
board = await getSharedBoard(token);
} catch (err: any) {
error = err.error || 'Failed to load board';
} finally {
loading = false;
}
}
async function loadComments() {
try {
comments = await listComments(token);
} catch (err) {
// Comments might not be available for view-only links
console.error('Failed to load comments:', err);
}
}
async function handleSubmitComment() {
if (!commentAuthor || !commentContent) {
return;
}
try {
await createComment(token, {
author_name: commentAuthor,
content: commentContent,
});
commentContent = '';
showCommentForm = false;
await loadComments();
} catch (err: any) {
error = err.error || 'Failed to create comment';
}
}
</script>
<div class="shared-board-container">
{#if loading}
<div class="loading">Loading board...</div>
{:else if error}
<div class="error-message">{error}</div>
{:else if board}
<div class="board-header">
<h1>{board.title}</h1>
{#if board.description}
<p class="description">{board.description}</p>
{/if}
</div>
<div class="board-content">
<p>Board ID: {board.id}</p>
<p class="note">This is a shared view of the board. You're viewing it as a guest.</p>
</div>
<div class="comments-section">
<h2>Comments</h2>
{#if comments.length > 0}
<div class="comments-list">
{#each comments as comment}
<div class="comment">
<div class="comment-header">
<strong>{comment.author_name}</strong>
<span class="comment-date">
{new Date(comment.created_at).toLocaleString()}
</span>
</div>
<p class="comment-content">{comment.content}</p>
</div>
{/each}
</div>
{:else}
<p class="no-comments">No comments yet.</p>
{/if}
{#if !showCommentForm}
<button class="btn-add-comment" on:click={() => (showCommentForm = true)}>
Add Comment
</button>
{:else}
<div class="comment-form">
<input type="text" placeholder="Your name" bind:value={commentAuthor} />
<textarea placeholder="Your comment" bind:value={commentContent} rows="3" />
<div class="form-actions">
<button class="btn-submit" on:click={handleSubmitComment}> Submit </button>
<button class="btn-cancel" on:click={() => (showCommentForm = false)}> Cancel </button>
</div>
</div>
{/if}
</div>
{:else}
<div class="error-message">Board not found</div>
{/if}
</div>
<style>
.shared-board-container {
max-width: 1200px;
margin: 0 auto;
padding: 2rem;
}
.loading,
.error-message {
text-align: center;
padding: 2rem;
font-size: 1.125rem;
}
.error-message {
color: #ef4444;
background: #fee2e2;
border-radius: 8px;
}
.board-header {
margin-bottom: 2rem;
}
.board-header h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.description {
color: #6b7280;
font-size: 1.125rem;
}
.board-content {
background: #f9fafb;
padding: 1.5rem;
border-radius: 8px;
margin-bottom: 2rem;
}
.note {
color: #6b7280;
font-style: italic;
}
.comments-section {
margin-top: 2rem;
}
.comments-section h2 {
font-size: 1.5rem;
margin-bottom: 1rem;
}
.comments-list {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.comment {
background: white;
border: 1px solid #e5e7eb;
padding: 1rem;
border-radius: 8px;
}
.comment-header {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.comment-date {
color: #9ca3af;
font-size: 0.875rem;
}
.comment-content {
color: #374151;
line-height: 1.5;
}
.no-comments {
color: #9ca3af;
font-style: italic;
}
.btn-add-comment {
background: #3b82f6;
color: white;
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}
.comment-form {
background: #f9fafb;
padding: 1rem;
border-radius: 8px;
margin-top: 1rem;
}
.comment-form input,
.comment-form textarea {
width: 100%;
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 4px;
margin-bottom: 0.5rem;
font-family: inherit;
}
.form-actions {
display: flex;
gap: 0.5rem;
}
.btn-submit,
.btn-cancel {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-submit {
background: #10b981;
color: white;
}
.btn-cancel {
background: #e5e7eb;
color: #374151;
}
</style>