Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[69] Add embeddable snippets #143

Merged
merged 4 commits into from
Dec 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
2 changes: 1 addition & 1 deletion client/src/components/common/buttons/CopyButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,4 +53,4 @@ const CopyButton: React.FC<CopyButtonProps> = ({ text }) => {
);
};

export default CopyButton;
export default CopyButton;
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;
2 changes: 1 addition & 1 deletion client/src/components/editor/FullCodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,4 +141,4 @@ export const FullCodeBlock: React.FC<FullCodeBlockProps> = ({
</div>
</div>
);
}
}
167 changes: 167 additions & 0 deletions client/src/components/snippets/embed/EmbedCodeView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import React, { useEffect, useRef, useState } from 'react';
import ReactMarkdown from 'react-markdown';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus, oneLight } from 'react-syntax-highlighter/dist/cjs/styles/prism';
import { getLanguageLabel, getMonacoLanguage } from '../../../utils/language/languageUtils';
import EmbedCopyButton from './EmbedCopyButton';

export interface EmbedCodeBlockProps {
code: string;
language?: string;
showLineNumbers?: boolean;
theme?: 'light' | 'dark' | 'blue' | 'system';
}

export const EmbedCodeView: React.FC<EmbedCodeBlockProps> = ({
code,
language = 'plaintext',
showLineNumbers = true,
theme = 'system'
}) => {
const [effectiveTheme, setEffectiveTheme] = useState<'light' | 'dark' | 'blue'>(() => {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return theme;
});

useEffect(() => {
if (theme !== 'system') {
setEffectiveTheme(theme);
return;
}

const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = () => {
setEffectiveTheme(mediaQuery.matches ? 'dark' : 'light');
};
mediaQuery.addEventListener('change', handleChange);
return () => mediaQuery.removeEventListener('change', handleChange);
}, [theme]);

const isDark = effectiveTheme === 'dark' || effectiveTheme === 'blue';
const isMarkdown = getLanguageLabel(language) === 'markdown';
const [highlighterHeight, setHighlighterHeight] = useState<string>("100px");
const containerRef = useRef<HTMLDivElement>(null);
const LINE_HEIGHT = 19;

useEffect(() => {
updateHighlighterHeight();
const resizeObserver = new ResizeObserver(updateHighlighterHeight);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => resizeObserver.disconnect();
}, [code]);

const updateHighlighterHeight = () => {
if (!containerRef.current) return;

const lineCount = code.split('\n').length;
const contentHeight = (lineCount * LINE_HEIGHT) + 35;
const newHeight = Math.min(500, Math.max(100, contentHeight));
setHighlighterHeight(`${newHeight}px`);
};

const baseTheme = isDark ? vscDarkPlus : oneLight;
const getBackgroundColor = () => {
switch (effectiveTheme) {
case 'blue':
case 'dark':
return '#1E1E1E';
case 'light':
return '#ffffff';
}
};

const backgroundColor = getBackgroundColor();
const customStyle = {
...baseTheme,
'pre[class*="language-"]': {
...baseTheme['pre[class*="language-"]'],
margin: 0,
fontSize: '13px',
background: backgroundColor,
padding: '1rem',
},
'code[class*="language-"]': {
...baseTheme['code[class*="language-"]'],
fontSize: '13px',
background: backgroundColor,
display: 'block',
textIndent: 0,
}
};

return (
<div className="relative">
<style>
{`
.markdown-content-full {
color: var(--text-color);
background-color: ${backgroundColor};
padding: 1rem;
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'};
}
`}
</style>
<div className="relative">
{isMarkdown ? (
<div className="markdown-content markdown-content-full rounded-lg" style={{ backgroundColor }}>
<ReactMarkdown className={`markdown prose ${isDark ? 'prose-invert' : ''} max-w-none`}>
{code}
</ReactMarkdown>
</div>
) : (
<div
ref={containerRef}
style={{ maxHeight: '500px' }}
>
<SyntaxHighlighter
language={getMonacoLanguage(language)}
style={customStyle}
showLineNumbers={showLineNumbers}
wrapLines={true}
lineProps={{
style: {
whiteSpace: 'pre',
wordBreak: 'break-all',
paddingLeft: 0
}
}}
customStyle={{
height: highlighterHeight,
minHeight: '100px',
marginBottom: 0,
marginTop: 0,
textIndent: 0,
paddingLeft: showLineNumbers ? 10 : 20,
borderRadius: '0.5rem',
background: backgroundColor
}}
>
{code}
</SyntaxHighlighter>
</div>
)}

<EmbedCopyButton text={code} theme={theme} />
</div>
</div>
);
};
91 changes: 91 additions & 0 deletions client/src/components/snippets/embed/EmbedCopyButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import React, { useState } from 'react';
import { Copy, Check } from 'lucide-react';

export interface EmbedCopyButtonProps {
text: string;
theme: 'light' | 'dark' | 'blue' | 'system';
}

const EmbedCopyButton: React.FC<EmbedCopyButtonProps> = ({ text, theme }) => {
const [isCopied, setIsCopied] = useState(false);

const isDark = theme === 'dark' || theme === 'blue' ||
(theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);

const handleCopy = async (e: React.MouseEvent) => {
e.stopPropagation();
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();

try {
const successful = document.execCommand('copy');
if (!successful) {
throw new Error('Copy command failed');
}
} finally {
textArea.remove();
}

setIsCopied(true);
setTimeout(() => setIsCopied(false), 2000);
} catch (err) {
console.error('Failed to copy text: ', err);
setIsCopied(false);
}
};

const getBackgroundColor = () => {
switch (theme) {
case 'blue':
return 'bg-dark-surface hover:bg-dark-hover';
case 'dark':
return 'bg-neutral-700 hover:bg-neutral-600';
case 'light':
return 'bg-light-surface hover:bg-light-hover';
case 'system':
return isDark
? 'bg-neutral-700 hover:bg-neutral-600'
: 'bg-light-surface hover:bg-light-hover';
}
};

const getTextColor = () => {
if (theme === 'blue' || theme === 'dark' || (theme === 'system' && isDark)) {
return 'text-dark-text';
}
return 'text-light-text';
};

const getIconColor = () => {
if (isCopied) {
return isDark ? 'text-dark-primary' : 'text-light-primary';
}
if (theme === 'blue' || theme === 'dark' || (theme === 'system' && isDark)) {
return 'text-dark-text';
}
return 'text-light-text';
};

return (
<button
onClick={handleCopy}
className={`absolute top-2 right-2 p-1 rounded-md transition-colors ${getBackgroundColor()} ${getTextColor()}`}
title="Copy to clipboard"
>
{isCopied ? (
<Check size={16} className={getIconColor()} />
) : (
<Copy size={16} className={getIconColor()} />
)}
</button>
);
};

export default EmbedCopyButton;
Loading