From 6966f93228f2a4da840fd6957f420c10805b09f1 Mon Sep 17 00:00:00 2001 From: Jordan Dalby Date: Fri, 8 Nov 2024 23:42:59 +0000 Subject: [PATCH 1/9] Added ability to share snippets --- client/package.json | 1 + client/src/App.tsx | 19 +- client/src/api/auth.ts | 3 +- client/src/api/basePath.ts | 1 + client/src/api/share.ts | 71 ++++++++ client/src/api/snippets.ts | 2 +- .../src/components/snippets/SnippetCard.tsx | 15 +- .../src/components/snippets/SnippetList.tsx | 3 + .../components/snippets/SnippetStorage.tsx | 22 +++ .../components/snippets/share/ShareMenu.tsx | 170 ++++++++++++++++++ .../snippets/share/SharedSnippetView.tsx | 119 ++++++++++++ client/src/types/global.d.ts | 3 + client/src/types/types.ts | 15 ++ client/tsconfig.json | 8 +- package-lock.json | 42 +++++ server/src/app.js | 2 + server/src/config/database.js | 11 ++ server/src/repositories/shareRepository.js | 156 ++++++++++++++++ server/src/routes/shareRoutes.js | 79 ++++++++ 19 files changed, 730 insertions(+), 12 deletions(-) create mode 100644 client/src/api/basePath.ts create mode 100644 client/src/api/share.ts create mode 100644 client/src/components/snippets/share/ShareMenu.tsx create mode 100644 client/src/components/snippets/share/SharedSnippetView.tsx create mode 100644 client/src/types/global.d.ts create mode 100644 server/src/repositories/shareRepository.js create mode 100644 server/src/routes/shareRoutes.js diff --git a/client/package.json b/client/package.json index dfeb6cd..f30bcf6 100644 --- a/client/package.json +++ b/client/package.json @@ -11,6 +11,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", + "react-router-dom": "^6.28.0", "react-syntax-highlighter": "^15.6.1", "vite-plugin-monaco-editor": "^1.1.0" }, diff --git a/client/src/App.tsx b/client/src/App.tsx index 5837f77..2a8c55b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -3,6 +3,8 @@ import SnippetStorage from './components/snippets/SnippetStorage'; import { ToastProvider } from './components/toast/Toast'; import { AuthProvider, useAuth } from './context/AuthContext'; import LoginPage from './components/auth/LoginPage'; +import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; +import SharedSnippetView from './components/snippets/share/SharedSnippetView'; const AuthenticatedApp: React.FC = () => { const { isAuthenticated, isAuthRequired, isLoading } = useAuth(); @@ -24,13 +26,16 @@ const AuthenticatedApp: React.FC = () => { const App: React.FC = () => { return ( - - -
- -
-
-
+ + + + + } /> + } /> + + + + ); }; diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts index deb5e7f..c3bdbac 100644 --- a/client/src/api/auth.ts +++ b/client/src/api/auth.ts @@ -1,3 +1,5 @@ +import { basePath } from "./basePath"; + interface AuthConfig { authRequired: boolean; } @@ -6,7 +8,6 @@ interface LoginResponse { token: string; } -export const basePath = (window as any).__BASE_PATH__ || ''; export const AUTH_API_URL = `${basePath}/api/auth`; interface ApiError extends Error { diff --git a/client/src/api/basePath.ts b/client/src/api/basePath.ts new file mode 100644 index 0000000..50a64cc --- /dev/null +++ b/client/src/api/basePath.ts @@ -0,0 +1 @@ +export const basePath = (window as any).__BASE_PATH__ || ''; \ No newline at end of file diff --git a/client/src/api/share.ts b/client/src/api/share.ts new file mode 100644 index 0000000..a02d97c --- /dev/null +++ b/client/src/api/share.ts @@ -0,0 +1,71 @@ +import { ShareSettings, Share, Snippet } from '../types/types'; +import { basePath } from './basePath'; + +const SHARE_API_URL = `${basePath}/api/share`; + +export const createShare = async ( + snippetId: string, + settings: ShareSettings +): Promise => { + const response = await fetch(SHARE_API_URL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${localStorage.getItem('token')}` + }, + body: JSON.stringify({ + snippetId, + ...settings + }) + }); + + if (!response.ok) { + throw new Error('Failed to create share'); + } + + return response.json(); +}; + +export const getSharesBySnippetId = async (snippetId: string): Promise => { + const response = await fetch(`${SHARE_API_URL}/snippet/${snippetId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!response.ok) { + throw new Error('Failed to get shares'); + } + + return response.json(); +}; + +export const deleteShare = async (shareId: string): Promise => { + const response = await fetch(`${SHARE_API_URL}/${shareId}`, { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!response.ok) { + throw new Error('Failed to delete share'); + } +}; + +export const getSharedSnippet = async (shareId: string): Promise => { + const response = await fetch(`${SHARE_API_URL}/${shareId}`, { + headers: { + 'Authorization': `Bearer ${localStorage.getItem('token')}` + } + }); + + if (!response.ok) { + const data = await response.json(); + const error = new Error(data.error || 'Failed to get shared snippet') as any; + error.errorCode = response.status; + throw error; + } + + return response.json(); +}; \ No newline at end of file diff --git a/client/src/api/snippets.ts b/client/src/api/snippets.ts index c16a37f..6137cdf 100644 --- a/client/src/api/snippets.ts +++ b/client/src/api/snippets.ts @@ -1,6 +1,6 @@ import { Snippet } from '../types/types'; +import { basePath } from './basePath'; -export const basePath = (window as any).__BASE_PATH__ || ''; export const API_URL = `${basePath}/api/snippets`; interface ApiError extends Error { diff --git a/client/src/components/snippets/SnippetCard.tsx b/client/src/components/snippets/SnippetCard.tsx index 40ffed3..0f4ce1c 100644 --- a/client/src/components/snippets/SnippetCard.tsx +++ b/client/src/components/snippets/SnippetCard.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Pencil, Trash2, Clock, ChevronLeft, ChevronRight, FileCode } from 'lucide-react'; +import { Pencil, Trash2, Clock, ChevronLeft, ChevronRight, FileCode, Share } from 'lucide-react'; import { formatDistanceToNow } from 'date-fns'; import DeleteConfirmationModal from './DeleteConfirmationModal'; import { CodeFragment, Snippet } from '../../types/types'; @@ -14,6 +14,7 @@ export interface SnippetCardProps { onDelete: (id: string) => void; onEdit: (snippet: Snippet) => void; onCategoryClick: (category: string) => void; + onShare: (snippet: Snippet) => void; compactView: boolean; showCodePreview: boolean; previewLines: number; @@ -29,6 +30,7 @@ const SnippetCard: React.FC = ({ onDelete, onEdit, onCategoryClick, + onShare, compactView, showCodePreview, previewLines, @@ -121,6 +123,17 @@ const SnippetCard: React.FC = ({ + +
); }; diff --git a/client/src/components/snippets/share/ShareMenu.tsx b/client/src/components/snippets/share/ShareMenu.tsx new file mode 100644 index 0000000..acae1a8 --- /dev/null +++ b/client/src/components/snippets/share/ShareMenu.tsx @@ -0,0 +1,170 @@ +import React, { useState, useEffect } from 'react'; +import { Share as ShareIcon, Trash2, Link as LinkIcon } from 'lucide-react'; +import { Share, ShareSettings } from '../../../types/types'; +import { createShare, getSharesBySnippetId, deleteShare } from '../../../api/share'; +import { useToast } from '../../toast/Toast'; +import Modal from '../../common/Modal'; +import { basePath } from '../../../api/basePath'; + +interface ShareMenuProps { + snippetId: string; + isOpen: boolean; + onClose: () => void; +} + +const ShareMenu: React.FC = ({ snippetId, isOpen, onClose }) => { + const [shares, setShares] = useState([]); + const [requiresAuth, setRequiresAuth] = useState(false); + const [expiresIn, setExpiresIn] = useState(); + const { addToast } = useToast(); + + useEffect(() => { + if (isOpen) { + loadShares(); + } + }, [isOpen, snippetId]); + + const loadShares = async () => { + try { + const loadedShares = await getSharesBySnippetId(snippetId); + setShares(loadedShares); + } catch (error) { + addToast('Failed to load shares', 'error'); + } + }; + + const handleCreateShare = async () => { + try { + const settings: ShareSettings = { + requiresAuth, + expiresIn: expiresIn && expiresIn * 3600 + }; + + await createShare(snippetId, settings); + await loadShares(); + addToast('Share link created', 'success'); + + setRequiresAuth(false); + setExpiresIn(undefined); + } catch (error) { + addToast('Failed to create share link', 'error'); + } + }; + + const handleDeleteShare = async (shareId: string) => { + try { + await deleteShare(shareId); + setShares(shares.filter(share => share.id !== shareId)); + addToast('Share link deleted', 'success'); + } catch (error) { + addToast('Failed to delete share link', 'error'); + } + }; + + const copyShareLink = (shareId: string) => { + const url = `${window.location.origin}${basePath}/s/${shareId}`; + navigator.clipboard.writeText(url); + addToast('Share link copied to clipboard', 'success'); + }; + + return ( + + +

Share Snippet

+ + } + > +
+
+

Create New Share Link

+ +
+ + +
+ + setExpiresIn(e.target.value ? parseInt(e.target.value) : undefined)} + min="1" + placeholder="Never" + className="w-full px-3 py-2 bg-gray-700 rounded-md" + /> +
+ + +
+
+ +
+

Active Share Links

+ + {shares.length === 0 ? ( +

No active share links

+ ) : ( +
+ {shares.map(share => ( +
+
+
+ /{share.id} + {share.requiresAuth && ( + + Auth Required + + )} + {share.expiresAt && ( + + Expires {new Date(share.expiresAt).toLocaleDateString()} + + )} +
+
+
+ + +
+
+ ))} +
+ )} +
+
+
+ ); +}; + +export default ShareMenu; \ No newline at end of file diff --git a/client/src/components/snippets/share/SharedSnippetView.tsx b/client/src/components/snippets/share/SharedSnippetView.tsx new file mode 100644 index 0000000..1a55b76 --- /dev/null +++ b/client/src/components/snippets/share/SharedSnippetView.tsx @@ -0,0 +1,119 @@ +import React, { useState, useEffect } from 'react'; +import { useParams } from 'react-router-dom'; +import { getSharedSnippet } from '../../../api/share'; +import { Snippet } from '../../../types/types'; +import FullCodeBlock from '../FullCodeBlock'; +import CategoryList from '../categories/CategoryList'; +import { FileCode, Clock } from 'lucide-react'; +import { getLanguageLabel } from '../../../utils/languageUtils'; +import { formatDistanceToNow } from 'date-fns'; +import { useAuth } from '../../../context/AuthContext'; +import LoginPage from '../../auth/LoginPage'; + +const SharedSnippetView: React.FC = () => { + const { shareId } = useParams<{ shareId: string }>(); + const [snippet, setSnippet] = useState(null); + const [error, setError] = useState(null); + const [errorCode, setErrorCode] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const { isAuthenticated } = useAuth(); + + useEffect(() => { + loadSharedSnippet(); + }, [shareId, isAuthenticated]); + + const loadSharedSnippet = async () => { + if (!shareId) return; + + try { + setIsLoading(true); + const shared = await getSharedSnippet(shareId); + setSnippet(shared); + setError(null); + } catch (err: any) { + setErrorCode(err.errorCode); + setError(err.error); + } finally { + setIsLoading(false); + } + }; + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (errorCode === 401 && !isAuthenticated) { + return ; + } + + if (error) { + return ( +
+
{error}
+
+ ); + } + + if (!snippet) { + return ( +
+
Snippet not found
+
+ ); + } + + return ( +
+
+
+

{snippet.title}

+

{snippet.description}

+ +
+
+ + Updated {formatDistanceToNow(new Date(snippet.updated_at), { addSuffix: true })} +
+
+ + {snippet.categories.length > 0 && ( + {}} + className="mt-4" + variant="clickable" + showAll={true} + /> + )} +
+ +
+ {snippet.fragments.map((fragment, index) => ( +
+
+
+ + {fragment.file_name} +
+ {getLanguageLabel(fragment.language)} +
+
+ +
+
+ ))} +
+
+
+ ); +}; + +export default SharedSnippetView; \ No newline at end of file diff --git a/client/src/types/global.d.ts b/client/src/types/global.d.ts new file mode 100644 index 0000000..0ea3f92 --- /dev/null +++ b/client/src/types/global.d.ts @@ -0,0 +1,3 @@ +interface Window { + __BASE_PATH__: string; +} \ No newline at end of file diff --git a/client/src/types/types.ts b/client/src/types/types.ts index 49291c5..5c67ac7 100644 --- a/client/src/types/types.ts +++ b/client/src/types/types.ts @@ -23,4 +23,19 @@ export interface FragmentEditorProps { onDelete: () => void; showLineNumbers: boolean; dragHandleProps?: any; +} + +export interface ShareSettings { + requiresAuth: boolean; + expiresIn?: number; +} + +export interface Share { + id: string; + snippetId: string; + requiresAuth: boolean; + viewCount: number; + expiresAt: string; + createdAt: string; + expired: boolean; } \ No newline at end of file diff --git a/client/tsconfig.json b/client/tsconfig.json index cf92881..d201d01 100644 --- a/client/tsconfig.json +++ b/client/tsconfig.json @@ -18,8 +18,12 @@ "types": ["node"], "paths": { "@/*": ["./src/*"] - } + }, + "typeRoots": [ + "./node_modules/@types", + "./src/types" + ] }, - "include": ["src"], + "include": ["src/**/*"], "references": [{ "path": "./tsconfig.node.json" }] } \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 3e88ff9..b6dace7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "react": "^18.3.1", "react-dom": "^18.3.1", "react-markdown": "^9.0.1", + "react-router-dom": "^6.28.0", "react-syntax-highlighter": "^15.6.1", "vite-plugin-monaco-editor": "^1.1.0" }, @@ -1742,6 +1743,15 @@ "node": ">=14" } }, + "node_modules/@remix-run/router": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.21.0.tgz", + "integrity": "sha512-xfSkCAchbdG5PnbrKqFWwia4Bi61nH+wm8wLEqfHDyp7Y3dZzgqS2itV8i4gAq9pC2HsTpwyBC6Ds8VHZ96JlA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.24.0", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", @@ -8529,6 +8539,38 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.28.0.tgz", + "integrity": "sha512-HrYdIFqdrnhDw0PqG/AKjAqEqM7AvxCz0DQ4h2W8k6nqmc5uRBYDag0SBxx9iYz5G8gnuNVLzUe13wl9eAsXXg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.21.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.28.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.28.0.tgz", + "integrity": "sha512-kQ7Unsl5YdyOltsPGl31zOjLrDv+m2VcIEcIHqYYD3Lp0UppLjrzcfJqDJwXxFw3TH/yvapbnUvPlAj7Kx5nbg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.21.0", + "react-router": "6.28.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, "node_modules/react-syntax-highlighter": { "version": "15.6.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-15.6.1.tgz", diff --git a/server/src/app.js b/server/src/app.js index 472e201..8218885 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -2,6 +2,7 @@ const express = require('express'); const { initializeDatabase } = require('./config/database'); const snippetRoutes = require('./routes/snippetRoutes'); const authRoutes = require('./routes/authRoutes'); +const shareRoutes = require('./routes/shareRoutes') const { authenticateToken } = require('./middleware/auth'); const { join } = require('path'); const fs = require('fs'); @@ -17,6 +18,7 @@ const assetsPath = join(buildPath, 'assets'); app.use(`${basePath}/api/auth`, authRoutes); app.use(`${basePath}/api/snippets`, authenticateToken, snippetRoutes); +app.use(`${basePath}/api/share`, shareRoutes); app.use(`${basePath}/assets`, express.static(assetsPath)); app.use(`${basePath}/monacoeditorwork`, express.static(join(buildPath, 'monacoeditorwork'))); diff --git a/server/src/config/database.js b/server/src/config/database.js index cd4e2f2..b570910 100644 --- a/server/src/config/database.js +++ b/server/src/config/database.js @@ -143,6 +143,17 @@ function initializeDatabase() { CREATE INDEX IF NOT EXISTS idx_categories_snippet_id ON categories(snippet_id); CREATE INDEX IF NOT EXISTS idx_fragments_snippet_id ON fragments(snippet_id); + + CREATE TABLE IF NOT EXISTS shared_snippets ( + id TEXT PRIMARY KEY, + snippet_id INTEGER NOT NULL, + requires_auth BOOLEAN NOT NULL DEFAULT false, + expires_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (snippet_id) REFERENCES snippets(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_shared_snippets_snippet_id ON shared_snippets(snippet_id); `); console.log('Database initialized successfully'); diff --git a/server/src/repositories/shareRepository.js b/server/src/repositories/shareRepository.js new file mode 100644 index 0000000..24bc6fd --- /dev/null +++ b/server/src/repositories/shareRepository.js @@ -0,0 +1,156 @@ +const { getDb } = require('../config/database'); +const crypto = require('crypto'); + +class ShareRepository { + constructor() { + this.createShareStmt = null; + this.getShareStmt = null; + this.getSharesBySnippetIdStmt = null; + this.deleteShareStmt = null; + this.incrementViewCountStmt = null; + } + + #initializeStatements() { + const db = getDb(); + + if (!this.createShareStmt) { + this.createShareStmt = db.prepare(` + INSERT INTO shared_snippets ( + id, + snippet_id, + requires_auth, + expires_at + ) VALUES (?, ?, ?, datetime('now', '+' || ? || ' seconds')) + `); + + this.getFragmentsStmt = db.prepare(` + SELECT id, file_name, code, language, position + FROM fragments + WHERE snippet_id = ? + ORDER BY position + `); + + this.getShareStmt = db.prepare(` + SELECT + ss.id as share_id, + ss.requires_auth, + ss.expires_at, + ss.created_at, + datetime(ss.expires_at) < datetime('now') as expired, + s.id, + s.title, + s.description, + datetime(s.updated_at) || 'Z' as updated_at, + GROUP_CONCAT(DISTINCT c.name) as categories + FROM shared_snippets ss + JOIN snippets s ON s.id = ss.snippet_id + LEFT JOIN categories c ON s.id = c.snippet_id + WHERE ss.id = ? + GROUP BY s.id + `); + + this.getSharesBySnippetIdStmt = db.prepare(` + SELECT + ss.*, + datetime(ss.expires_at) < datetime('now') as expired + FROM shared_snippets ss + WHERE ss.snippet_id = ? + ORDER BY ss.created_at DESC + `); + + this.deleteShareStmt = db.prepare(` + DELETE FROM shared_snippets WHERE id = ? + `); + } + } + + #processShare(share) { + if (!share) return null; + + const fragments = this.getFragmentsStmt.all(share.id); + + return { + id: share.id, + title: share.title, + description: share.description, + updated_at: share.updated_at, + categories: share.categories ? share.categories.split(',') : [], + fragments: fragments.sort((a, b) => a.position - b.position), + share: { + id: share.share_id, + requiresAuth: !!share.requires_auth, + expiresAt: share.expires_at, + createdAt: share.created_at, + expired: !!share.expired, + } + }; + } + + async createShare({ snippetId, requiresAuth, expiresIn }) { + this.#initializeStatements(); + + const shareId = crypto.randomBytes(16).toString('hex'); + + try { + this.createShareStmt.run( + shareId, + snippetId, + requiresAuth ? 1 : 0, + expiresIn + ); + + return { + id: shareId, + snippetId, + requiresAuth, + viewCount: 0, + expiresIn + }; + } catch (error) { + console.error('Error in createShare:', error); + throw error; + } + } + + async getShare(id) { + this.#initializeStatements(); + try { + const share = this.getShareStmt.get(id); + return this.#processShare(share); + } catch (error) { + console.error('Error in getShare:', error); + throw error; + } + } + async getSharesBySnippetId(snippetId) { + this.#initializeStatements(); + try { + return this.getSharesBySnippetIdStmt.all(snippetId); + } catch (error) { + console.error('Error in getSharesBySnippetId:', error); + throw error; + } + } + + async deleteShare(id) { + this.#initializeStatements(); + try { + return this.deleteShareStmt.run(id); + } catch (error) { + console.error('Error in deleteShare:', error); + throw error; + } + } + + async incrementViewCount(id) { + this.#initializeStatements(); + try { + return this.incrementViewCountStmt.run(id); + } catch (error) { + console.error('Error in incrementViewCount:', error); + throw error; + } + } +} + +module.exports = new ShareRepository(); \ No newline at end of file diff --git a/server/src/routes/shareRoutes.js b/server/src/routes/shareRoutes.js new file mode 100644 index 0000000..8926396 --- /dev/null +++ b/server/src/routes/shareRoutes.js @@ -0,0 +1,79 @@ +const express = require('express'); +const jwt = require('jsonwebtoken'); +const { JWT_SECRET, authenticateToken } = require('../middleware/auth'); +const shareRepository = require('../repositories/shareRepository'); +const router = express.Router(); + +router.post('/', authenticateToken, async (req, res) => { + try { + const { snippetId, requiresAuth, expiresIn } = req.body; + const share = await shareRepository.createShare({ + snippetId, + requiresAuth: !!requiresAuth, + expiresIn: expiresIn ? parseInt(expiresIn) : null + }); + res.status(201).json(share); + } catch (error) { + console.error('Error creating share:', error); + res.status(500).json({ error: 'Failed to create share' }); + } +}); + +router.get('/:id', async (req, res) => { + try { + const { id } = req.params; + const share = await shareRepository.getShare(id); + + if (!share) { + return res.status(404).json({ error: 'Share not found' }); + } + + if (share.share?.requiresAuth) { + const authHeader = req.headers['authorization']; + const token = authHeader && authHeader.split(' ')[1]; + + if (!token) { + return res.status(401).json({ error: 'Authentication required' }); + } + + try { + jwt.verify(token, JWT_SECRET); + } catch (err) { + return res.status(401).json({ error: 'Invalid or expired token' }); + } + } + + if (share.expired) { + return res.status(410).json({ error: 'Share has expired' }); + } + + res.json(share); + } catch (error) { + console.error('Error getting share:', error); + res.status(500).json({ error: 'Failed to get share' }); + } +}); + +router.get('/snippet/:snippetId', authenticateToken, async (req, res) => { + try { + const { snippetId } = req.params; + const shares = await shareRepository.getSharesBySnippetId(snippetId); + res.json(shares); + } catch (error) { + console.error('Error listing shares:', error); + res.status(500).json({ error: 'Failed to list shares' }); + } +}); + +router.delete('/:id', authenticateToken, async (req, res) => { + try { + const { id } = req.params; + await shareRepository.deleteShare(id); + res.json({ success: true }); + } catch (error) { + console.error('Error deleting share:', error); + res.status(500).json({ error: 'Failed to delete share' }); + } +}); + +module.exports = router; \ No newline at end of file From 023e4d64857f560894fdbe7c919e65289294be54 Mon Sep 17 00:00:00 2001 From: Jordan Dalby Date: Fri, 8 Nov 2024 23:57:19 +0000 Subject: [PATCH 2/9] Moved share button to correct place --- .../src/components/snippets/SnippetCard.tsx | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/client/src/components/snippets/SnippetCard.tsx b/client/src/components/snippets/SnippetCard.tsx index 0f4ce1c..1e3797f 100644 --- a/client/src/components/snippets/SnippetCard.tsx +++ b/client/src/components/snippets/SnippetCard.tsx @@ -123,18 +123,17 @@ const SnippetCard: React.FC = ({ - -
+