274 lines
8.8 KiB
JavaScript
Executable File
274 lines
8.8 KiB
JavaScript
Executable File
import React, { useState, useEffect } from 'react';
|
|
import ViolationForm from './components/ViolationForm';
|
|
import Dashboard from './components/Dashboard';
|
|
import ReadmeModal from './components/ReadmeModal';
|
|
import ToastProvider from './components/ToastProvider';
|
|
import './styles/mobile.css';
|
|
|
|
const REPO_URL = 'https://git.alwisp.com/jason/cpas';
|
|
const PROJECT_START = new Date('2026-03-06T11:33:32-06:00');
|
|
|
|
function elapsed(from) {
|
|
const totalSec = Math.floor((Date.now() - from.getTime()) / 1000);
|
|
const d = Math.floor(totalSec / 86400);
|
|
const h = Math.floor((totalSec % 86400) / 3600);
|
|
const m = Math.floor((totalSec % 3600) / 60);
|
|
const s = totalSec % 60;
|
|
return `${d}d ${String(h).padStart(2,'0')}h ${String(m).padStart(2,'0')}m ${String(s).padStart(2,'0')}s`;
|
|
}
|
|
|
|
function DevTicker() {
|
|
const [tick, setTick] = useState(() => elapsed(PROJECT_START));
|
|
useEffect(() => {
|
|
const id = setInterval(() => setTick(elapsed(PROJECT_START)), 1000);
|
|
return () => clearInterval(id);
|
|
}, []);
|
|
return (
|
|
<span title="Time since first commit" style={{ display: 'inline-flex', alignItems: 'center', gap: '5px' }}>
|
|
<span style={{
|
|
width: '7px', height: '7px', borderRadius: '50%',
|
|
background: '#22c55e', display: 'inline-block',
|
|
animation: 'cpas-pulse 1.4s ease-in-out infinite',
|
|
}} />
|
|
{tick}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function GiteaIcon() {
|
|
return (
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" style={{ verticalAlign: 'middle' }}>
|
|
<path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0 0 24 12c0-6.63-5.37-12-12-12z"/>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function AppFooter({ version }) {
|
|
const year = new Date().getFullYear();
|
|
const sha = version?.shortSha || null;
|
|
const built = version?.buildTime
|
|
? new Date(version.buildTime).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
: null;
|
|
|
|
return (
|
|
<>
|
|
<style>{`
|
|
@keyframes cpas-pulse {
|
|
0%, 100% { opacity: 1; transform: scale(1); }
|
|
50% { opacity: 0.4; transform: scale(0.75); }
|
|
}
|
|
|
|
/* Mobile-specific footer adjustments */
|
|
@media (max-width: 768px) {
|
|
.footer-content {
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
font-size: 10px;
|
|
padding: 10px 16px;
|
|
gap: 8px;
|
|
}
|
|
}
|
|
`}</style>
|
|
<footer style={sf.footer} className="footer-content">
|
|
<span style={sf.copy}>© {year} Jason Stedwell</span>
|
|
<span style={sf.sep}>·</span>
|
|
<DevTicker />
|
|
<span style={sf.sep}>·</span>
|
|
<a href={REPO_URL} target="_blank" rel="noopener noreferrer" style={sf.link}>
|
|
<GiteaIcon /> cpas
|
|
</a>
|
|
{sha && sha !== 'dev' && (
|
|
<>
|
|
<span style={sf.sep}>·</span>
|
|
<a
|
|
href={`${REPO_URL}/commit/${version.sha}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
style={sf.link}
|
|
title={built ? `Built ${built}` : 'View commit'}
|
|
>
|
|
{sha}
|
|
</a>
|
|
</>
|
|
)}
|
|
</footer>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const tabs = [
|
|
{ id: 'dashboard', label: '📊 Dashboard' },
|
|
{ id: 'violation', label: '+ New Violation' },
|
|
];
|
|
|
|
// Responsive utility hook
|
|
function useMediaQuery(query) {
|
|
const [matches, setMatches] = useState(false);
|
|
useEffect(() => {
|
|
const media = window.matchMedia(query);
|
|
if (media.matches !== matches) setMatches(media.matches);
|
|
const listener = () => setMatches(media.matches);
|
|
media.addEventListener('change', listener);
|
|
return () => media.removeEventListener('change', listener);
|
|
}, [matches, query]);
|
|
return matches;
|
|
}
|
|
|
|
const s = {
|
|
app: { minHeight: '100vh', background: '#050608', fontFamily: "'Segoe UI', Arial, sans-serif", color: '#f8f9fa', display: 'flex', flexDirection: 'column' },
|
|
nav: { background: '#000000', padding: '0 40px', display: 'flex', alignItems: 'center', gap: 0, borderBottom: '1px solid #333' },
|
|
logoWrap: { display: 'flex', alignItems: 'center', marginRight: '32px', padding: '14px 0' },
|
|
logoImg: { height: '28px', marginRight: '10px' },
|
|
logoText: { color: '#f8f9fa', fontWeight: 800, fontSize: '18px', letterSpacing: '0.5px' },
|
|
tab: (active) => ({
|
|
padding: '18px 22px',
|
|
color: active ? '#f8f9fa' : 'rgba(248,249,250,0.6)',
|
|
borderBottom: active ? '3px solid #d4af37' : '3px solid transparent',
|
|
cursor: 'pointer', fontWeight: active ? 700 : 400, fontSize: '14px',
|
|
background: 'none', border: 'none',
|
|
}),
|
|
docsBtn: {
|
|
marginLeft: 'auto',
|
|
background: 'none',
|
|
border: '1px solid #2a2b3a',
|
|
color: '#9ca0b8',
|
|
borderRadius: '6px',
|
|
padding: '6px 14px',
|
|
fontSize: '12px',
|
|
cursor: 'pointer',
|
|
fontWeight: 600,
|
|
letterSpacing: '0.3px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '6px',
|
|
},
|
|
main: { flex: 1 },
|
|
card: { maxWidth: '1100px', margin: '30px auto', background: '#111217', borderRadius: '10px', boxShadow: '0 2px 16px rgba(0,0,0,0.6)', border: '1px solid #222' },
|
|
};
|
|
|
|
// Mobile-responsive style overrides
|
|
const mobileStyles = `
|
|
@media (max-width: 768px) {
|
|
.app-nav {
|
|
padding: 0 16px !important;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
}
|
|
.logo-wrap {
|
|
margin-right: 0 !important;
|
|
padding: 12px 0 !important;
|
|
width: 100%;
|
|
justify-content: center;
|
|
border-bottom: 1px solid #1a1b22;
|
|
}
|
|
.nav-tabs {
|
|
display: flex;
|
|
width: 100%;
|
|
justify-content: space-around;
|
|
}
|
|
.nav-tab {
|
|
flex: 1;
|
|
text-align: center;
|
|
padding: 14px 8px !important;
|
|
font-size: 13px !important;
|
|
}
|
|
.docs-btn {
|
|
position: absolute;
|
|
top: 16px;
|
|
right: 16px;
|
|
padding: 4px 10px !important;
|
|
font-size: 11px !important;
|
|
}
|
|
.docs-btn span:first-child {
|
|
display: none;
|
|
}
|
|
.main-card {
|
|
margin: 12px !important;
|
|
border-radius: 8px !important;
|
|
}
|
|
}
|
|
|
|
@media (max-width: 480px) {
|
|
.logo-text {
|
|
font-size: 16px !important;
|
|
}
|
|
.logo-img {
|
|
height: 24px !important;
|
|
}
|
|
}
|
|
`;
|
|
|
|
const sf = {
|
|
footer: {
|
|
borderTop: '1px solid #1a1b22',
|
|
padding: '12px 40px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '12px',
|
|
fontSize: '11px',
|
|
color: 'rgba(248,249,250,0.35)',
|
|
background: '#000',
|
|
flexShrink: 0,
|
|
},
|
|
copy: { color: 'rgba(248,249,250,0.35)' },
|
|
sep: { color: 'rgba(248,249,250,0.15)' },
|
|
link: {
|
|
color: 'rgba(248,249,250,0.35)',
|
|
textDecoration: 'none',
|
|
display: 'inline-flex',
|
|
alignItems: 'center',
|
|
gap: '4px',
|
|
transition: 'color 0.15s',
|
|
},
|
|
};
|
|
|
|
export default function App() {
|
|
const [tab, setTab] = useState('dashboard');
|
|
const [showReadme, setShowReadme] = useState(false);
|
|
const [version, setVersion] = useState(null);
|
|
const isMobile = useMediaQuery('(max-width: 768px)');
|
|
|
|
useEffect(() => {
|
|
fetch('/version.json')
|
|
.then(r => r.ok ? r.json() : null)
|
|
.then(v => { if (v) setVersion(v); })
|
|
.catch(() => {});
|
|
}, []);
|
|
|
|
return (
|
|
<ToastProvider>
|
|
<style>{mobileStyles}</style>
|
|
<div style={s.app}>
|
|
<nav style={s.nav} className="app-nav">
|
|
<div style={s.logoWrap} className="logo-wrap">
|
|
<img src="/static/mpm-logo.png" alt="MPM" style={s.logoImg} className="logo-img" />
|
|
<div style={s.logoText} className="logo-text">CPAS Tracker</div>
|
|
</div>
|
|
|
|
<div className="nav-tabs">
|
|
{tabs.map(t => (
|
|
<button key={t.id} style={s.tab(tab === t.id)} className="nav-tab" onClick={() => setTab(t.id)}>
|
|
{isMobile ? t.label.replace('📊 ', '📊 ').replace('+ New ', '+ ') : t.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<button style={s.docsBtn} className="docs-btn" onClick={() => setShowReadme(true)} title="Open admin documentation">
|
|
<span>?</span> Docs
|
|
</button>
|
|
</nav>
|
|
|
|
<div style={s.main}>
|
|
<div style={s.card} className="main-card">
|
|
{tab === 'dashboard' ? <Dashboard /> : <ViolationForm />}
|
|
</div>
|
|
</div>
|
|
|
|
<AppFooter version={version} />
|
|
|
|
{showReadme && <ReadmeModal onClose={() => setShowReadme(false)} />}
|
|
</div>
|
|
</ToastProvider>
|
|
);
|
|
}
|