Compare commits

...

10 Commits

Author SHA1 Message Date
dereklseitz
9a0ce6fb34 feat: Add KendoReact credit to login screen 2025-09-28 21:16:14 -05:00
dereklseitz
e9bd6be189 feat: add modals to Cancel and Publish buttons in Editor 2025-09-28 19:49:30 -05:00
dereklseitz
0c00c49850 maintenance: Clean up comments throughout component files 2025-09-28 16:22:05 -05:00
dereklseitz
7f576bac73 fix: Fix layout shifting with Copyright Component 2025-09-28 15:24:23 -05:00
dereklseitz
c778015244 feat: replace Vite favicon with Campfire Logs logo 2025-09-28 15:07:45 -05:00
dereklseitz
5ba65a0861 feat: add animation to LoginPage 2025-09-28 15:02:57 -05:00
dereklseitz
36670fd528 fix: resolve header image paths in production build
- Add import.meta.glob to process header images in PostCard component
- Replace manual URL construction with Vite's asset handling
- Ensures images load correctly in both dev and production environments
2025-09-28 14:11:55 -05:00
dereklseitz
3450239fe4 feat: Add demo login guidance
• Pre-populate login form with demo credentials
• Add user guidance message for demo access
2025-09-28 11:55:47 -05:00
dereklseitz
3692e08245 maintenance: Remove unused dependencies 2025-09-28 11:36:18 -05:00
dereklseitz
0dfb15466e feat: Add .env.example 2025-09-28 10:54:39 -05:00
24 changed files with 424 additions and 41 deletions

2
.env.example Normal file
View File

@@ -0,0 +1,2 @@
VITE_MOCK_USERNAME=your_username_here
VITE_MOCK_PASSWORD=your_password_here

1
.gitignore vendored
View File

@@ -1,5 +1,4 @@
.env
.env.*
telerik-license.txt
# Logs

View File

@@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<link rel="icon" type="image/png" href="/favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Campfire Logs Dashboard</title>

View File

@@ -31,13 +31,8 @@
"@progress/kendo-react-tooltip": "^12.0.1",
"@progress/kendo-svg-icons": "^4.5.0",
"@progress/kendo-theme-default": "^12.0.1",
"@syncfusion/ej2-react-base": "^31.1.17",
"@syncfusion/ej2-react-layouts": "^31.1.17",
"@syncfusion/ej2-react-richtexteditor": "^31.1.21",
"campfire-logs-dashboard": "file:./campfire-logs-dashboard",
"front-matter": "^4.0.2",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
"marked": "^16.3.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",

BIN
public/derek.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 516 KiB

View File

@@ -57,7 +57,7 @@ function App() {
)}
</IconsContext.Provider>
</div>
<Copyright />
<Copyright isLoginPage={location.pathname === '/login'} isLoggedIn={isLoggedIn} onLogin={handleLogin} />
</div>
);
}

View File

@@ -46,3 +46,16 @@
.read-the-docs {
color: #888;
}
/* Copyright transition styles */
.copyright-login {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.copyright-normal {
position: relative;
transform: none;
}

View File

@@ -231,3 +231,52 @@ form {
margin: 0 auto !important;
contain: layout;
}
/* Enhanced visual styling with shadows */
/* PostCard component shadows */
.k-card {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease;
}
.k-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
/* Campfire theme shadow for cards */
.k-card {
box-shadow: 0 2px 8px rgba(237, 189, 125, 0.1);
}
.k-card:hover {
box-shadow: 0 4px 16px rgba(237, 189, 125, 0.3);
}
/* PanelBar component shadow */
.k-panelbar {
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
}
/* Campfire theme shadow for PanelBar */
.k-panelbar {
box-shadow: 2px 0 12px rgba(0, 0, 0, 0.3);
}
/* Button component shadows */
.k-button {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: box-shadow 0.3s ease;
}
.k-button:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15);
}
/* Campfire theme shadow for buttons */
.k-button {
box-shadow: 0 2px 8px rgba(237, 189, 125, 0.1);
}
.k-button:hover {
box-shadow: 0 4px 16px rgba(237, 189, 125, 0.3);
}

View File

