Skip to content

Commit

Permalink
Add embeddable snippets
Browse files Browse the repository at this point in the history
  • Loading branch information
jordan-dalby committed Nov 30, 2024
1 parent f880f96 commit 8bcca32
Show file tree
Hide file tree
Showing 12 changed files with 646 additions and 133 deletions.
27 changes: 26 additions & 1 deletion client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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();
Expand All @@ -34,6 +35,29 @@ const AuthenticatedApp: React.FC = () => {
return <SnippetStorage />;
};

const EmbedViewWrapper: React.FC = () => {
const { shareId } = useParams();
const searchParams = new URLSearchParams(window.location.search);

if (!shareId) {
return <div>Invalid share ID</div>;
}

const theme = searchParams.get('theme') as 'light' | 'dark' | 'system' | null;

return (
<EmbedView
shareId={shareId}
showTitle={searchParams.get('showTitle') === 'true'}
showDescription={searchParams.get('showDescription') === 'true'}
showFileHeaders={searchParams.get('showFileHeaders') !== 'false'}
showPoweredBy={searchParams.get('showPoweredBy') !== 'false'}
theme={theme || 'system'}
fragmentIndex={searchParams.get('fragmentIndex') ? parseInt(searchParams.get('fragmentIndex')!, 10) : undefined}
/>
);
};

