start project
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
/* app/static/src/css/main.css — Tailwind entry point (Chapter 05) */
|
||||
|
||||
/* Self-hosted fonts (Ch05: build-time only, no runtime CDN dependency).
|
||||
Display face used sparingly for headings/labels; body face for UI
|
||||
chrome; mono face for every data readout (IPs, counts, paths,
|
||||
timestamps) — reinforcing that this app's whole job is turning raw
|
||||
log lines into a readable instrument panel. */
|
||||
@import "@fontsource/space-grotesk/500.css";
|
||||
@import "@fontsource/space-grotesk/700.css";
|
||||
@import "@fontsource/public-sans/400.css";
|
||||
@import "@fontsource/public-sans/500.css";
|
||||
@import "@fontsource/public-sans/600.css";
|
||||
@import "@fontsource/jetbrains-mono/400.css";
|
||||
@import "@fontsource/jetbrains-mono/500.css";
|
||||
|
||||
@import "gridjs/dist/theme/mermaid.css";
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
color-scheme: light;
|
||||
}
|
||||
html.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
body {
|
||||
font-family: theme('fontFamily.sans');
|
||||
}
|
||||
h1, h2, h3, h4, .font-display {
|
||||
font-family: theme('fontFamily.display');
|
||||
}
|
||||
/* Every number/IP/path/timestamp reads like it came straight off the
|
||||
log line — the app's one signature typographic choice. */
|
||||
.font-data {
|
||||
font-family: theme('fontFamily.mono');
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Grid.js theme override to match the token system in both modes,
|
||||
without forking the whole "mermaid" stylesheet. */
|
||||
.gridjs-wrapper, .gridjs-container {
|
||||
@apply !border-line dark:!border-line-dark !rounded-lg;
|
||||
}
|
||||
.gridjs-table {
|
||||
@apply font-data text-sm;
|
||||
}
|
||||
.gridjs-th {
|
||||
@apply !bg-surface-raised dark:!bg-surface-raised-dark !text-muted dark:!text-muted-dark font-sans !font-medium !text-xs !uppercase !tracking-wide;
|
||||
}
|
||||
.gridjs-td {
|
||||
@apply !bg-surface dark:!bg-surface-dark !text-ink dark:!text-ink-dark !border-line dark:!border-line-dark;
|
||||
}
|
||||
.gridjs-footer {
|
||||
@apply !bg-surface dark:!bg-surface-dark !border-line dark:!border-line-dark;
|
||||
}
|
||||
.gridjs-pagination .gridjs-pages button {
|
||||
@apply !text-ink dark:!text-ink-dark;
|
||||
}
|
||||
.gridjs-search-input {
|
||||
@apply !bg-surface dark:!bg-surface-dark !text-ink dark:!text-ink-dark !border-line dark:!border-line-dark;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Shared Chart.js setup (Chapter 05) — avoids duplicating config per chart.
|
||||
* Callers must pass pre-aggregated series only (≤~200 points, per Ch05);
|
||||
* this file never fetches or aggregates raw rows itself.
|
||||
*
|
||||
* Dark-mode aware: reads the current theme at init time and colors grid
|
||||
* lines/ticks/legend accordingly. Charts drawn before a theme toggle
|
||||
* don't live-repaint — they pick up the new theme on their next redraw
|
||||
* (date-range change, tab revisit), which keeps this simple.
|
||||
*/
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
// Shared categorical palette, drawn from the Kavosh token system rather
|
||||
// than Chart.js's stock colors.
|
||||
export const CHART_PALETTE = ['#0E7C86', '#B8860B', '#C4372F', '#3F7D5C', '#5B6663', '#8A6FBE'];
|
||||
|
||||
function isDarkMode() {
|
||||
return document.documentElement.classList.contains('dark');
|
||||
}
|
||||
|
||||
function defaultOptions() {
|
||||
const dark = isDarkMode();
|
||||
const textColor = dark ? '#96A19D' : '#5B6663';
|
||||
const gridColor = dark ? '#2C3336' : '#E1E5E2';
|
||||
const tickFont = { family: '"JetBrains Mono", monospace', size: 11 };
|
||||
|
||||
return {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: { duration: 200 },
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'bottom',
|
||||
labels: { color: textColor, font: { family: '"Public Sans", sans-serif', size: 12 }, boxWidth: 12, padding: 12 },
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { ticks: { color: textColor, font: tickFont }, grid: { color: gridColor } },
|
||||
y: { ticks: { color: textColor, font: tickFont }, grid: { color: gridColor } },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** @param {string} canvasId @param {object} config @returns {Chart} */
|
||||
export function initChart(canvasId, config) {
|
||||
const ctx = document.getElementById(canvasId);
|
||||
if (!ctx) throw new Error(`initChart: no element #${canvasId}`);
|
||||
const base = defaultOptions();
|
||||
const callerOptions = config.options ?? {};
|
||||
return new Chart(ctx, {
|
||||
...config,
|
||||
options: {
|
||||
...base,
|
||||
...callerOptions,
|
||||
scales: config.type === 'doughnut' || config.type === 'pie' ? undefined : { ...base.scales, ...callerOptions.scales },
|
||||
plugins: { ...base.plugins, ...callerOptions.plugins },
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Shared Grid.js setup (Chapter 05) — every table is server-side
|
||||
* paginated/sorted/searched against `?page=&per_page=&sort=&q=`.
|
||||
*/
|
||||
import { Grid } from 'gridjs';
|
||||
|
||||
/**
|
||||
* @param {string} elementId container element id
|
||||
* @param {string} endpoint JSON endpoint, e.g. '/api/overview/top-urls'
|
||||
* @param {Array} columns Grid.js column defs
|
||||
* @param {object} extraParams static extra query params (e.g. {from, to})
|
||||
*/
|
||||
export function initGrid(elementId, endpoint, columns, extraParams = {}) {
|
||||
const buildUrl = (prev, params) => {
|
||||
const url = new URL(prev, window.location.origin);
|
||||
Object.entries({ ...extraParams, ...params }).forEach(([k, v]) => url.searchParams.set(k, v));
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
return new Grid({
|
||||
columns,
|
||||
server: {
|
||||
url: endpoint,
|
||||
then: (res) => res.data.rows,
|
||||
total: (res) => res.data.total,
|
||||
},
|
||||
pagination: {
|
||||
server: { url: (prev, page, per_page) => buildUrl(prev, { page: page + 1, per_page }) },
|
||||
limit: 20,
|
||||
},
|
||||
sort: {
|
||||
server: {
|
||||
url: (prev, cols) => (cols.length ? buildUrl(prev, { sort: cols[0].direction === 1 ? cols[0].index : `-${cols[0].index}` }) : prev),
|
||||
},
|
||||
},
|
||||
search: { server: { url: (prev, keyword) => buildUrl(prev, { q: keyword }) } },
|
||||
}).render(document.getElementById(elementId));
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Vite entry point: HTMX + Alpine init + Chart.js/Grid.js glue (Chapter 05).
|
||||
* Bundled at build time — no CDN scripts, nothing compiled at request time.
|
||||
*/
|
||||
import htmx from 'htmx.org';
|
||||
import Alpine from 'alpinejs';
|
||||
import { html as gridHtml } from 'gridjs';
|
||||
|
||||
import { initChart, CHART_PALETTE } from './charts.js';
|
||||
import { initGrid } from './grids.js';
|
||||
import { initUploadForm } from './uploads.js';
|
||||
|
||||
window.htmx = htmx;
|
||||
window.Alpine = Alpine;
|
||||
window.initChart = initChart;
|
||||
window.initGrid = initGrid;
|
||||
window.gridHtml = gridHtml;
|
||||
window.initUploadForm = initUploadForm;
|
||||
window.KAVOSH_CHART_PALETTE = CHART_PALETTE;
|
||||
|
||||
// Chapter 12: HTMX requests must carry the CSRF token as a header, not
|
||||
// rely on the cookie alone.
|
||||
document.body.addEventListener('htmx:configRequest', (event) => {
|
||||
const token = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
if (token) event.detail.headers['X-CSRFToken'] = token;
|
||||
});
|
||||
|
||||
// Dark mode toggle (persisted; the initial class is set synchronously in
|
||||
// an inline <head> script in base.html to avoid a flash of the wrong
|
||||
// theme before this bundle loads).
|
||||
window.toggleTheme = function toggleTheme() {
|
||||
const isDark = document.documentElement.classList.toggle('dark');
|
||||
localStorage.setItem('kavosh-theme', isDark ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
Alpine.start();
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* Upload with real byte-transfer progress (Chapter 08/12).
|
||||
*
|
||||
* fetch() can't reliably report upload progress across browsers, so this
|
||||
* uses XMLHttpRequest directly rather than depending on htmx's internal
|
||||
* transport. On completion, the returned HTML fragment (queued/duplicate/
|
||||
* error) is injected manually, then handed to htmx.process() so any
|
||||
* hx-* polling attributes inside it (e.g. the processing-status poll)
|
||||
* activate normally, exactly as if htmx had performed the swap itself.
|
||||
*/
|
||||
export function initUploadForm(formId) {
|
||||
const form = document.getElementById(formId);
|
||||
if (!form) return;
|
||||
|
||||
const fileInput = form.querySelector('input[type="file"]');
|
||||
const progressWrap = document.getElementById('upload-progress-wrap');
|
||||
const progressBar = document.getElementById('upload-progress-bar');
|
||||
const progressLabel = document.getElementById('upload-progress-label');
|
||||
const resultEl = document.getElementById('upload-result');
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
|
||||
form.addEventListener('submit', (event) => {
|
||||
event.preventDefault();
|
||||
if (!fileInput.files.length) return;
|
||||
|
||||
const formData = new FormData(form);
|
||||
const xhr = new XMLHttpRequest();
|
||||
|
||||
submitBtn.disabled = true;
|
||||
resultEl.innerHTML = '';
|
||||
progressWrap.classList.remove('hidden');
|
||||
progressBar.style.width = '0%';
|
||||
progressLabel.textContent = 'Uploading… 0%';
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (!e.lengthComputable) return;
|
||||
const pct = Math.round((e.loaded / e.total) * 100);
|
||||
progressBar.style.width = pct + '%';
|
||||
progressLabel.textContent = `Uploading… ${pct}%`;
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
submitBtn.disabled = false;
|
||||
progressWrap.classList.add('hidden');
|
||||
resultEl.innerHTML = xhr.responseText;
|
||||
if (window.htmx) window.htmx.process(resultEl);
|
||||
form.reset();
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
submitBtn.disabled = false;
|
||||
progressWrap.classList.add('hidden');
|
||||
resultEl.innerHTML =
|
||||
'<p class="text-danger dark:text-danger-dark text-sm">Upload failed — check your connection and try again.</p>';
|
||||
});
|
||||
|
||||
xhr.open('POST', form.getAttribute('action') || '/uploads');
|
||||
const token = document.querySelector('meta[name="csrf-token"]')?.content;
|
||||
if (token) xhr.setRequestHeader('X-CSRFToken', token);
|
||||
xhr.send(formData);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user