mirror of
https://github.com/Dvorinka/MyClubServer.git
synced 2026-06-04 02:32:57 +00:00
dev day #67
This commit is contained in:
@@ -57,6 +57,9 @@ import { MapCoordinates } from '../../utils/mapUrlParser';
|
||||
import ContactMap from '../../components/home/ContactMap';
|
||||
import RichTextEditor from '../../components/common/RichTextEditor';
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
|
||||
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
|
||||
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
|
||||
import { FiVideo, FiYoutube, FiLink } from 'react-icons/fi';
|
||||
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||
import { assetUrl } from '../../utils/url';
|
||||
@@ -77,6 +80,8 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
const qc = useQueryClient();
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [editing, setEditing] = useState<Partial<Event> | null>(null);
|
||||
const [showDraftRecovery, setShowDraftRecovery] = useState(false);
|
||||
const [draftKey, setDraftKey] = useState<string>('');
|
||||
const [aiPrompt, setAiPrompt] = useState<string>('');
|
||||
const [aiLoading, setAiLoading] = useState<boolean>(false);
|
||||
const [aiTone, setAiTone] = useState<'informative'|'friendly'|'formal'>('friendly');
|
||||
@@ -88,6 +93,31 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
const [clubVideos, setClubVideos] = useState<YouTubeVideo[]>([]);
|
||||
const [youtubeTab, setYoutubeTab] = useState<'club' | 'custom'>('club');
|
||||
|
||||
// Auto-save hook - saves draft automatically
|
||||
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
|
||||
data: editing || {},
|
||||
storageKey: draftKey,
|
||||
onSave: async (data) => {
|
||||
// If event has ID, update it
|
||||
if (data.id) {
|
||||
return await updateEvent(data.id, data);
|
||||
}
|
||||
// If no ID and has title, create as draft
|
||||
if (data.title?.trim() && data.start_time) {
|
||||
const created = await createEvent(data);
|
||||
// Update editing state with new ID
|
||||
if (created?.id) {
|
||||
setEditing(prev => ({ ...prev, id: created.id } as any));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
// Don't save if no title or start time
|
||||
return {};
|
||||
},
|
||||
debounceMs: 2000,
|
||||
enabled: isOpen && editing !== null,
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['admin-events'],
|
||||
queryFn: () => getEvents(),
|
||||
@@ -115,14 +145,28 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
staleTime: 5 * 60_000,
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
onOpen();
|
||||
const openCreate = () => {
|
||||
// Check for existing draft
|
||||
const key = 'draft-activity-new';
|
||||
setDraftKey(key);
|
||||
const metadata = getDraftMetadata(key);
|
||||
if (metadata && metadata.age < 1440) {
|
||||
// Show recovery modal
|
||||
setShowDraftRecovery(true);
|
||||
} else {
|
||||
// No draft, start fresh
|
||||
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
const openEdit = (ev: Event) => {
|
||||
setEditing({ ...ev });
|
||||
const openEdit = (ev: Event) => {
|
||||
// Set unique draft key for this event
|
||||
const key = `draft-activity-${ev.id}`;
|
||||
setDraftKey(key);
|
||||
|
||||
setEditing({ ...ev });
|
||||
// Initialize map coordinates from event
|
||||
if ((ev as any).latitude && (ev as any).longitude) {
|
||||
setLocationLat((ev as any).latitude);
|
||||
@@ -131,13 +175,43 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
}
|
||||
onOpen();
|
||||
onOpen();
|
||||
};
|
||||
const closeModal = () => {
|
||||
setEditing(null);
|
||||
const closeModal = () => {
|
||||
setEditing(null);
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
onClose();
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Draft recovery handlers
|
||||
const handleRecoverDraft = () => {
|
||||
const draft = loadDraft<Partial<Event>>(draftKey);
|
||||
if (draft) {
|
||||
setEditing(draft);
|
||||
// Restore location if present
|
||||
if ((draft as any)?.latitude && (draft as any)?.longitude) {
|
||||
setLocationLat((draft as any).latitude);
|
||||
setLocationLng((draft as any).longitude);
|
||||
}
|
||||
onOpen();
|
||||
}
|
||||
setShowDraftRecovery(false);
|
||||
};
|
||||
|
||||
const handleDiscardDraft = () => {
|
||||
clearDraft();
|
||||
setEditing({ title: '', description: '', type: 'other', is_public: true } as any);
|
||||
setLocationLat(undefined);
|
||||
setLocationLng(undefined);
|
||||
setShowDraftRecovery(false);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleDeleteOnly = () => {
|
||||
clearDraft();
|
||||
setShowDraftRecovery(false);
|
||||
// Don't open the modal - just delete and close
|
||||
};
|
||||
|
||||
const createMut = useMutation({
|
||||
@@ -433,10 +507,13 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
<ModalOverlay backdropFilter="blur(3px)" />
|
||||
<ModalContent maxW={{ base: '96vw', md: '920px' }} maxH={{ base: '90vh', md: '86vh' }} borderRadius="2xl" overflow="hidden" boxShadow="2xl">
|
||||
<ModalHeader>
|
||||
<VStack align="start" spacing={1}>
|
||||
<Heading size="md">{(editing as any)?.id ? 'Upravit aktivitu' : 'Nová aktivita'}</Heading>
|
||||
<Text fontSize="sm" color="gray.500">Plánujte klubové akce, sdílejte s fanoušky a týmem.</Text>
|
||||
</VStack>
|
||||
<HStack justify="space-between" align="start" w="full" pr={8}>
|
||||
<VStack align="start" spacing={1} flex={1}>
|
||||
<Heading size="md">{(editing as any)?.id ? 'Upravit aktivitu' : 'Nová aktivita'}</Heading>
|
||||
<Text fontSize="sm" color="gray.500">Plánujte klubové akce, sdílejte s fanoušky a týmem.</Text>
|
||||
</VStack>
|
||||
<SaveStatusIndicator status={saveStatus} lastSaved={lastSaved} compact />
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody overflowY="auto" maxH={{ base: '76vh', md: '70vh' }}>
|
||||
@@ -946,6 +1023,17 @@ const AdminActivitiesPage: React.FC = () => {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Draft Recovery Modal */}
|
||||
<DraftRecoveryModal
|
||||
isOpen={showDraftRecovery}
|
||||
onClose={() => setShowDraftRecovery(false)}
|
||||
onRecover={handleRecoverDraft}
|
||||
onDiscard={handleDiscardDraft}
|
||||
onDeleteOnly={handleDeleteOnly}
|
||||
draftAge={getDraftMetadata(draftKey)?.age || null}
|
||||
entityType="aktivitu"
|
||||
/>
|
||||
</Box>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
@@ -24,6 +24,9 @@ import AlbumPhotoPicker from '../../components/admin/AlbumPhotoPicker';
|
||||
import PollLinker from '../../components/admin/PollLinker';
|
||||
import ThumbnailPreview from '../../components/common/ThumbnailPreview';
|
||||
import FilePreview from '../../components/common/FilePreview';
|
||||
import SaveStatusIndicator from '../../components/common/SaveStatusIndicator';
|
||||
import DraftRecoveryModal from '../../components/common/DraftRecoveryModal';
|
||||
import { useAutoSave, loadDraft, getDraftMetadata } from '../../hooks/useAutoSave';
|
||||
|
||||
import { getCachedYouTube, YouTubeVideo } from '../../services/youtube';
|
||||
|
||||
@@ -173,6 +176,8 @@ const ArticlesAdminPage = () => {
|
||||
}
|
||||
|
||||
const [editing, setEditing] = useState<EditingArticle | null>(null);
|
||||
const [showDraftRecovery, setShowDraftRecovery] = useState(false);
|
||||
const [draftKey, setDraftKey] = useState<string>('');
|
||||
|
||||
|
||||
|
||||
@@ -254,6 +259,53 @@ const ArticlesAdminPage = () => {
|
||||
const [youtubeManualInput, setYoutubeManualInput] = useState<string>('');
|
||||
const { isOpen: isYouTubeModalOpen, onOpen: onYouTubeModalOpen, onClose: onYouTubeModalClose } = useDisclosure();
|
||||
|
||||
// Auto-save hook - saves draft automatically
|
||||
const { saveStatus, lastSaved, forceSave, clearDraft } = useAutoSave({
|
||||
data: editing || {},
|
||||
storageKey: draftKey,
|
||||
onSave: async (data) => {
|
||||
// If article has ID, update it as draft
|
||||
if (data.id) {
|
||||
return await updateArticle(data.id, { ...data as any, published: false });
|
||||
}
|
||||
// If no ID, create as draft
|
||||
if (data.title?.trim()) {
|
||||
const payload: CreateArticlePayload = {
|
||||
title: data.title || 'Koncept článku',
|
||||
content: data.content || '',
|
||||
image_url: data.image_url || '',
|
||||
category_name: data.category_name,
|
||||
published: false, // Always save as draft
|
||||
slug: data.slug || '',
|
||||
seo_title: data.seo_title || '',
|
||||
seo_description: data.seo_description || '',
|
||||
og_image_url: data.og_image_url || '',
|
||||
featured: data.featured || false,
|
||||
};
|
||||
const created = await createArticle(payload);
|
||||
// Update editing state with new ID
|
||||
if (created?.id) {
|
||||
setEditing(prev => ({ ...prev, id: created.id } as any));
|
||||
}
|
||||
return created;
|
||||
}
|
||||
// Don't save if no title
|
||||
return {};
|
||||
},
|
||||
debounceMs: 2000,
|
||||
enabled: isOpen && editing !== null,
|
||||
});
|
||||
|
||||
// Check for draft on component mount
|
||||
React.useEffect(() => {
|
||||
const key = 'draft-article-new';
|
||||
setDraftKey(key);
|
||||
const metadata = getDraftMetadata(key);
|
||||
if (metadata && metadata.age < 1440) { // Less than 24 hours old
|
||||
setShowDraftRecovery(true);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Fetch cached Zonerama gallery from prefetch
|
||||
const fetchCachedGallery = useCallback(async () => {
|
||||
try {
|
||||
@@ -616,13 +668,27 @@ const ArticlesAdminPage = () => {
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing({ title: '', content: '', featured: false, published: true } as any);
|
||||
setActiveTabIndex(0); // Start on AI tab for new articles
|
||||
setAiPrompt(''); // Clear AI prompt
|
||||
onOpen();
|
||||
// Check for existing draft
|
||||
const key = 'draft-article-new';
|
||||
setDraftKey(key);
|
||||
const metadata = getDraftMetadata(key);
|
||||
if (metadata && metadata.age < 1440) {
|
||||
// Show recovery modal
|
||||
setShowDraftRecovery(true);
|
||||
} else {
|
||||
// No draft, start fresh
|
||||
setEditing({ title: '', content: '', featured: false, published: false } as any);
|
||||
setActiveTabIndex(0); // Start on AI tab for new articles
|
||||
setAiPrompt(''); // Clear AI prompt
|
||||
onOpen();
|
||||
}
|
||||
};
|
||||
|
||||
const openEdit = (a: Article) => {
|
||||
// Set unique draft key for this article
|
||||
const key = `draft-article-${a.id}`;
|
||||
setDraftKey(key);
|
||||
|
||||
setEditing({
|
||||
...a,
|
||||
category_name: a.category?.name || a.category_name || ''
|
||||
@@ -652,6 +718,31 @@ const ArticlesAdminPage = () => {
|
||||
onClose();
|
||||
};
|
||||
|
||||
// Draft recovery handlers
|
||||
const handleRecoverDraft = () => {
|
||||
const draft = loadDraft<EditingArticle>(draftKey);
|
||||
if (draft) {
|
||||
setEditing(draft);
|
||||
setActiveTabIndex(1); // Go to Základní tab
|
||||
onOpen();
|
||||
}
|
||||
setShowDraftRecovery(false);
|
||||
};
|
||||
|
||||
const handleDiscardDraft = () => {
|
||||
clearDraft();
|
||||
setEditing({ title: '', content: '', featured: false, published: false } as any);
|
||||
setActiveTabIndex(0);
|
||||
setShowDraftRecovery(false);
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleDeleteOnly = () => {
|
||||
clearDraft();
|
||||
setShowDraftRecovery(false);
|
||||
// Don't open the modal - just delete and close
|
||||
};
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (payload: CreateArticlePayload) =>
|
||||
// Forward the payload as-is so new fields (youtube, gallery) are persisted
|
||||
@@ -1189,7 +1280,12 @@ const ArticlesAdminPage = () => {
|
||||
<Modal isOpen={isOpen} onClose={closeModal} size="xl" isCentered>
|
||||
<ModalOverlay />
|
||||
<ModalContent maxW="90vw" maxH="90vh">
|
||||
<ModalHeader>{(editing as any)?.id ? 'Upravit článek' : 'Nový článek'}</ModalHeader>
|
||||
<ModalHeader>
|
||||
<HStack justify="space-between" align="center" w="full" pr={8}>
|
||||
<Text>{(editing as any)?.id ? 'Upravit článek' : 'Nový článek'}</Text>
|
||||
<SaveStatusIndicator status={saveStatus} lastSaved={lastSaved} />
|
||||
</HStack>
|
||||
</ModalHeader>
|
||||
<ModalCloseButton />
|
||||
<ModalBody maxH="calc(90vh - 120px)" overflowY="auto">
|
||||
<Tabs variant="enclosed" colorScheme="blue" isFitted index={activeTabIndex} onChange={(index) => setActiveTabIndex(index)}>
|
||||
@@ -1839,20 +1935,28 @@ const ArticlesAdminPage = () => {
|
||||
qc.invalidateQueries({ queryKey: ['linked-polls'] });
|
||||
}} />
|
||||
) : (
|
||||
<Alert status="warning" borderRadius="md">
|
||||
<Alert status="info" borderRadius="md">
|
||||
<AlertIcon />
|
||||
<VStack align="start" spacing={2}>
|
||||
<Text fontWeight="semibold">Článek ještě není uložen</Text>
|
||||
<Text fontSize="sm">
|
||||
Pro propojení anket s článkem musíte nejprve článek uložit. Klikněte na "Uložit" níže - článek se uloží jako koncept a poté budete moci přidat ankety.
|
||||
<Text fontWeight="semibold">
|
||||
{saveStatus === 'saving' ? 'Ukládání článku...' : 'Článek se ukládá automaticky'}
|
||||
</Text>
|
||||
<Text fontSize="sm">
|
||||
Začněte psát článek na záložkách výše. Systém automaticky ukládá každou změnu jako koncept. Jakmile bude článek uložen (v záhlaví se zobrazí "Uloženo"), budete moci přidat ankety.
|
||||
</Text>
|
||||
{saveStatus === 'saving' && <Spinner size="sm" color="blue.500" />}
|
||||
{saveStatus === 'idle' && (
|
||||
<Text fontSize="xs" color="gray.600">
|
||||
💡 Vyplňte název článku pro aktivaci automatického ukládání
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
colorScheme="blue"
|
||||
onClick={async () => {
|
||||
// Save article as draft first, keep modal open
|
||||
// Force save if needed
|
||||
try {
|
||||
await onSubmit({ keepOpen: true });
|
||||
await forceSave();
|
||||
// Switch to poll tab after save
|
||||
setActiveTabIndex(5); // Poll tab is index 5
|
||||
} catch (error) {
|
||||
@@ -2164,6 +2268,17 @@ const ArticlesAdminPage = () => {
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
{/* Draft Recovery Modal */}
|
||||
<DraftRecoveryModal
|
||||
isOpen={showDraftRecovery}
|
||||
onClose={() => setShowDraftRecovery(false)}
|
||||
onRecover={handleRecoverDraft}
|
||||
onDiscard={handleDiscardDraft}
|
||||
onDeleteOnly={handleDeleteOnly}
|
||||
draftAge={getDraftMetadata(draftKey)?.age || null}
|
||||
entityType="článek"
|
||||
/>
|
||||
</AdminLayout>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user