@@ -52,9 +52,35 @@ const CampfireAppBar = ({ isLoggedIn, onLogin, onDrawerToggle }) => {
<AppBarSpacer style={{ width: 20 }} />
{isLoggedIn ? (
<Button look="flat" onClick={handleLogout}>
Logout
</Button>
<>
<Button look="flat" onClick={handleLogout}>
Logout
</Button>
<Avatar
rounded="full"
type="image"
style={{
marginLeft: "8px",
width: "48px !important",
height: "48px !important",
minWidth: "48px",
maxWidth: "48px",
minHeight: "48px",
maxHeight: "48px"
}}
>
<img
src="/derek.jpg"
alt="Derek Seitz"
style={{
width: "100%",
height: "100%",
border: "2px solid #edbd7d",
borderRadius: "50%"
}}
/>
</Avatar>
</>
) : (
<Link to="/login">
<Button look="flat">Login</Button>

View File

@@ -3,10 +3,12 @@ import React from 'react';
import { Button } from '@progress/kendo-react-buttons';
import { Card, CardImage, CardBody } from '@progress/kendo-react-layout';
// Import all header images so Vite processes them
const headerImages = import.meta.glob('../../assets/header/*', { eager: true });
const PostCard = ({ post, onEdit }) => {
const dataPath = post.header.image;
const relativePath = `../../${dataPath}`;
const imageUrl = new URL(relativePath, import.meta.url).href;
const imageUrl = headerImages[`../../${dataPath}`]?.default || dataPath;
const formatDate = (utcString) => {
if (!utcString) return '';
@@ -25,8 +27,10 @@ const PostCard = ({ post, onEdit }) => {
const formattedDate = date.toLocaleDateString(undefined, dateOptions);
// Split formatted date to check if time component exists
const parts = formattedDate.split(',');
if (parts.length > 1) {
// Reconstruct with separate date and time formatting for consistency
const timePart = date.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit', hour12: true });
const datePart = date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' });
return `${datePart}, ${timePart}`;

View File

@@ -1,6 +1,5 @@
// MarkdownEditor.jsx
// KendoReact Splitter implementation for dual-pane markdown editing with live preview
// Demonstrates advanced layout components and real-time markdown processing
import React, { useState } from 'react';
import { Splitter, SplitterPane } from '@progress/kendo-react-layout';
import { marked } from 'marked';

View File

@@ -0,0 +1,50 @@
// CancelModal.jsx
// Confirmation modal for canceling post editing
import React from 'react';
import { Dialog, DialogActionsBar } from '@progress/kendo-react-dialogs';
import { Button } from '@progress/kendo-react-buttons';
const CancelModal = ({ isVisible, onClose, onConfirm, hasUnsavedChanges }) => {
if (!isVisible) return null;
return (
<Dialog
title="Cancel Editing"
onClose={onClose}
minWidth={400}
width={500}
style={{ borderRadius: '8px' }}
>
<div style={{ padding: '20px' }}>
{hasUnsavedChanges ? (
<>
<p style={{ margin: '0 0 16px 0', fontSize: '16px' }}>
You have unsaved changes.
</p>
<p style={{ margin: '0', fontSize: '14px', color: '#666' }}>
Are you sure you want to cancel? All unsaved changes will be lost.
</p>
</>
) : (
<p style={{ margin: '0', fontSize: '16px' }}>
Are you sure you want to cancel editing?
</p>
)}
</div>
<DialogActionsBar>
<Button onClick={onClose}>
Continue Editing
</Button>
<Button
onClick={onConfirm}
themeColor="primary"
look="outline"
>
{hasUnsavedChanges ? 'Discard Changes' : 'Cancel'}
</Button>
</DialogActionsBar>
</Dialog>
);
};
export default CancelModal;

View File

@@ -0,0 +1,51 @@
// PublishModal.jsx
// Confirmation modal for publishing posts
import React from 'react';
import { Dialog, DialogActionsBar } from '@progress/kendo-react-dialogs';
import { Button } from '@progress/kendo-react-buttons';
const PublishModal = ({ isVisible, onClose, onConfirm, postTitle }) => {
if (!isVisible) return null;
return (
<Dialog
title="Publish Post"
onClose={onClose}
minWidth={400}
width={500}
style={{ borderRadius: '8px' }}
>
<div style={{ padding: '20px' }}>
<p style={{ margin: '0 0 16px 0', fontSize: '16px' }}>
Are you sure you want to publish this post?
</p>
{postTitle && (
<p style={{
margin: '0 0 16px 0',
fontSize: '14px',
fontStyle: 'italic',
color: '#666'
}}>
"{postTitle}"
</p>
)}
<p style={{ margin: '0', fontSize: '14px', color: '#666' }}>
This will make the post visible to your readers.
</p>
</div>
<DialogActionsBar>
<Button onClick={onClose}>
Cancel
</Button>
<Button
onClick={onConfirm}
themeColor="primary"
>
Publish Post
</Button>
</DialogActionsBar>
</Dialog>
);
};
export default PublishModal;

View File

@@ -1,6 +1,5 @@
// WysiwygEditor.jsx
// KendoReact Editor implementation for HTML editing with custom toolbar
// Demonstrates custom tool integration and advanced editor configuration
import React, { useState } from 'react';
import { Editor, EditorTools, EditorToolsSettings } from '@progress/kendo-react-editor';
import InlineCodeTool from './custom/InlineCodeTool';

View File

@@ -1,6 +1,5 @@
// EditorModeToggle.jsx
// Custom KendoReact component for switching between HTML and Markdown editing modes
// Demonstrates reusable UI components and state management patterns
import React from 'react';
import { Button } from '@progress/kendo-react-buttons';

View File

@@ -21,14 +21,14 @@ const InlineCodeTool = (props) => {
const hasMark = state.doc.rangeHasMark(from, to, markType);
if (hasMark) {
// Remove code mark if already present
// Remove code mark if already present (toggle off)
tr.removeMark(from, to, markType);
} else {
// Add code mark to selected text
// Add code mark to selected text (toggle on)
tr.addMark(from, to, markType.create());
}
// Dispatch transaction to apply changes
// Dispatch transaction to apply changes to editor
dispatch(tr);
}
}

View File

@@ -7,21 +7,24 @@ import { Link } from 'react-router-dom';
import { Notification, NotificationGroup } from '@progress/kendo-react-notification';
const LoginComponent = ({ onLogin }) => {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
// Get credentials from environment
const mockUsername = import.meta.env.VITE_MOCK_USERNAME;
const mockPassword = import.meta.env.VITE_MOCK_PASSWORD;
// Prepopulate with demo credentials from env
const [username, setUsername] = useState(mockUsername);
const [password, setPassword] = useState(mockPassword);
const [error, setError] = useState('');
const handleSubmit = (event) => {
event.preventDefault();
const mockUsername = import.meta.env.VITE_MOCK_USERNAME;
const mockPassword = import.meta.env.VITE_MOCK_PASSWORD;
if (username === mockUsername && password === mockPassword) {
onLogin(true);
} else {
console.error("Invalid username or password");
setError("Invalid username or password. Please try again.");
// Clear error after 50 seconds (long timeout for demo purposes)
setTimeout(() => {
setError('');
}, 50000);
@@ -40,7 +43,15 @@ const LoginComponent = ({ onLogin }) => {
<Input style={{ border: '1px solid #edbd7d', marginBottom: '10px', width: '300px', maxWidth: '300px', minWidth: '300px' }} type="password" id="password" value={password} onChange={(e) => setPassword(e.target.value)} autoComplete="current-password" />
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ textAlign: 'center', marginBottom: '10px' }}>
<p style={{
fontSize: '14px',
color: '#666',
fontStyle: 'italic',
margin: '0 0 10px 0'
}}>
Just click 'Login'. The credentials are already loaded for you.
</p>
<Button look="flat" type="submit" style={{ padding: '0 20px' }}>Login</Button>
</div>
<div>

View File

@@ -35,7 +35,7 @@ const CampfirePanelBar = ({ isExpanded = true }) => {
const navigate = useNavigate();
const renderItem = (item) => {
// External links
// Handle external links (open in new tab)
if (item.url) {
return (
<PanelBarItem
@@ -79,7 +79,7 @@ const CampfirePanelBar = ({ isExpanded = true }) => {
);
}
// Internal routes
// Handle internal routes (navigate within app)
if (item.route) {
return (
<PanelBarItem
@@ -106,7 +106,7 @@ const CampfirePanelBar = ({ isExpanded = true }) => {
);
}
// Headers with children
// Handle section headers with child items
return (
<PanelBarItem
key={item.title}

View File

@@ -10,10 +10,12 @@ const CampfireBreadcrumb = () => {
const path = location.pathname;
const pathSegments = path.split('/').filter(segment => segment);
// Dashboard routes
if (path === '/dashboard' || path === '/') {
return [{ id: 'dashboard', text: 'Dashboard' }];
}
// Published posts filter
if (path === '/posts') {
return [
{ id: 'dashboard', text: 'Dashboard' },
@@ -21,6 +23,7 @@ const CampfireBreadcrumb = () => {
];
}
// Drafts filter
if (path === '/drafts') {
return [
{ id: 'dashboard', text: 'Dashboard' },
@@ -28,6 +31,7 @@ const CampfireBreadcrumb = () => {
];
}
// New post editor
if (path === '/editor') {
return [
{ id: 'editor', text: 'Editor' },
@@ -35,6 +39,7 @@ const CampfireBreadcrumb = () => {
];
}
// Edit existing post
if (path.startsWith('/editor/')) {
const slug = pathSegments[1];
return [
@@ -43,6 +48,7 @@ const CampfireBreadcrumb = () => {
];
}
// Default fallback
return [{ id: 'dashboard', text: 'Dashboard' }];
};

View File

@@ -1,12 +1,38 @@
// Copyright.jsx
import React from "react";
import React, { useState, useEffect } from "react";
export default function Copyright({ isLoginPage, isLoggedIn }) {
const [isCentered, setIsCentered] = useState(true);
useEffect(() => {
if (isLoginPage) {
// On login page, start centered then transition to bottom
const timer = setTimeout(() => {
setIsCentered(false);
}, 1000);
return () => clearTimeout(timer);
} else {
// Not on login page, go to bottom immediately
setIsCentered(false);
}
}, [isLoginPage]);
// Show copyright on login page with transition, or at bottom after login
// Hide completely when not on login page and not logged in
if (!isLoginPage && !isLoggedIn) {
return null;
}
export default function Copyright() {
return (
<div
style={{
textAlign: "center",
marginTop: "1rem",
transition: "all 0.8s ease-in-out",
position: isLoginPage ? "fixed" : "relative",
top: isLoginPage ? (isCentered ? "65%" : "calc(90% + 3px)") : "auto",
left: isLoginPage ? "50%" : "auto",
transform: isLoginPage ? "translate(-50%, -50%)" : "none"
}}
>
<p>

View File

@@ -48,6 +48,7 @@ const Dashboard = React.forwardRef((props, ref) => {
};
const filter = getFilterFromPath();
// Show sections based on URL filter or show both if no filter
const showPublished = !filter || filter === 'published';
const showDrafts = !filter || filter === 'drafts';

View File

@@ -1,15 +1,18 @@
import React, { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom';
import { useParams, useNavigate } from 'react-router-dom';
import { Button } from '@progress/kendo-react-buttons';
import { marked } from 'marked';
import MetadataEditor from '../components/Editor/MetadataEditor';
import WysiwygEditor from '../components/Editor/WysiwygEditor';
import MarkdownEditor from '../components/Editor/MarkdownEditor';
import EditorModeToggle from '../components/Editor/custom/EditorModeToggle';
import PublishModal from '../components/Editor/UI/PublishModal';
import CancelModal from '../components/Editor/UI/CancelModal';
import { getPostBySlug } from '../data/postsCache';
const EditorPage = React.forwardRef((props, ref) => {
const { slug } = useParams();
const navigate = useNavigate();
const [postData, setPostData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
@@ -22,6 +25,11 @@ const EditorPage = React.forwardRef((props, ref) => {
{}
]);
// Modal state management
const [showPublishModal, setShowPublishModal] = useState(false);
const [showCancelModal, setShowCancelModal] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
useEffect(() => {
const loadPostData = async () => {
setIsLoading(true);
@@ -60,18 +68,31 @@ const EditorPage = React.forwardRef((props, ref) => {
}
const handlePublish = () => {
console.log('Publish clicked', { postData });
setShowPublishModal(true);
};
const handlePublishConfirm = () => {
console.log('Publish confirmed', { postData });
setShowPublishModal(false);
// Demo functionality - would save and publish the post
navigate('/dashboard');
};
const handleSaveDraft = () => {
console.log('Save Draft clicked', { postData });
setHasUnsavedChanges(false);
// Demo functionality - would save as draft
};
const handleCancel = () => {
console.log('Cancel clicked');
// Demo functionality - would navigate back to dashboard
setShowCancelModal(true);
};
const handleCancelConfirm = () => {
console.log('Cancel confirmed');
setShowCancelModal(false);
setHasUnsavedChanges(false);
navigate('/dashboard');
};
// Editor mode toggle handler
@@ -79,7 +100,7 @@ const EditorPage = React.forwardRef((props, ref) => {
if (editMode === 'html') {
// Switch to markdown mode - keep the existing markdown content
setEditMode('markdown');
// Reset splitter to 50/50
// Reset splitter to 50/50 for markdown editor
setPanes([
{ size: '50%' },
{}
@@ -95,6 +116,13 @@ const EditorPage = React.forwardRef((props, ref) => {
// Markdown change handler
const handleMarkdownChange = (event) => {
setMarkdownContent(event.target.value);
setHasUnsavedChanges(true);
};
// Content change handler for WYSIWYG editor
const handleContentChange = (newContent) => {
setContent(newContent);
setHasUnsavedChanges(true);
};
// Splitter change handler
@@ -119,7 +147,7 @@ const EditorPage = React.forwardRef((props, ref) => {
{editMode === 'html' ? (
<WysiwygEditor
content={content}
onContentChange={setContent}
onContentChange={handleContentChange}
/>
) : (
<MarkdownEditor
@@ -148,6 +176,21 @@ const EditorPage = React.forwardRef((props, ref) => {
Publish
</Button>
</div>
{/* Confirmation Modals */}
<PublishModal
isVisible={showPublishModal}
onClose={() => setShowPublishModal(false)}
onConfirm={handlePublishConfirm}
postTitle={postData?.title}
/>
<CancelModal
isVisible={showCancelModal}
onClose={() => setShowCancelModal(false)}
onConfirm={handleCancelConfirm}
hasUnsavedChanges={hasUnsavedChanges}
/>
</div>
);
});

View File

@@ -1,13 +1,123 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import LoginComponent from '../components/LoginComponent';
import Logo from '../assets/images/campfire_logs_square_logo_bg_match.png';
const LoginPage = React.forwardRef(({ onLogin }, ref) => {
const [showLogin, setShowLogin] = useState(false);
const [isTransitioning, setIsTransitioning] = useState(true);
const [showKendoCredit, setShowKendoCredit] = useState(false);
useEffect(() => {
// Prevent scrollbar during initial load
document.body.style.overflow = 'hidden';
// First transition: move logo and copyright to final positions
const transitionTimer = setTimeout(() => {
setIsTransitioning(false);
}, 800); // Match copyright transition timing
// Second: fade in login component after transition completes
const loginTimer = setTimeout(() => {
setShowLogin(true);
}, 1500); // Give copyright more time to transition
// Third: show KendoReact credit after login appears
const kendoTimer = setTimeout(() => {
setShowKendoCredit(true);
}, 1800); // After login form appears
// Re-enable scrolling after everything is completely done
const scrollTimer = setTimeout(() => {
document.body.style.overflow = 'auto';
}, 2000); // After all transitions complete
return () => {
clearTimeout(transitionTimer);
clearTimeout(loginTimer);
clearTimeout(kendoTimer);
clearTimeout(scrollTimer);
// Cleanup: restore scrolling if component unmounts
document.body.style.overflow = 'auto';
};
}, []);
return (
<div ref={ref} style={{ textAlign: 'center', width: '400px', margin: '0 auto' }}>
<img src={Logo} alt="Campfire Logs Logo" width="350" height="280" style={{ display: 'block', margin: '0 auto' }} />
<hr style={{ backgroundColor: '#edbd7d', height: '2px', border: 'none', width: '400px', margin: '5px auto 25px auto' }}/>
<LoginComponent onLogin={onLogin} />
<div
ref={ref}
style={{
textAlign: 'center',
width: '400px',
margin: '0 auto',
transition: 'all 0.8s ease-in-out',
transform: isTransitioning ? 'translateY(150px)' : 'translateY(30px)',
overflow: 'hidden'
}}
>
<img
src={Logo}
alt="Campfire Logs Logo"
width="350"
height="280"
style={{
display: 'block',
margin: '0 auto',
transition: 'all 0.8s ease-in-out'
}}
/>
<hr style={{
backgroundColor: '#edbd7d',
height: '2px',
border: 'none',
width: '400px',
margin: '5px auto 25px auto'
}}/>
<div style={{
opacity: showLogin ? 1 : 0,
transition: 'opacity 0.5s ease-in-out',
visibility: showLogin ? 'visible' : 'hidden',
position: 'relative',
width: '100%',
marginTop: '20px'
}}>
<LoginComponent onLogin={onLogin} />
</div>
{/* KendoReact Credit - appears after login form */}
<div style={{
opacity: showKendoCredit ? 1 : 0,
transition: 'opacity 0.6s ease-in-out',
visibility: showKendoCredit ? 'visible' : 'hidden',
position: 'fixed',
bottom: '65px',
left: '50%',
transform: 'translateX(-50%)',
fontSize: '12px',
fontStyle: 'italic',
zIndex: 1000,
padding: '5px 0',
lineHeight: '1.2'
}}>
Powered by{' '}
<a
href="https://www.telerik.com/kendo-react-ui"
target="_blank"
rel="noopener noreferrer"
style={{
color: '#d94f27',
textDecoration: 'none',
transition: 'color 0.2s ease',
cursor: 'pointer',
display: 'inline-block',
padding: '2px 4px',
margin: '-2px -4px',
borderRadius: '2px'
}}
onMouseEnter={(e) => e.target.style.color = '#ff6f48'}
onMouseLeave={(e) => e.target.style.color = '#d94f27'}
>
KendoReact UI
</a>
</div>
</div>
);
});