phase 14
This commit is contained in:
@@ -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' });
|
||||
}
|
||||
|
||||
|
||||
142
frontend/src/lib/api/sharing.ts
Normal file
142
frontend/src/lib/api/sharing.ts
Normal 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}`;
|
||||
}
|
||||
314
frontend/src/lib/components/sharing/ShareModal.svelte
Normal file
314
frontend/src/lib/components/sharing/ShareModal.svelte
Normal 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>
|
||||
263
frontend/src/routes/shared/[token]/+page.svelte
Normal file
263
frontend/src/routes/shared/[token]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user