From 8bcca32804acabdbba692a8f7844608f82dad6df Mon Sep 17 00:00:00 2001 From: Jordan Dalby Date: Sat, 30 Nov 2024 11:28:03 +0000 Subject: [PATCH] Add embeddable snippets --- client/src/App.tsx | 27 +- .../components/common/buttons/CopyButton.tsx | 31 ++- client/src/components/common/modals/Modal.tsx | 10 +- .../src/components/editor/FullCodeBlock.tsx | 22 +- .../components/snippets/embed/EmbedModal.tsx | 160 +++++++++++ .../components/snippets/embed/EmbedView.tsx | 195 +++++++++++++ .../components/snippets/share/ShareMenu.tsx | 257 ++++++++++-------- .../snippets/view/SnippetStorage.tsx | 2 +- client/src/constants/routes.ts | 5 +- client/src/utils/helpers/embedUtils.ts | 23 ++ server/src/app.js | 4 +- server/src/routes/embedRoutes.js | 43 +++ 12 files changed, 646 insertions(+), 133 deletions(-) create mode 100644 client/src/components/snippets/embed/EmbedModal.tsx create mode 100644 client/src/components/snippets/embed/EmbedView.tsx create mode 100644 client/src/utils/helpers/embedUtils.ts create mode 100644 server/src/routes/embedRoutes.js diff --git a/client/src/App.tsx b/client/src/App.tsx index dbb76d1..7bb1b18 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom'; +import { BrowserRouter as Router, Route, Routes, Navigate, useParams } from 'react-router-dom'; import { AuthProvider } from './contexts/AuthContext'; import { ThemeProvider } from './contexts/ThemeContext'; import { useAuth } from './hooks/useAuth'; @@ -13,6 +13,7 @@ import SnippetStorage from './components/snippets/view/SnippetStorage'; import SharedSnippetView from './components/snippets/share/SharedSnippetView'; import SnippetPage from './components/snippets/view/SnippetPage'; import PublicSnippetStorage from './components/snippets/view/public/PublicSnippetStorage'; +import EmbedView from './components/snippets/embed/EmbedView'; const AuthenticatedApp: React.FC = () => { const { isAuthenticated, isLoading } = useAuth(); @@ -34,6 +35,29 @@ const AuthenticatedApp: React.FC = () => { return ; }; +const EmbedViewWrapper: React.FC = () => { + const { shareId } = useParams(); + const searchParams = new URLSearchParams(window.location.search); + + if (!shareId) { + return
Invalid share ID
; + } + + const theme = searchParams.get('theme') as 'light' | 'dark' | 'system' | null; + + return ( + + ); +}; + const App: React.FC = () => { return ( @@ -47,6 +71,7 @@ const App: React.FC = () => { } /> } /> } /> + } /> } /> } /> diff --git a/client/src/components/common/buttons/CopyButton.tsx b/client/src/components/common/buttons/CopyButton.tsx index 6f8a6fd..942264e 100644 --- a/client/src/components/common/buttons/CopyButton.tsx +++ b/client/src/components/common/buttons/CopyButton.tsx @@ -1,19 +1,23 @@ import React, { useState } from 'react'; import { Copy, Check } from 'lucide-react'; +import { useTheme } from '../../../contexts/ThemeContext'; export interface CopyButtonProps { text: string; + forceTheme?: 'light' | 'dark' | null; } -const CopyButton: React.FC = ({ text }) => { +const CopyButton: React.FC = ({ text, forceTheme = null }) => { const [isCopied, setIsCopied] = useState(false); + const { theme } = useTheme(); + const isDark = forceTheme ? forceTheme == 'dark' : theme === 'dark' || (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) const handleCopy = async (e: React.MouseEvent) => { e.stopPropagation(); try { - if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(text); - } else { + const isEmbedded = window !== window.parent; + + if (isEmbedded || !navigator.clipboard || !window.isSecureContext) { const textArea = document.createElement('textarea'); textArea.value = text; textArea.style.position = 'fixed'; @@ -24,30 +28,39 @@ const CopyButton: React.FC = ({ text }) => { textArea.select(); try { - document.execCommand('copy'); + const successful = document.execCommand('copy'); + if (!successful) { + throw new Error('Copy command failed'); + } } finally { textArea.remove(); } + } else { + await navigator.clipboard.writeText(text); } setIsCopied(true); setTimeout(() => setIsCopied(false), 2000); } catch (err) { console.error('Failed to copy text: ', err); + setIsCopied(false); } }; return ( ); diff --git a/client/src/components/common/modals/Modal.tsx b/client/src/components/common/modals/Modal.tsx index ab79dc0..56ff81e 100644 --- a/client/src/components/common/modals/Modal.tsx +++ b/client/src/components/common/modals/Modal.tsx @@ -27,7 +27,11 @@ const Modal: React.FC = ({ useEffect(() => { const handleClickOutside = (event: MouseEvent) => { - if (modalRef.current && !modalRef.current.contains(event.target as Node)) { + // Check if the click target is a modal backdrop (the semi-transparent overlay) + const isBackdropClick = (event.target as HTMLElement).classList.contains('modal-backdrop'); + + // Only close if clicking directly on the backdrop of this modal + if (isBackdropClick && modalRef.current?.parentElement === event.target) { onClose(); } }; @@ -56,7 +60,7 @@ const Modal: React.FC = ({ return (
= ({ ); }; -export default Modal; \ No newline at end of file +export default Modal; diff --git a/client/src/components/editor/FullCodeBlock.tsx b/client/src/components/editor/FullCodeBlock.tsx index d6cbe29..3bfe0b4 100644 --- a/client/src/components/editor/FullCodeBlock.tsx +++ b/client/src/components/editor/FullCodeBlock.tsx @@ -10,12 +10,14 @@ export interface FullCodeBlockProps { code: string; language?: string; showLineNumbers?: boolean; + forceTheme?: 'light' | 'dark' | null; } export const FullCodeBlock: React.FC = ({ code, language = 'plaintext', - showLineNumbers = true + showLineNumbers = true, + forceTheme = null }) => { const { theme } = useTheme(); const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>( @@ -25,6 +27,11 @@ export const FullCodeBlock: React.FC = ({ ); useEffect(() => { + if (forceTheme) { + setEffectiveTheme(forceTheme); + return; + } + if (theme === 'system') { const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); const handleChange = () => { @@ -92,6 +99,17 @@ export const FullCodeBlock: React.FC = ({ border-radius: 0.5rem; position: relative; } + .markdown-content-full pre, + .markdown-content-full code { + background-color: ${isDark ? '#2d2d2d' : '#ebebeb'} !important; + color: ${isDark ? '#e5e7eb' : '#1f2937'} !important; + } + .markdown-content-full pre code { + background-color: transparent !important; + padding: 0; + border: none; + box-shadow: none; + } :root { --text-color: ${isDark ? '#ffffff' : '#000000'}; } @@ -137,7 +155,7 @@ export const FullCodeBlock: React.FC = ({
)} - +
); diff --git a/client/src/components/snippets/embed/EmbedModal.tsx b/client/src/components/snippets/embed/EmbedModal.tsx new file mode 100644 index 0000000..85e5577 --- /dev/null +++ b/client/src/components/snippets/embed/EmbedModal.tsx @@ -0,0 +1,160 @@ +import React, { useState } from 'react'; +import { Code2 } from 'lucide-react'; +import Modal from '../../common/modals/Modal'; +import { Switch } from '../../common/switch/Switch'; +import { basePath } from '../../../utils/api/basePath'; +import { Snippet } from '../../../types/snippets'; +import { FullCodeBlock } from '../../editor/FullCodeBlock'; +import { generateEmbedId } from '../../../utils/helpers/embedUtils'; + +interface EmbedModalProps { + isOpen: boolean; + onClose: () => void; + shareId: string; + snippet: Snippet; +} + +export const EmbedModal: React.FC = ({ + isOpen, + onClose, + shareId, + snippet +}) => { + const [showTitle, setShowTitle] = useState(true); + const [showDescription, setShowDescription] = useState(true); + const [showFileHeaders, setShowFileHeaders] = useState(true); + const [showPoweredBy, setShowPoweredBy] = useState(true); + const [theme, setTheme] = useState<'light' | 'dark' | 'system'>('system'); + const [selectedFragment, setSelectedFragment] = useState(undefined); + + const getEmbedCode = () => { + const origin = window.location.origin; + const embedUrl = `${origin}${basePath}/embed/${shareId}?showTitle=${showTitle}&showDescription=${showDescription}&showFileHeaders=${showFileHeaders}&showPoweredBy=${showPoweredBy}&theme=${theme}${ + selectedFragment !== undefined ? `&fragmentIndex=${selectedFragment}` : '' + }`; + + const embedId = generateEmbedId({ + shareId, + showTitle, + showDescription, + showFileHeaders, + showPoweredBy, + theme, + fragmentIndex: selectedFragment + }); + + return ``; + }; + + const handleModalClick = (e: React.MouseEvent) => { + e.stopPropagation(); + }; + + return ( + + +

Embed Snippet

+ + } + > +
+
+

Customize Embed

+ +
+ + + + + + + + +
+ + +
+ +
+ + +
+
+
+ +
+

Embed Code

+ +
+
+
+ ); +}; + +export default EmbedModal; diff --git a/client/src/components/snippets/embed/EmbedView.tsx b/client/src/components/snippets/embed/EmbedView.tsx new file mode 100644 index 0000000..531a48c --- /dev/null +++ b/client/src/components/snippets/embed/EmbedView.tsx @@ -0,0 +1,195 @@ +import React, { useEffect, useState, useRef } from 'react'; +import { FileCode } from 'lucide-react'; +import { Snippet } from '../../../types/snippets'; +import { getLanguageLabel } from '../../../utils/language/languageUtils'; +import { FullCodeBlock } from '../../editor/FullCodeBlock'; +import { basePath } from '../../../utils/api/basePath'; +import { generateEmbedId } from '../../../utils/helpers/embedUtils'; + +interface EmbedViewProps { + shareId: string; + showTitle?: boolean; + showDescription?: boolean; + showFileHeaders?: boolean; + showPoweredBy?: boolean; + theme?: 'light' | 'dark' | 'system'; + fragmentIndex?: number; +} + +export const EmbedView: React.FC = ({ + shareId, + showTitle = false, + showDescription = false, + showFileHeaders = true, + showPoweredBy = true, + theme = 'system', + fragmentIndex +}) => { + const [snippet, setSnippet] = useState(null); + const [error, setError] = useState(null); + const [activeTheme, setActiveTheme] = useState<'light' | 'dark'>(theme === 'system' ? 'light' : theme); + const containerRef = useRef(null); + const isDark = activeTheme === 'dark'; + + const embedId = generateEmbedId({ + shareId, + showTitle, + showDescription, + showFileHeaders, + showPoweredBy, + theme, + fragmentIndex + }); + + useEffect(() => { + const updateTheme = (isDark: boolean) => { + const newTheme = isDark ? 'dark' : 'light'; + setActiveTheme(newTheme); + }; + + if (theme === 'system') { + const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + + const handleChange = (e: MediaQueryListEvent | MediaQueryList) => { + updateTheme(e.matches); + }; + + handleChange(mediaQuery); + mediaQuery.addEventListener('change', handleChange); + return () => mediaQuery.removeEventListener('change', handleChange); + } else { + updateTheme(theme === 'dark'); + } + }, [theme]); + + useEffect(() => { + const fetchSnippet = async () => { + try { + const response = await fetch( + `${basePath}/api/embed/${shareId}?` + + new URLSearchParams({ + showTitle: showTitle.toString(), + showDescription: showDescription.toString(), + showFileHeaders: showFileHeaders.toString(), + showPoweredBy: showPoweredBy.toString(), + theme: activeTheme, + ...(fragmentIndex !== undefined && { fragmentIndex: fragmentIndex.toString() }) + }) + ); + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error || 'Failed to load snippet'); + } + + const data = await response.json(); + setSnippet(data); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load snippet'); + } + }; + + fetchSnippet(); + }, [shareId, showTitle, showDescription, showFileHeaders, showPoweredBy, activeTheme, fragmentIndex]); + + useEffect(() => { + const updateHeight = () => { + if (containerRef.current) { + const height = containerRef.current.offsetHeight; + window.parent.postMessage({ type: 'resize', height, embedId }, '*'); + } + }; + + updateHeight(); + + const observer = new ResizeObserver(updateHeight); + if (containerRef.current) { + observer.observe(containerRef.current); + } + + return () => observer.disconnect(); + }, [snippet, embedId]); + + if (error) { + return ( +
+
+

{error}

+
+
+ ); + } + + if (!snippet) { + return ( +
+
+

Loading...

+
+
+ ); + } + + return ( +
+
+
+ {(showTitle || showDescription) && ( +
+ {showTitle && snippet.title && ( +

+ {snippet.title} +

+ )} + {showDescription && snippet.description && ( +

+ {snippet.description} +

+ )} +
+ )} + +
+ {snippet.fragments.map((fragment, index) => ( +
+ {showFileHeaders && ( +
+
+ + {fragment.file_name} +
+ + {getLanguageLabel(fragment.language)} + +
+ )} + + +
+ ))} +
+ + {showPoweredBy && ( +
+ + Powered by ByteStash + +
+ )} +
+
+
+ ); +}; + +export default EmbedView; diff --git a/client/src/components/snippets/share/ShareMenu.tsx b/client/src/components/snippets/share/ShareMenu.tsx index d1b6130..4b2a8eb 100644 --- a/client/src/components/snippets/share/ShareMenu.tsx +++ b/client/src/components/snippets/share/ShareMenu.tsx @@ -1,26 +1,29 @@ import React, { useState, useEffect } from 'react'; -import { Share as ShareIcon, Trash2, Link as LinkIcon, Check, ShieldCheck, ShieldOff } from 'lucide-react'; +import { Share as ShareIcon, Trash2, Link as LinkIcon, Check, ShieldCheck, ShieldOff, Code2 } from 'lucide-react'; import parseDuration from 'parse-duration'; import { formatDistanceToNow } from 'date-fns'; -import { Share, ShareSettings } from '../../../types/snippets'; +import { Share, ShareSettings, Snippet } from '../../../types/snippets'; import { useToast } from '../../../hooks/useToast'; import { createShare, deleteShare, getSharesBySnippetId } from '../../../utils/api/share'; import { basePath } from '../../../utils/api/basePath'; import Modal from '../../common/modals/Modal'; import { Switch } from '../../common/switch/Switch'; +import EmbedModal from '../embed/EmbedModal'; interface ShareMenuProps { - snippetId: string; isOpen: boolean; onClose: () => void; + snippet: Snippet; } -export const ShareMenu: React.FC = ({ snippetId, isOpen, onClose }) => { +export const ShareMenu: React.FC = ({ isOpen, onClose, snippet }) => { const [shares, setShares] = useState([]); const [requiresAuth, setRequiresAuth] = useState(false); const [expiresIn, setExpiresIn] = useState(''); const [durationError, setDurationError] = useState(''); const [copiedStates, setCopiedStates] = useState>({}); + const [selectedShareId, setSelectedShareId] = useState(null); + const [isEmbedModalOpen, setIsEmbedModalOpen] = useState(false); const { addToast } = useToast(); useEffect(() => { @@ -28,11 +31,11 @@ export const ShareMenu: React.FC = ({ snippetId, isOpen, onClose loadShares(); setCopiedStates({}); } - }, [isOpen, snippetId]); + }, [isOpen, snippet]); const loadShares = async () => { try { - const loadedShares = await getSharesBySnippetId(snippetId); + const loadedShares = await getSharesBySnippetId(snippet.id); setShares(loadedShares); } catch (error) { addToast('Failed to load shares', 'error'); @@ -55,7 +58,7 @@ export const ShareMenu: React.FC = ({ snippetId, isOpen, onClose expiresIn: expiresIn ? Math.floor(parseDuration(expiresIn, 's')!) : undefined }; - await createShare(snippetId, settings); + await createShare(snippet.id, settings); await loadShares(); addToast('Share link created', 'success'); @@ -107,6 +110,11 @@ export const ShareMenu: React.FC = ({ snippetId, isOpen, onClose } }; + const handleEmbedClick = (shareId: string) => { + setSelectedShareId(shareId); + setIsEmbedModalOpen(true); + }; + const getRelativeExpiryTime = (expiresAt: string): string => { try { const expiryDate = new Date(expiresAt); @@ -118,125 +126,146 @@ export const ShareMenu: React.FC = ({ snippetId, isOpen, onClose }; return ( - - -

Share Snippet

- - } - > -
-
-

Create New Share Link

- + <> + + +

Share Snippet

+
+ } + > +
- +

Create New Share Link

+ +
+ -
- - { - setExpiresIn(e.target.value); - setDurationError(''); - }} - placeholder="Never" - className="w-full px-3 py-2 bg-light-surface dark:bg-dark-surface text-light-text dark:text-dark-text rounded-md border border-light-border dark:border-dark-border focus:outline-none focus:ring-2 focus:ring-light-primary dark:focus:ring-dark-primary" - /> - {durationError && ( -

{durationError}

- )} -
+
+ + { + setExpiresIn(e.target.value); + setDurationError(''); + }} + placeholder="Never" + className="w-full px-3 py-2 bg-light-surface dark:bg-dark-surface text-light-text dark:text-dark-text rounded-md border border-light-border dark:border-dark-border focus:outline-none focus:ring-2 focus:ring-light-primary dark:focus:ring-dark-primary" + /> + {durationError && ( +

{durationError}

+ )} +
- + +
-
-
-

Active Share Links

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

No active share links

- ) : ( -
- {shares.map(share => ( -
-
-
- {share.id} - {share.requires_auth === 1 && ( - - - - )} - {share.requires_auth === 0 && ( - - - - )} -
- {share.expired === 1 && ( - - Expired - - )} - {share.expires_at && share.expired === 0 && ( - - {getRelativeExpiryTime(share.expires_at)} +
+

Active Share Links

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

No active share links

+ ) : ( +
+ {shares.map(share => ( +
+
+
+ {share.id} + {share.requires_auth === 1 && ( + + )} - {share.expires_at === null && ( - - Never Expires + {share.requires_auth === 0 && ( + + )} +
+ {share.expired === 1 && ( + + Expired + + )} + {share.expires_at && share.expired === 0 && ( + + {getRelativeExpiryTime(share.expires_at)} + + )} + {share.expires_at === null && ( + + Never Expires + + )} +
+
+ + + +
-
- - -
-
- ))} -
- )} + ))} +
+ )} +
-
- + + + {selectedShareId && ( + { + setIsEmbedModalOpen(false); + setSelectedShareId(null); + }} + shareId={selectedShareId} + snippet={snippet} + /> + )} + ); }; diff --git a/client/src/components/snippets/view/SnippetStorage.tsx b/client/src/components/snippets/view/SnippetStorage.tsx index 14420b7..3b29989 100644 --- a/client/src/components/snippets/view/SnippetStorage.tsx +++ b/client/src/components/snippets/view/SnippetStorage.tsx @@ -149,7 +149,7 @@ const SnippetStorage: React.FC = () => { {snippetToShare && ( diff --git a/client/src/constants/routes.ts b/client/src/constants/routes.ts index 83ed65b..353aa19 100644 --- a/client/src/constants/routes.ts +++ b/client/src/constants/routes.ts @@ -5,5 +5,6 @@ export const ROUTES = { LOGIN: '/login', REGISTER: '/register', PUBLIC_SNIPPETS: '/public/snippets', - AUTH_CALLBACK: '/auth/callback' -} as const; \ No newline at end of file + AUTH_CALLBACK: '/auth/callback', + EMBED: '/embed/:shareId' +} as const; diff --git a/client/src/utils/helpers/embedUtils.ts b/client/src/utils/helpers/embedUtils.ts new file mode 100644 index 0000000..5c224fc --- /dev/null +++ b/client/src/utils/helpers/embedUtils.ts @@ -0,0 +1,23 @@ +interface EmbedParams { + shareId: string; + showTitle: boolean; + showDescription: boolean; + showFileHeaders: boolean; + showPoweredBy: boolean; + theme: string; + fragmentIndex?: number; +} + +export const generateEmbedId = (params: EmbedParams): string => { + const paramsString = `${params.shareId}-${params.showTitle}-${params.showDescription}-${params.showFileHeaders}-${params.showPoweredBy}-${params.fragmentIndex ?? 'all'}`; + + let hash = 0; + for (let i = 0; i < paramsString.length; i++) { + const char = paramsString.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; + } + + const hashStr = Math.abs(hash).toString(16).padStart(16, '0').slice(0, 16); + return hashStr; +}; diff --git a/server/src/app.js b/server/src/app.js index 31f5dfe..79343b5 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -5,6 +5,7 @@ import authRoutes from './routes/authRoutes.js'; import shareRoutes from './routes/shareRoutes.js'; import publicRoutes from './routes/publicRoutes.js'; import oidcRoutes from './routes/oidcRoutes.js'; +import embedRoutes from './routes/embedRoutes.js'; import { authenticateToken } from './middleware/auth.js'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; @@ -29,6 +30,7 @@ app.use(`${basePath}/api/auth/oidc`, oidcRoutes); app.use(`${basePath}/api/snippets`, authenticateToken, snippetRoutes); app.use(`${basePath}/api/share`, shareRoutes); app.use(`${basePath}/api/public/snippets`, publicRoutes); +app.use(`${basePath}/api/embed`, embedRoutes); app.use(`${basePath}/assets`, express.static(assetsPath)); app.use(`${basePath}/monacoeditorwork`, express.static(join(buildPath, 'monacoeditorwork'))); @@ -100,4 +102,4 @@ function handleShutdown() { })(); process.on('SIGTERM', handleShutdown); -process.on('SIGINT', handleShutdown); \ No newline at end of file +process.on('SIGINT', handleShutdown); diff --git a/server/src/routes/embedRoutes.js b/server/src/routes/embedRoutes.js new file mode 100644 index 0000000..0375dcd --- /dev/null +++ b/server/src/routes/embedRoutes.js @@ -0,0 +1,43 @@ +import express from 'express'; +import shareRepository from '../repositories/shareRepository.js'; + +const router = express.Router(); + +router.get('/:shareId', async (req, res) => { + try { + const { shareId } = req.params; + const { showTitle, showDescription, fragmentIndex } = req.query; + + const snippet = await shareRepository.getShare(shareId); + if (!snippet) { + return res.status(404).json({ error: 'Snippet not found' }); + } + + if (snippet.share.expired) { + return res.status(404).json({ error: 'Share link has expired' }); + } + + if (snippet.share.requiresAuth && !req.user) { + return res.status(401).json({ error: 'Authentication required' }); + } + + const embedData = { + id: snippet.id, + title: showTitle === 'true' ? snippet.title : undefined, + description: showDescription === 'true' ? snippet.description : undefined, + language: snippet.language, + fragments: fragmentIndex !== undefined ? + [snippet.fragments[parseInt(fragmentIndex, 10)]] : + snippet.fragments, + created_at: snippet.created_at, + updated_at: snippet.updated_at + }; + + res.json(embedData); + } catch (error) { + console.error('Error in embed route:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + +export default router;