const App: React.FC = () => {
return (
<Router basename={window.__BASE_PATH__} future={{ v7_relativeSplatPath: true }}>
Expand All @@ -47,6 +71,7 @@ const App: React.FC = () => {
<Route path={ROUTES.AUTH_CALLBACK} element={<OIDCCallback />} />
<Route path={ROUTES.SHARED_SNIPPET} element={<SharedSnippetView />} />
<Route path={ROUTES.PUBLIC_SNIPPETS} element={<PublicSnippetStorage />} />
<Route path={ROUTES.EMBED} element={<EmbedViewWrapper />} />
<Route path={ROUTES.SNIPPET} element={<SnippetPage />} />
<Route path={ROUTES.HOME} element={<AuthenticatedApp />} />
</Routes>
Expand Down
31 changes: 22 additions & 9 deletions client/src/components/common/buttons/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -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<CopyButtonProps> = ({ text }) => {
const CopyButton: React.FC<CopyButtonProps> = ({ 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';
Expand All @@ -24,30 +28,39 @@ const CopyButton: React.FC<CopyButtonProps> = ({ 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 (
<button
onClick={handleCopy}
className="absolute top-2 right-2 p-1 bg-light-surface dark:bg-dark-surface rounded-md
hover:bg-light-hover dark:hover:bg-dark-hover transition-colors text-light-text dark:text-dark-text"
className={`absolute top-2 right-2 p-1 rounded-md transition-colors ${
isDark
? `bg-dark-surface hover:bg-dark-hover text-dark-text`
: `bg-light-surface hover:bg-light-hover text-light-text`
}`}
title="Copy to clipboard"
>
{isCopied ? (
<Check size={16} className="text-light-primary dark:text-dark-primary" />
<Check size={16} className={isDark ? "text-dark-primary" : "text-light-primary"} />
) : (
<Copy size={16} className="text-light-text dark:text-dark-text" />
<Copy size={16} className={isDark ? "text-dark-text" : "text-light-text"} />
)}
</button>
);
Expand Down
10 changes: 7 additions & 3 deletions client/src/components/common/modals/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,11 @@ const Modal: React.FC<ModalProps> = ({

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();
}
};
Expand Down Expand Up @@ -56,7 +60,7 @@ const Modal: React.FC<ModalProps> = ({

return (
<div
className={`fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center p-4 z-50 transition-opacity duration-300
className={`modal-backdrop fixed inset-0 bg-black bg-opacity-50 flex justify-center items-center p-4 z-50 transition-opacity duration-300
${isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'}`}
>
<div
Expand Down Expand Up @@ -99,4 +103,4 @@ const Modal: React.FC<ModalProps> = ({
);
};

export default Modal;
export default Modal;
22 changes: 20 additions & 2 deletions client/src/components/editor/FullCodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,14 @@ export interface FullCodeBlockProps {
code: string;
language?: string;
showLineNumbers?: boolean;
forceTheme?: 'light' | 'dark' | null;
}

export const FullCodeBlock: React.FC<FullCodeBlockProps> = ({
code,
language = 'plaintext',
showLineNumbers = true
showLineNumbers = true,
forceTheme = null
}) => {
const { theme } = useTheme();
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark'>(
Expand All @@ -25,6 +27,11 @@ export const FullCodeBlock: React.FC<FullCodeBlockProps> = ({
);

useEffect(() => {
if (forceTheme) {
setEffectiveTheme(forceTheme);
return;
}

if (theme === 'system') {
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
Expand Down Expand Up @@ -92,6 +99,17 @@ export const FullCodeBlock: React.FC<FullCodeBlockProps> = ({
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'};
}
Expand Down Expand Up @@ -137,7 +155,7 @@ export const FullCodeBlock: React.FC<FullCodeBlockProps> = ({
</div>
)}

<CopyButton text={code} />
<CopyButton text={code} forceTheme={forceTheme}/>
</div>
</div>
);
Expand Down
160 changes: 160 additions & 0 deletions client/src/components/snippets/embed/EmbedModal.tsx
Original file line number Diff line number Diff line change
@@ -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<EmbedModalProps> = ({
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<number | undefined>(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 `<iframe
src="${embedUrl}"
style="width: 100%; border: none; border-radius: 8px;"
onload="(function(iframe) {
window.addEventListener('message', function(e) {
if (e.data.type === 'resize' && e.data.embedId === '${embedId}') {
iframe.style.height = e.data.height + 'px';
}
});
})(this);"
title="ByteStash Code Snippet"
></iframe>`;
};

const handleModalClick = (e: React.MouseEvent) => {
e.stopPropagation();
};

return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={
<div className="flex items-center gap-2 text-light-text dark:text-dark-text">
<Code2 size={20} />
<h2 className="text-xl font-bold">Embed Snippet</h2>
</div>
}
>
<div className="space-y-6 text-light-text dark:text-dark-text" onClick={handleModalClick}>
<div className="space-y-4">
<h3 className="text-lg font-medium">Customize Embed</h3>

<div className="space-y-4">
<label className="flex items-center gap-2">
<Switch
id="showTitle"
checked={showTitle}
onChange={setShowTitle}
/>
<span>Show title</span>
</label>

<label className="flex items-center gap-2">
<Switch
id="showDescription"
checked={showDescription}
onChange={setShowDescription}
/>
<span>Show description</span>
</label>

<label className="flex items-center gap-2">
<Switch
id="showFileHeaders"
checked={showFileHeaders}
onChange={setShowFileHeaders}
/>
<span>Show file headers</span>
</label>

<label className="flex items-center gap-2">
<Switch
id="showPoweredBy"
checked={showPoweredBy}
onChange={setShowPoweredBy}
/>
<span>Show "Powered by ByteStash"</span>
</label>

<div>
<label className="block text-sm mb-2">Theme</label>
<select
value={theme}
onChange={(e) => setTheme(e.target.value as 'light' | 'dark' | 'system')}
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"
>
<option value="system">System (follow user's preference)</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</div>

<div>
<label className="block text-sm mb-2">Fragment to show (optional)</label>
<select
value={selectedFragment === undefined ? '' : selectedFragment}
onChange={(e) => setSelectedFragment(e.target.value === '' ? undefined : parseInt(e.target.value, 10))}
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"
>
<option value="">All fragments</option>
{snippet.fragments.map((fragment, index) => (
<option key={index} value={index}>
{fragment.file_name}
</option>
))}
</select>
</div>
</div>
</div>

<div className="space-y-4">
<h3 className="text-lg font-medium">Embed Code</h3>
<FullCodeBlock
code={getEmbedCode()}
language={'html'}
showLineNumbers={false}
/>
</div>
</div>
</Modal>
);
};

export default EmbedModal;
Loading

0 comments on commit 8bcca32

Please sign in to comment.