start project

This commit is contained in:
Hemmat
2026-08-07 21:17:17 +03:30
commit ea1e1eead6
121 changed files with 8108 additions and 0 deletions
+67
View File
@@ -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;
}
}
+62
View File
@@ -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 },
},
});
}
+38
View File
@@ -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));
}
+36
View File
@@ -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();
+62
View File
@@ -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);
});
}