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

Added ability to share snippets #39

Merged
merged 9 commits into from
Nov 9, 2024
2 changes: 2 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@
"framer-motion": "^11.11.9",
"lucide-react": "^0.452.0",
"monaco-editor": "^0.52.0",
"parse-duration": "^1.1.0",
"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"
},
Expand Down
19 changes: 12 additions & 7 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -24,13 +26,16 @@ const AuthenticatedApp: React.FC = () => {

const App: React.FC = () => {
return (
<AuthProvider>
<ToastProvider>
<div className="min-h-screen bg-gray-900">
<AuthenticatedApp />
</div>
</ToastProvider>
</AuthProvider>
<Router basename={window.__BASE_PATH__} future={{ v7_relativeSplatPath: true }}>
<AuthProvider>
<ToastProvider>
<Routes>
<Route path="/s/:shareId" element={<SharedSnippetView />} />
<Route path="/" element={<AuthenticatedApp />} />
</Routes>
</ToastProvider>
</AuthProvider>
</Router>
);
};

Expand Down
3 changes: 2 additions & 1 deletion client/src/api/auth.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { basePath } from "./basePath";

interface AuthConfig {
authRequired: boolean;
}
Expand All @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions client/src/api/basePath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const basePath = (window as any).__BASE_PATH__ || '';
71 changes: 71 additions & 0 deletions client/src/api/share.ts
Original file line number Diff line number Diff line change
@@ -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<Share> => {
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<Share[]> => {
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<void> => {
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<Snippet> => {
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();
};
2 changes: 1 addition & 1 deletion client/src/api/snippets.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
4 changes: 2 additions & 2 deletions client/src/components/snippets/EditSnippetModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const EditSnippetModal: React.FC<EditSnippetModalProps> = ({
setFragments([{
file_name: 'main',
code: '',
language: 'plaintext',
language: '',
position: 0
}]);
setCategories([]);
Expand Down Expand Up @@ -96,7 +96,7 @@ const EditSnippetModal: React.FC<EditSnippetModalProps> = ({
{
file_name: `file${current.length + 1}`,
code: '',
language: 'plaintext',
language: '',
position: current.length
}
]);
Expand Down
49 changes: 44 additions & 5 deletions client/src/components/snippets/SnippetCard.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import React, { useState } from 'react';
import { Pencil, Trash2, Clock, ChevronLeft, ChevronRight, FileCode } from 'lucide-react';
import React, { useState, useEffect } from 'react';
import { Pencil, Trash2, Clock, ChevronLeft, ChevronRight, FileCode, Share, Users } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import DeleteConfirmationModal from './DeleteConfirmationModal';
import { CodeFragment, Snippet } from '../../types/types';
import { getLanguageLabel } from '../../utils/languageUtils';
import PreviewCodeBlock from './PreviewCodeBlock';
import CategoryList from './categories/CategoryList';
import { getSharesBySnippetId } from '../../api/share';

export interface SnippetCardProps {
snippet: Snippet;
Expand All @@ -14,6 +15,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;
Expand All @@ -29,6 +31,7 @@ const SnippetCard: React.FC<SnippetCardProps> = ({
onDelete,
onEdit,
onCategoryClick,
onShare,
compactView,
showCodePreview,
previewLines,
Expand All @@ -38,6 +41,21 @@ const SnippetCard: React.FC<SnippetCardProps> = ({
}) => {
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [currentFragmentIndex, setCurrentFragmentIndex] = useState(0);
const [activeShares, setActiveShares] = useState(0);

useEffect(() => {
const loadShareStatus = async () => {
try {
const shares = await getSharesBySnippetId(snippet.id);
const validShares = shares.filter(share => !share.expired);
setActiveShares(validShares.length);
} catch (error) {
console.error('Error loading share status:', error);
}
};

loadShareStatus();
}, [snippet]);

const handleDeleteClick = (e: React.MouseEvent) => {
e.stopPropagation();
Expand Down Expand Up @@ -109,9 +127,20 @@ const SnippetCard: React.FC<SnippetCardProps> = ({
>
<div className="flex justify-between items-start gap-4 mb-3">
<div className="min-w-0 flex-1">
<h3 className={`${compactView ? 'text-lg' : 'text-xl'} font-bold text-gray-200 truncate`} title={snippet.title}>
{snippet.title}
</h3>
<div className="flex items-center gap-2">
<h3 className={`${compactView ? 'text-lg' : 'text-xl'} font-bold text-gray-200 truncate leading-none`} title={snippet.title}>
{snippet.title}
</h3>
{activeShares > 0 && (
<div
className="inline-flex items-center gap-1 px-2 bg-blue-900/40 text-blue-300 rounded text-xs border border-blue-700/30 leading-relaxed"
title={`Shared with ${activeShares} active link${activeShares === 1 ? '' : 's'}`}
>
<Users size={12} className="stroke-[2.5]" />
<span>{activeShares}</span>
</div>
)}
</div>
<div className="flex items-center gap-3 mt-1 text-sm text-gray-400">
<div className="truncate">{getUniqueLanguages(snippet.fragments)}</div>
<div className="flex items-center gap-1 text-xs text-gray-500 whitespace-nowrap">
Expand All @@ -122,6 +151,16 @@ const SnippetCard: React.FC<SnippetCardProps> = ({
</div>

<div className="flex items-start gap-1.5">
<button
onClick={(e) => {
e.stopPropagation();
onShare(snippet);
}}
className="p-1.5 bg-gray-700 rounded-md hover:bg-gray-600 transition-colors opacity-0 group-hover:opacity-100"
title="Share snippet"
>
<Share size={16} className="text-gray-400 hover:text-blue-500" />
</button>
<button
onClick={handleEditClick}
className="p-1.5 bg-gray-700 rounded-md hover:bg-gray-600 transition-colors opacity-0 group-hover:opacity-100"
Expand Down
3 changes: 3 additions & 0 deletions client/src/components/snippets/SnippetList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface SnippetListProps {
onDelete: (id: string) => void;
onEdit: (snippet: Snippet) => void;
onCategoryClick: (category: string) => void;
onShare: (snippet: Snippet) => void;
compactView: boolean;
showCodePreview: boolean;
previewLines: number;
Expand All @@ -24,6 +25,7 @@ const SnippetList: React.FC<SnippetListProps> = ({
onDelete,
onEdit,
onCategoryClick,
onShare,
compactView,
showCodePreview,
previewLines,
Expand Down Expand Up @@ -53,6 +55,7 @@ const SnippetList: React.FC<SnippetListProps> = ({
onDelete={onDelete}
onEdit={onEdit}
onCategoryClick={onCategoryClick}
onShare={onShare}
compactView={compactView}
showCodePreview={showCodePreview}
previewLines={previewLines}
Expand Down
22 changes: 22 additions & 0 deletions client/src/components/snippets/SnippetStorage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { useAuth } from '../../context/AuthContext';
import { getLanguageLabel } from '../../utils/languageUtils';
import { Snippet } from '../../types/types';
import { initializeMonaco } from '../../utils/languageUtils';
import ShareMenu from './share/ShareMenu';

const APP_VERSION = "1.4.0";

Expand All @@ -31,6 +32,8 @@ const SnippetStorage: React.FC = () => {
const [snippetToEdit, setSnippetToEdit] = useState<Snippet | null>(null);
const [sortOrder, setSortOrder] = useState<'newest' | 'oldest' | 'alpha-asc' | 'alpha-desc'>('newest');
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [isShareMenuOpen, setIsShareMenuOpen] = useState(false);
const [snippetToShare, setSnippetToShare] = useState<Snippet | null>(null);

useEffect(() => {
initializeMonaco();
Expand All @@ -53,6 +56,16 @@ const SnippetStorage: React.FC = () => {
logout();
};

const openShareMenu = useCallback((snippet: Snippet) => {
setSnippetToShare(snippet);
setIsShareMenuOpen(true);
}, []);

const closeShareMenu = useCallback(() => {
setSnippetToShare(null);
setIsShareMenuOpen(false);
}, []);

const languages = useMemo(() => {
const langSet = new Set<string>();
snippets.forEach(snippet => {
Expand Down Expand Up @@ -211,6 +224,7 @@ const SnippetStorage: React.FC = () => {
onDelete={handleDeleteSnippet}
onEdit={openEditSnippetModal}
onCategoryClick={handleCategoryClick}
onShare={openShareMenu}
compactView={compactView}
showCodePreview={showCodePreview}
previewLines={previewLines}
Expand Down Expand Up @@ -249,6 +263,14 @@ const SnippetStorage: React.FC = () => {
}}
onSettingsChange={updateSettings}
/>

{snippetToShare && (
<ShareMenu
snippetId={snippetToShare.id}
isOpen={isShareMenuOpen}
onClose={closeShareMenu}
/>
)}
</div>
);
};
Expand Down
Loading