Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b40959b5dd | |||
| cf511908b8 | |||
| fc33b7d5f6 | |||
| 7d5b7a3e14 | |||
| d5630b0df9 | |||
| 68a4f9544c |
-21
@@ -1,21 +0,0 @@
|
|||||||
# Dependencies
|
|
||||||
node_modules/
|
|
||||||
|
|
||||||
# Tailwind build temp
|
|
||||||
tmp.json
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
*.log
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
|
|
||||||
# OS
|
|
||||||
.DS_Store
|
|
||||||
Thumbs.db
|
|
||||||
|
|
||||||
# Editor
|
|
||||||
.vscode/
|
|
||||||
.idea/
|
|
||||||
*.swp
|
|
||||||
*.swo
|
|
||||||
@@ -1,233 +0,0 @@
|
|||||||
# AGENTS.md — NGO Landing Page
|
|
||||||
|
|
||||||
Read this file fully before taking any action.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Project Context
|
|
||||||
|
|
||||||
Single-page static landing page for an NGO ERP product.
|
|
||||||
|
|
||||||
| Item | Detail |
|
|
||||||
|---|---|
|
|
||||||
| Pages | `index.html` — the only HTML file |
|
|
||||||
| Styling | Tailwind CSS v3 · source `src/input.css` → compiled `assets/site.css` |
|
|
||||||
| Data | `blog.json` — flat array of blog posts, loaded at runtime by inline JS |
|
|
||||||
| i18n | Inline `translations` object inside `index.html` (EN / ES / DE) |
|
|
||||||
| Blog renderer | Inline `<script>` in `index.html` — no external JS files |
|
|
||||||
| Dev server | `./start` (runs `live-server`) |
|
|
||||||
| Git remote | `git@git.odoo4projects.com:Oliver/NGO.git` |
|
|
||||||
|
|
||||||
### File map
|
|
||||||
|
|
||||||
```
|
|
||||||
index.html ← only HTML file — layout + all inline JS + i18n
|
|
||||||
src/input.css ← Tailwind source (edit here, then rebuild)
|
|
||||||
assets/site.css ← compiled output — DO NOT edit by hand
|
|
||||||
blog.json ← English blog posts (canonical + fallback)
|
|
||||||
blog.es.json ← Spanish blog posts
|
|
||||||
blog.de.json ← German blog posts
|
|
||||||
tailwind.config.js ← design tokens (colors, fonts, shadows)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Design tokens (from `tailwind.config.js`)
|
|
||||||
|
|
||||||
| Token | Hex | Usage |
|
|
||||||
|---|---|---|
|
|
||||||
| `brand-ink` | `#201824` | default text / dark backgrounds |
|
|
||||||
| `brand-primary` | `#603F57` | section labels, bullet accents |
|
|
||||||
| `brand-accent` | `#F762B4` | CTA buttons, links |
|
|
||||||
| `brand-sunrise` | `#F8B84A` | hero highlights, hover states |
|
|
||||||
| `brand-emerald` | `#39B982` | positive indicators |
|
|
||||||
| `brand-snow` | `#F5F2F7` | page background |
|
|
||||||
| `brand-smoke` | `#E5DCE8` | borders, subtle dividers |
|
|
||||||
|
|
||||||
NEVER use raw hex values in HTML — always use the token class names above.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Goal
|
|
||||||
|
|
||||||
- Deliver an **SEO-optimised, fast-loading, responsive** single-page site.
|
|
||||||
- Keep all edits **minimal and targeted** — preserve design, structure, and functionality.
|
|
||||||
- NEVER introduce new frameworks, libraries, or external dependencies.
|
|
||||||
|
|
||||||
### Quality bar for every change
|
|
||||||
|
|
||||||
| Concern | Rule |
|
|
||||||
|---|---|
|
|
||||||
| SEO | Keep all existing meta tags, `ld+json`, `alt` attributes, and heading hierarchy |
|
|
||||||
| Performance | No comments in production HTML, no unused CSS classes, no render-blocking additions |
|
|
||||||
| Responsive | Every element must work on mobile (375 px), tablet (768 px), and desktop (1280 px+) |
|
|
||||||
| Consistency | Use only brand token classes; match existing spacing and rounding patterns |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Build CSS after editing src/input.css or tailwind.config.js
|
|
||||||
npm run build:css
|
|
||||||
|
|
||||||
# Watch CSS during active development
|
|
||||||
npm run watch:css
|
|
||||||
|
|
||||||
# Validate blog.json
|
|
||||||
jq empty blog.json && echo "JSON valid"
|
|
||||||
|
|
||||||
# Start dev server
|
|
||||||
./start
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Git Rules
|
|
||||||
|
|
||||||
- ALWAYS work on **`main`** branch.
|
|
||||||
- Pull before every session:
|
|
||||||
```bash
|
|
||||||
git pull origin main
|
|
||||||
```
|
|
||||||
- Commit messages must follow the pattern:
|
|
||||||
- `blog: add post – <title>`
|
|
||||||
- `fix: <short description>`
|
|
||||||
- `content: <short description>`
|
|
||||||
- Push immediately after committing — never leave unpushed commits.
|
|
||||||
- Branch creation is permitted ONLY under the **Branch Handling Exception** (see bottom).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## File Safety Rules
|
|
||||||
|
|
||||||
- ONLY modify files required for the task.
|
|
||||||
- NEVER delete or restructure content unrelated to the task.
|
|
||||||
- NEVER remove existing blog posts from `blog.json`.
|
|
||||||
- NEVER edit `assets/site.css` directly — always rebuild from `src/input.css`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Blog Post Schema
|
|
||||||
|
|
||||||
### Language-to-file mapping
|
|
||||||
|
|
||||||
| Language | File loaded | Fallback |
|
|
||||||
|---|---|---|
|
|
||||||
| English (`en`) | `blog.json` | — |
|
|
||||||
| Spanish (`es`) | `blog.es.json` | `blog.json` |
|
|
||||||
| German (`de`) | `blog.de.json` | `blog.json` |
|
|
||||||
|
|
||||||
The loader in `index.html` fetches the language-specific file automatically when the visitor switches language. If a language file is missing or returns an error, it falls back to `blog.json` silently.
|
|
||||||
|
|
||||||
`blog.json` is always the **English source and fallback**. Keep all three files structurally identical — same number of posts, same order, translated content only.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
`blog.json` (and every language variant) is a root-level JSON array. Every post object contains **exactly these five fields**:
|
|
||||||
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{
|
|
||||||
"area": "Automation",
|
|
||||||
"date": "2026-03-25",
|
|
||||||
"title": "Article title as plain text",
|
|
||||||
"teaser": "<p>One or two sentences visible before 'Read more'.</p>",
|
|
||||||
"content": "<h3>Section</h3><p>Full article as HTML.</p>"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Constraint |
|
|
||||||
|---|---|---|
|
|
||||||
| `area` | plain string | Category label rendered above the title |
|
|
||||||
| `date` | ISO 8601 (`YYYY-MM-DD`) | Used by `Intl.DateTimeFormat` |
|
|
||||||
| `title` | plain string | Rendered as `<h3>` — no HTML |
|
|
||||||
| `teaser` | HTML string | Always visible — wrap in `<p>` tags |
|
|
||||||
| `content` | HTML string | Revealed by "Read more" toggle — use `<h3>`, `<p>`, `<ul>`, `<li>` |
|
|
||||||
|
|
||||||
NEVER add extra fields (`slug`, `author`, `tags`, `excerpt`, `hero_image`, etc.) — they are ignored by the renderer and pollute the file.
|
|
||||||
|
|
||||||
### Adding a new blog post (jq method)
|
|
||||||
|
|
||||||
> Add to **all three files** in the same session. A post that exists in English but not in Spanish/German will silently fall back to the English version for those visitors — which is acceptable temporarily but should be resolved promptly.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Pull latest
|
|
||||||
git pull origin main
|
|
||||||
|
|
||||||
# 2. Backup all blog files once per session
|
|
||||||
mkdir -p /backup
|
|
||||||
for f in blog.json blog.es.json blog.de.json; do
|
|
||||||
cp $f /backup/$f.$(date +%Y%m%d%H%M%S)
|
|
||||||
done
|
|
||||||
|
|
||||||
# 3. Prepend new post to each language file
|
|
||||||
# $NEW_EN, $NEW_ES, $NEW_DE must each be a valid single post object
|
|
||||||
jq --argjson new "$NEW_EN" '. = [$new] + .' blog.json > tmp.json && mv tmp.json blog.json
|
|
||||||
jq --argjson new "$NEW_ES" '. = [$new] + .' blog.es.json > tmp.json && mv tmp.json blog.es.json
|
|
||||||
jq --argjson new "$NEW_DE" '. = [$new] + .' blog.de.json > tmp.json && mv tmp.json blog.de.json
|
|
||||||
|
|
||||||
# 4. Validate all three
|
|
||||||
jq empty blog.json && echo "blog.json OK"
|
|
||||||
jq empty blog.es.json && echo "blog.es.json OK"
|
|
||||||
jq empty blog.de.json && echo "blog.de.json OK"
|
|
||||||
|
|
||||||
# 5. Commit and push
|
|
||||||
git add blog.json blog.es.json blog.de.json
|
|
||||||
git commit -m "blog: add post – <title>"
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
NEVER use Python, Node scripts, or manual text editing to modify `blog.json`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Execution Workflow
|
|
||||||
|
|
||||||
Follow this order for every task:
|
|
||||||
|
|
||||||
1. `git pull origin main`
|
|
||||||
2. Identify the exact files to change — nothing else.
|
|
||||||
3. Apply minimal edits.
|
|
||||||
4. If `src/input.css` or `tailwind.config.js` were changed → rebuild CSS.
|
|
||||||
5. Validate: `jq empty blog.json` for JSON; visual check for HTML.
|
|
||||||
6. Commit with a descriptive message.
|
|
||||||
7. `git push origin main`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Success Criteria
|
|
||||||
|
|
||||||
- Changes are minimal, validated, and committed.
|
|
||||||
- `blog.json` is a valid array with exactly five fields per post.
|
|
||||||
- `assets/site.css` reflects any CSS changes (rebuilt, not hand-edited).
|
|
||||||
- HTML passes a visual check — layout and design unchanged except for the intended edit.
|
|
||||||
- All commits pushed to `main`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Failure Conditions
|
|
||||||
|
|
||||||
These actions constitute a failure:
|
|
||||||
|
|
||||||
- Refusing to act due to perceived access restrictions.
|
|
||||||
- Failing to push after committing.
|
|
||||||
- Adding undeclared fields to `blog.json`.
|
|
||||||
- Removing or overwriting existing blog posts.
|
|
||||||
- Editing `assets/site.css` by hand.
|
|
||||||
- Modifying files outside the scope of the task.
|
|
||||||
- Leaving a branch open without merging.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Branch Handling Exception
|
|
||||||
|
|
||||||
Branch creation is permitted **only** when:
|
|
||||||
|
|
||||||
- The task explicitly requires a feature branch, **or**
|
|
||||||
- A direct push to `main` is blocked by branch protection.
|
|
||||||
|
|
||||||
If a branch is created it **must**:
|
|
||||||
|
|
||||||
- Follow the naming convention `<type>/<short-description>` (e.g. `fix/blog-json-schema`).
|
|
||||||
- Be merged into `main` via PR immediately after the task is complete.
|
|
||||||
- Be deleted after merge — never abandoned.
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
Rollback and Recovery Changelog
|
||||||
|
|
||||||
|
Timestamp: ${TS}
|
||||||
|
|
||||||
|
Actions performed:
|
||||||
|
- Reset main branch (working tree and HEAD) to commit 6127867d51c9f0e4e86d1d8390081208f45136ab and force-pushed to origin/main.
|
||||||
|
- Confirmed README.md content matches version at commit 6127867d51.
|
||||||
|
- Investigated deleted backups: located backups in parent commit 5189b23 and extracted available files into recovered_backups/ directory in the working tree (not yet committed).
|
||||||
|
- Created this changelog and saved recovered backup files under recovered_backups/ for review.
|
||||||
|
|
||||||
|
Recovered backup files:
|
||||||
|
- backups/index.html.20260324164129.bak -> recovered_backups/index.html.20260324164129.bak (extracted from commit 5189b23)
|
||||||
|
- backups/index.html.bak.20260324164053 -> recovered_backups/index.html.bak.20260324164053 (extracted from commit 5189b23)
|
||||||
|
|
||||||
|
Notes on recovery:
|
||||||
|
- The deleted backup files were present in previous commits and have been extracted from git history into recovered_backups/.
|
||||||
|
- Additional backup files referencing other timestamps exist in git history (e.g., backups/index.html.20260324164556.bak and backups/index.html.20260324164626.bak). These can be extracted on request.
|
||||||
|
|
||||||
|
Suspicious findings:
|
||||||
|
- Commit 6127867d51 (message: "Deleted Backups and changed readme") removed backup files. Author: Oliver. This commit may be responsible for missing backups. Please confirm whether this deletion was authorized.
|
||||||
|
|
||||||
|
Remaining tasks / recommendations:
|
||||||
|
- Decide whether to reintroduce recovered backups into main branch. Restoring them to main will create a new commit on top of 6127867d51 (this changelog can be used), or they can be provided in a separate branch or as artifacts.
|
||||||
|
- If you want me to commit the recovered_backups/ content and this changelog to the repository and push, confirm and I will proceed.
|
||||||
|
- If you prefer the repository to remain exactly at 6127867d51 (no additional commits), I will not commit recovered files; instead I will provide them separately (download/attach) and leave main at the rollback state.
|
||||||
|
|
||||||
@@ -5,24 +5,12 @@
|
|||||||
**Stack:** Static HTML · Tailwind CSS · Vanilla JS
|
**Stack:** Static HTML · Tailwind CSS · Vanilla JS
|
||||||
|
|
||||||
---
|
---
|
||||||
## BLOG POSTS
|
|
||||||
Blog posts live in `blog.json` — do not create separate backup files.
|
|
||||||
To add a blog post customize this command
|
|
||||||
jq '.arrays[0] = [$NEW_JSON] + .arrays[0]' blog.json > tmp.json && mv tmp.json blog.json
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
## Repository Rules
|
## Repository Rules
|
||||||
- Do not touch this readme
|
|
||||||
- Keep the seperation of Site and the blogposts in blog.json
|
- Blog posts live in `blog.json` — do not create separate backup files.
|
||||||
Example of JSON structure for blog.json
|
- No backup files in the repo.
|
||||||
{
|
- Update this README with every commit.
|
||||||
"area": "Transparency",
|
|
||||||
"title": "Every Peso on the Page: Ana's Ledger Story",
|
|
||||||
"teaser": "<p>Ana replaced five notebooks with a four-stop impact ledger and now answers donor questions in seconds.</p>",
|
|
||||||
"content": "<h3>Every peso needs a map</h3>\n<p>Ana used to juggle five notebooks and still felt blind when a donor asked where their coin landed.</p>\n<h3>Pressure points</h3>\n<p without late nights.</p>\n",
|
|
||||||
"date": "2026-03-22"
|
|
||||||
},
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -38,6 +26,22 @@ Example of JSON structure for blog.json
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## CI Checks (required on every push)
|
||||||
|
|
||||||
|
- **HTML lint** — valid markup, no broken anchors
|
||||||
|
- **JS lint** — no errors, no `console.error` left in production paths
|
||||||
|
- **Tailwind build** — `npx tailwindcss -i ./src/input.css -o ./assets/site.css --minify` must exit 0
|
||||||
|
- **Corporate design compliance:**
|
||||||
|
- No CSS gradients — `gradient`, `bg-*-gradient`, `linear-gradient`, `radial-gradient` are forbidden
|
||||||
|
- No CSS animations or keyframes — `animate-*`, `@keyframes`, transform-based `hover:translate-*` are forbidden
|
||||||
|
- No blur or glassmorphism effects — `backdrop-blur`, `backdrop-filter` are forbidden
|
||||||
|
- Colors must use the approved brand palette only: `brand-ink · brand-primary · brand-accent · brand-sunrise · brand-emerald · brand-snow · brand-smoke`
|
||||||
|
- Images must be served from `odoo4projects.com`, `images.unsplash.com`, or local `assets/`
|
||||||
|
- **Accessibility** — heading hierarchy (one `<h1>` per page), `alt` on all images, sufficient contrast (WCAG AA)
|
||||||
|
- **Performance budget** — total page weight ≤ 200 KB (excluding cached fonts); critical render path ≤ 1 s on fast 3G
|
||||||
|
- **SEO** — `<title>`, `<meta name="description">`, `<h1>`, canonical `<link>`, and JSON-LD structured data must be present
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Languages
|
## Languages
|
||||||
|
|
||||||
@@ -48,3 +52,9 @@ Example of JSON structure for blog.json
|
|||||||
| `de` | Deutsch |
|
| `de` | Deutsch |
|
||||||
|
|
||||||
All UI strings are managed via the inline `translations` object in `index.html`.
|
All UI strings are managed via the inline `translations` object in `index.html`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Focus Keywords
|
||||||
|
|
||||||
|
`NGO donor management` · `transparent donations` · `Odoo for NGOs` · `donor communication` · `minimal admin for charities`
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
+1
-1
File diff suppressed because one or more lines are too long
@@ -1,16 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"area": "Automatisierung",
|
|
||||||
"date": "2026-03-25",
|
|
||||||
"title": "NGO-Verwaltung um 20–40 % reduzieren: 7 Workflows, die du diesen Monat automatisieren kannst",
|
|
||||||
"teaser": "<p>Maria verbrachte ihre Abende mit Papierkram statt mit den Familien, denen sie helfen wollte. Mit Odoo und n8n reduzierte ihr Team den Verwaltungsaufwand in unter vier Wochen um ein Drittel.</p>",
|
|
||||||
"content": "<h3>Die Admin-Falle, die jede NGO kennt</h3><p>Verstreute Spenderdaten, wiederholte Kopier-Einfüge-Schritte und langsame Bestätigungen untergraben das Vertrauen und kosten wertvolle Energie. Ein ERP bündelt Kontakte, Spenden, Rechnungen und Projektaufgaben an einem zentralen Ort — niemand muss dieselben Daten mehr in fünf verschiedene Tools eintippen.</p><h3>Odoo als Kern, n8n als Verbindung</h3><p>Nutze Odoo für Kontakte, Spenden, Projekte und Dokumente. Erstelle dann n8n-Workflows, um automatische Spendenquittungen zu versenden, Kontaktdatensätze bei Formulareinreichungen zu aktualisieren, Folgeaufgaben zu terminieren und Zusammenfassungsberichte per Slack oder E-Mail zu verteilen.</p><h3>7 Workflows für diesen Monat</h3><ul><li>Sofortige Dankesnachricht an Spender bei jeder Zuwendung</li><li>Fördermittel-Compliance-Erinnerungen im 30-Tage-Rhythmus</li><li>Onboarding-Checkliste für Freiwillige automatisch bei der Anmeldung</li><li>Monatlicher Impact-Bericht automatisch erstellt und versendet</li><li>Dubletten-Erkennung bei jedem Datenimport</li><li>Rechnungsgenehmigung nach Projekt und Budgetposition geroutet</li><li>Ausgabenerfassung aus dem Feld mit Foto-Belegerfassung</li></ul><h3>Was du erwarten kannst</h3><p>Typische NGO-Implementierungen zeigen innerhalb weniger Wochen 20–40 % weniger Verwaltungsstunden. Marias Team hörte mit den nächtlichen Admin-Sprints auf und begann jeden Morgen mit einem einzigen Dashboard — mehr Zeit für Lehrercoaching, Gemeindebesuche und die Planung wirkungsvoller Programme.</p>"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"area": "Spenderkommunikation",
|
|
||||||
"date": "2026-03-18",
|
|
||||||
"title": "Wie Maya das Spender-Chaos in klare Verbindungen verwandelte",
|
|
||||||
"teaser": "<p>Maya verwandelte verstreute Spendernachrichten in einen reibungslosen, vertrauenswürdigen Ablauf, der die Unterstützung wachsen lässt und das gesamte Team auf Kurs hält.</p>",
|
|
||||||
"content": "<h3>Von verstreuten E-Mails zur einheitlichen Ansicht</h3><p>Maya jonglierte früher mit Tabellen, E-Mail-Threads und Haftnotizen, um Spenderinteraktionen zu verfolgen. Nachrichten gingen verloren, Dankesworte kamen verspätet an und das Vertrauen bröckelte. Sie wusste, dass sich etwas ändern musste.</p><h3>Den Spender-Journey strukturieren</h3><p>Sie kartierte jeden Kontaktpunkt: Erstkontakt, Spende, Nachfassaktionen, Erneuerungen und Sonderkampagnen. Jede Interaktion erhielt eine Karte in Odoo mit Spendername, Datum, bevorzugtem Kanal und Teamnotizen — für alle auf einen Blick sichtbar.</p><h3>Automatisierte Berührungspunkte</h3><p>n8n übernimmt die Routine: Spender werden innerhalb von Minuten nach einer Zuwendung gedankt, Zusagen werden bestätigt und das Team wird vor fälligen Erneuerungen benachrichtigt. Nichts fällt durchs Raster, und der persönliche Ton bleibt erhalten — dank Vorlagen, die auf jedes Spendersegment zugeschnitten sind.</p><h3>Transparenz auf allen Ebenen</h3><p>Spender können Updates zu den Projekten sehen, die sie unterstützt haben. Status-Labels in Kampagnen zeigen zugewiesene Mittel, Impact-Geschichten und bevorstehende Veranstaltungen. Mitarbeiter hinterlassen für Kollegen sichtbare Notizen — doppelte Kontaktaufnahmen und unangenehme Überschneidungen gehören der Vergangenheit an.</p><h3>Visuelle Echtzeit-Dashboards</h3><p>Ein Live-Dashboard zeigt Spenderaktivitäten, bevorstehende Erneuerungen und die Kampagnengesundheit auf einen Blick. Farbcodes setzen Prioritäten: Grün für bestätigte Gaben, Gelb für ausstehende Zusagen, Rot für dringende Nachfassaktionen, die menschliche Aufmerksamkeit erfordern.</p><h3>Kommunikationsrhythmus</h3><p>Wöchentliche Spender-Check-ins halten das Team auf Kurs. Monatliche Newsletter heben Erfolge, Spender-Impact und kommende Möglichkeiten hervor. Sonderkampagnen lösen personalisierte Micro-E-Mails aus, die auf Spenderlevel und Verlauf jeder Person abgestimmt sind.</p><ul><li>Montag: neue Spender und ausstehende Nachfassaktionen prüfen.</li><li>Mittwoch: Kampagnenfortschritt bestätigen und Spendernotizen aktualisieren.</li><li>Freitag: Dankesmails und Impact-Updates versenden.</li></ul><h3>Kennzahlen & Wirkung</h3><p>Die Spenderbindung verbesserte sich um 25 %. Reaktionszeiten sanken von Tagen auf Stunden. Mitarbeiter berichten von weniger Stress, weil Kommunikation berechenbar, sichtbar und nicht mehr vom Gedächtnis einer einzelnen Person abhängig ist.</p><h3>Dein Rollout-Plan</h3><ul><li>Alle Spenderdaten in Odoo-Karten importieren und Verantwortliche zuweisen.</li><li>n8n-Alerts für Gaben, Nachfassaktionen und bevorstehende Erneuerungen einrichten.</li><li>Dashboards mit aktiven Spendern, Zusagen und Kampagnenstatus erstellen.</li><li>Dankesmails und vierteljährliche Impact-Updates automatisieren.</li></ul><p>Vier Schritte ersetzen wochenlange verstreute E-Mails und verpasste Möglichkeiten — und jeder Spender beginnt sich wirklich wahrgenommen zu fühlen.</p>"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"area": "Automatización",
|
|
||||||
"date": "2026-03-25",
|
|
||||||
"title": "Reduce el trabajo administrativo de tu ONG un 20–40 %: 7 flujos que puedes automatizar este mes",
|
|
||||||
"teaser": "<p>María pasaba sus noches sepultada en papeleo en lugar de ayudar a las familias que había venido a servir. Con Odoo y n8n, su equipo redujo el tiempo administrativo en un tercio en menos de cuatro semanas.</p>",
|
|
||||||
"content": "<h3>La trampa administrativa que toda ONG conoce</h3><p>Datos dispersos de donantes, pasos repetitivos de copiar y pegar y acuses de recibo lentos erosionan la confianza y agotan la energía del equipo. Un ERP centraliza contactos, donaciones, facturas y tareas de proyecto en un único lugar, de modo que nadie tenga que reintroducir los mismos datos en cinco herramientas distintas.</p><h3>Odoo como núcleo, n8n como conector</h3><p>Usa Odoo para Contactos, Donaciones, Proyectos y Documentos. Después crea flujos en n8n para enviar recibos automáticos a donantes, actualizar registros cuando se envían formularios, programar tareas de seguimiento y distribuir informes de resumen por Slack o correo electrónico.</p><h3>7 flujos para automatizar este mes</h3><ul><li>Correo de agradecimiento instantáneo al donante en cada aportación</li><li>Recordatorios de cumplimiento de subvenciones cada 30 días</li><li>Lista de incorporación de voluntarios activada al registrarse</li><li>Informe de impacto mensual generado y enviado automáticamente</li><li>Detección de contactos duplicados en cada importación</li><li>Enrutamiento de aprobaciones de facturas por proyecto y línea presupuestaria</li><li>Envío de gastos desde campo con captura de recibo fotográfico</li></ul><h3>Resultados esperados</h3><p>Las implementaciones habituales en ONG muestran un 20–40 % menos de horas administrativas en pocas semanas. El equipo de María dejó de hacer maratones nocturnos de administración y empezó cada mañana con un solo panel de control, liberando tiempo para capacitar a docentes, visitar comunidades y planificar programas de impacto.</p>"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"area": "Comunicación con Donantes",
|
|
||||||
"date": "2026-03-18",
|
|
||||||
"title": "Cómo Maya convirtió el caos de donantes en conexiones claras",
|
|
||||||
"teaser": "<p>Maya transformó los mensajes dispersos de donantes en un flujo fluido y confiable que hace crecer el apoyo y mantiene a todo el equipo alineado.</p>",
|
|
||||||
"content": "<h3>De correos dispersos a una visión única</h3><p>Maya solía manejar hojas de cálculo, hilos de correo y notas adhesivas para rastrear las interacciones con donantes. Se perdían mensajes, los agradecimientos llegaban tarde y la confianza comenzaba a tambalearse. Sabía que algo tenía que cambiar.</p><h3>Organizando el recorrido del donante</h3><p>Mapeó cada punto de contacto: contacto inicial, donación, seguimientos, renovaciones y campañas especiales. Cada interacción tuvo su tarjeta en Odoo con el nombre del donante, la fecha, el canal preferido y las notas del equipo, visible para todos de un vistazo.</p><h3>Puntos de contacto automatizados</h3><p>n8n automatiza la rutina: agradece a los donantes a los pocos minutos de recibir una aportación, confirma los compromisos y avisa al equipo antes de que venzan las renovaciones. Nadie se queda sin respuesta y el tono personal se mantiene intacto gracias a plantillas diseñadas para cada segmento de donantes.</p><h3>Transparencia en todos los niveles</h3><p>Los donantes pueden ver actualizaciones de los proyectos que apoyaron. Las etiquetas de estado en las campañas muestran los fondos asignados, historias de impacto y próximos eventos. El personal deja notas visibles para sus colegas, eliminando contactos duplicados y superposiciones incómodas.</p><h3>Paneles visuales en tiempo real</h3><p>Un panel en vivo muestra la actividad de donantes, las próximas renovaciones y la salud de la campaña de un solo vistazo. Los códigos de color marcan la prioridad: verde para donaciones confirmadas, amarillo para compromisos pendientes, rojo para seguimientos urgentes que requieren atención humana.</p><h3>Ritmo de comunicación</h3><p>Las revisiones semanales de donantes mantienen al equipo alineado. Los boletines mensuales destacan logros, impacto de los donantes y próximas oportunidades. Las campañas especiales activan microemails personalizados según el nivel de donación y el historial de cada persona.</p><ul><li>Lunes: revisar nuevos donantes y seguimientos pendientes.</li><li>Miércoles: confirmar el avance de las campañas y actualizar las notas de donantes.</li><li>Viernes: enviar correos de agradecimiento y actualizaciones de impacto.</li></ul><h3>Métricas e impacto</h3><p>La retención de donantes mejoró un 25 %. Los tiempos de respuesta bajaron de días a horas. El personal reporta menos estrés porque la comunicación es predecible, visible y ya no depende de la memoria de una sola persona.</p><h3>Tu plan de implementación</h3><ul><li>Importar todos los registros de donantes a Odoo y asignar responsables.</li><li>Configurar alertas en n8n para donaciones, seguimientos y renovaciones.</li><li>Crear paneles con donantes activos, compromisos y estado de campaña.</li><li>Automatizar correos de agradecimiento y actualizaciones de impacto trimestrales.</li></ul><p>Cuatro pasos reemplazan semanas de correos dispersos y oportunidades perdidas — y cada donante empieza a sentirse genuinamente valorado.</p>"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
-677
@@ -1,677 +0,0 @@
|
|||||||
<!doctype html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8" />
|
|
||||||
<title>Dashboard</title>
|
|
||||||
<style>
|
|
||||||
body {
|
|
||||||
margin: 0;
|
|
||||||
width: 1920px;
|
|
||||||
height: 1200px;
|
|
||||||
font-family: Arial, sans-serif;
|
|
||||||
background: #2f3e4e;
|
|
||||||
color: #e6edf3;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
padding: 20px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: 2fr 1fr 1fr;
|
|
||||||
grid-template-rows: 1fr 1fr;
|
|
||||||
gap: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.box {
|
|
||||||
background: #3b4c5e;
|
|
||||||
border-radius: 14px;
|
|
||||||
padding: 18px;
|
|
||||||
box-sizing: border-box;
|
|
||||||
box-shadow: 0 6px 14px rgba(0, 0, 0, 0.25);
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title {
|
|
||||||
font-size: 20px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-light {
|
|
||||||
position: absolute;
|
|
||||||
top: 18px;
|
|
||||||
right: 18px;
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 50%;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.green {
|
|
||||||
background: #3ad29f;
|
|
||||||
}
|
|
||||||
.yellow {
|
|
||||||
background: #f5c542;
|
|
||||||
}
|
|
||||||
.red {
|
|
||||||
background: #e05d5d;
|
|
||||||
}
|
|
||||||
|
|
||||||
.green-text {
|
|
||||||
color: #3ad29f;
|
|
||||||
}
|
|
||||||
.yellow-text {
|
|
||||||
color: #f5c542;
|
|
||||||
}
|
|
||||||
.red-text {
|
|
||||||
color: #e05d5d;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* SERVER GRID */
|
|
||||||
.servers {
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 1fr);
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server {
|
|
||||||
background: #44586d;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server-name {
|
|
||||||
font-size: 15px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
opacity: 0.85;
|
|
||||||
font-weight: bold;
|
|
||||||
padding-right: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.server .status-light {
|
|
||||||
top: 12px;
|
|
||||||
right: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metrics {
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.7;
|
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
.metric-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* MEETINGS */
|
|
||||||
.meetings-scroll {
|
|
||||||
overflow-y: auto;
|
|
||||||
max-height: calc(100% - 45px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting {
|
|
||||||
background: #44586d;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
border: 2px solid transparent;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting.soon {
|
|
||||||
border: 2px solid #e05d5d;
|
|
||||||
box-shadow: 0 0 8px rgba(224, 93, 93, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-name {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: bold;
|
|
||||||
opacity: 0.95;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meeting-badges {
|
|
||||||
display: flex;
|
|
||||||
gap: 4px;
|
|
||||||
margin-left: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
font-size: 9px;
|
|
||||||
padding: 2px 5px;
|
|
||||||
border-radius: 4px;
|
|
||||||
font-weight: bold;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge-odoo {
|
|
||||||
background: #714b67;
|
|
||||||
color: #e6edf3;
|
|
||||||
}
|
|
||||||
.badge-brandize {
|
|
||||||
background: #1a6fa0;
|
|
||||||
color: #e6edf3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.time {
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.65;
|
|
||||||
margin-top: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* TODOS */
|
|
||||||
.todos-scroll {
|
|
||||||
overflow-y: auto;
|
|
||||||
max-height: calc(100% - 45px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo {
|
|
||||||
background: #44586d;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 13px;
|
|
||||||
opacity: 0.9;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo.done-todo {
|
|
||||||
opacity: 0.5;
|
|
||||||
text-decoration: line-through;
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-check {
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: 2px solid #8aa0b5;
|
|
||||||
flex-shrink: 0;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.todo-check.checked {
|
|
||||||
background: #3ad29f;
|
|
||||||
border-color: #3ad29f;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* LATEST SIGNUPS */
|
|
||||||
.signups-scroll {
|
|
||||||
overflow-y: auto;
|
|
||||||
max-height: calc(100% - 45px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.signup-item {
|
|
||||||
background: #44586d;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 10px 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signup-email {
|
|
||||||
font-size: 13px;
|
|
||||||
font-weight: bold;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
opacity: 0.95;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signup-meta {
|
|
||||||
display: flex;
|
|
||||||
gap: 8px;
|
|
||||||
opacity: 0.7;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signup-tag {
|
|
||||||
background: #2f3e4e;
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 1px 6px;
|
|
||||||
font-size: 10px;
|
|
||||||
color: #3ad29f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.signup-server {
|
|
||||||
font-size: 11px;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* CURRENT TESTS */
|
|
||||||
.test-box {
|
|
||||||
background: #44586d;
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.8;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-row {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
|
||||||
padding: 3px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-label {
|
|
||||||
opacity: 0.6;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.test-value {
|
|
||||||
font-size: 11px;
|
|
||||||
text-align: right;
|
|
||||||
max-width: 65%;
|
|
||||||
word-break: break-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge {
|
|
||||||
display: inline-block;
|
|
||||||
padding: 1px 7px;
|
|
||||||
border-radius: 5px;
|
|
||||||
font-size: 10px;
|
|
||||||
font-weight: bold;
|
|
||||||
background: #3ad29f33;
|
|
||||||
color: #3ad29f;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-badge.dev {
|
|
||||||
background: #f5c54233;
|
|
||||||
color: #f5c542;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* LOADING / ERROR */
|
|
||||||
.loading {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
font-size: 18px;
|
|
||||||
opacity: 0.5;
|
|
||||||
letter-spacing: 1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.error-msg {
|
|
||||||
color: #e05d5d;
|
|
||||||
font-size: 13px;
|
|
||||||
opacity: 0.85;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="dashboard" id="dashboard">
|
|
||||||
<div class="loading" style="grid-column: 1/-1; grid-row: 1/-1">
|
|
||||||
Loading dashboard…
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
const WEBHOOK_URL =
|
|
||||||
"https://002-001-5dd6e535-4d1c-46bc-9bd9-42ad4bc5f082.odoo4projects.com/webhook/52781c84-9f12-4a61-96b2-9e1e3933148d";
|
|
||||||
const REFRESH_INTERVAL_MS = 60 * 1000; // refresh every 60 seconds
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function mbToGb(mb) {
|
|
||||||
return (mb / 1024).toFixed(1);
|
|
||||||
}
|
|
||||||
function pct(free, total) {
|
|
||||||
return total > 0 ? (free / total) * 100 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function metricColor(freePct) {
|
|
||||||
if (freePct < 10) return "red";
|
|
||||||
if (freePct < 20) return "yellow";
|
|
||||||
return "green";
|
|
||||||
}
|
|
||||||
|
|
||||||
function worstColor(colors) {
|
|
||||||
if (colors.includes("red")) return "red";
|
|
||||||
if (colors.includes("yellow")) return "yellow";
|
|
||||||
return "green";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse the wall-clock date/time directly from an ISO-8601 string that
|
|
||||||
// already carries an offset (e.g. "2026-03-31T10:00:00-03:00").
|
|
||||||
// We never let the browser re-interpret it into its own timezone.
|
|
||||||
function parseISOParts(dateTimeStr) {
|
|
||||||
// Matches: YYYY-MM-DDTHH:MM:SS(±HH:MM or Z)
|
|
||||||
const m = dateTimeStr.match(
|
|
||||||
/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::\d{2})?/,
|
|
||||||
);
|
|
||||||
if (!m) return null;
|
|
||||||
return {
|
|
||||||
year: parseInt(m[1], 10),
|
|
||||||
month: parseInt(m[2], 10),
|
|
||||||
day: parseInt(m[3], 10),
|
|
||||||
hour: parseInt(m[4], 10),
|
|
||||||
minute: parseInt(m[5], 10),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLocalTime(dateTimeStr) {
|
|
||||||
const p = parseISOParts(dateTimeStr);
|
|
||||||
if (!p) return "";
|
|
||||||
const h = String(p.hour).padStart(2, "0");
|
|
||||||
const m = String(p.minute).padStart(2, "0");
|
|
||||||
return `${h}:${m}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatLocalDate(dateTimeStr) {
|
|
||||||
const p = parseISOParts(dateTimeStr);
|
|
||||||
if (!p) return "";
|
|
||||||
// Build a UTC date just for the locale label so day/month names are correct
|
|
||||||
const d = new Date(Date.UTC(p.year, p.month - 1, p.day));
|
|
||||||
return d.toLocaleDateString([], {
|
|
||||||
weekday: "short",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
timeZone: "UTC",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function isToday(dateTimeStr) {
|
|
||||||
const now = new Date();
|
|
||||||
const p = parseISOParts(dateTimeStr);
|
|
||||||
if (!p) return false;
|
|
||||||
return (
|
|
||||||
p.year === now.getFullYear() &&
|
|
||||||
p.month === now.getMonth() + 1 &&
|
|
||||||
p.day === now.getDate()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSoonOrOngoing(startStr, endStr) {
|
|
||||||
const now = new Date();
|
|
||||||
const start = new Date(startStr);
|
|
||||||
const end = new Date(endStr);
|
|
||||||
const diffMin = (start - now) / 60000;
|
|
||||||
if (now >= start && now <= end) return true;
|
|
||||||
if (diffMin >= 0 && diffMin <= 30) return true;
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Server cards
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function buildServerCard(s) {
|
|
||||||
const ramPct = pct(s.free_ram, s.available_ram);
|
|
||||||
const hddPct = pct(s.free_hdd, s.available_hdd);
|
|
||||||
const ramColor = metricColor(ramPct);
|
|
||||||
const hddColor = metricColor(hddPct);
|
|
||||||
const lightColor = worstColor([ramColor, hddColor]);
|
|
||||||
|
|
||||||
const displayNames = {
|
|
||||||
saopaulo: "São Paulo",
|
|
||||||
manchester: "Manchester",
|
|
||||||
mumbai: "Mumbai",
|
|
||||||
sydney: "Sydney",
|
|
||||||
meppel: "Meppel",
|
|
||||||
boston: "Boston",
|
|
||||||
};
|
|
||||||
const displayName =
|
|
||||||
displayNames[s.Server] ||
|
|
||||||
s.Server.charAt(0).toUpperCase() + s.Server.slice(1);
|
|
||||||
|
|
||||||
return {
|
|
||||||
lightColor,
|
|
||||||
html: `
|
|
||||||
<div class="server">
|
|
||||||
<div class="server-name">${displayName}</div>
|
|
||||||
<div class="status-light ${lightColor}"></div>
|
|
||||||
<div class="metrics">
|
|
||||||
<div class="metric-row">
|
|
||||||
<span>RAM:</span>
|
|
||||||
<span class="${ramColor}-text">${ramPct.toFixed(0)}% free (${mbToGb(s.free_ram)} GB)</span>
|
|
||||||
</div>
|
|
||||||
<div class="metric-row">
|
|
||||||
<span>HDD:</span>
|
|
||||||
<span class="${hddColor}-text">${hddPct.toFixed(0)}% free (${mbToGb(s.free_hdd)} GB)</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Meetings
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function parseMeetings(calEvents, calName) {
|
|
||||||
const seen = new Set();
|
|
||||||
const result = [];
|
|
||||||
for (const e of calEvents) {
|
|
||||||
const ev = e.json || e;
|
|
||||||
if (seen.has(ev.id)) continue;
|
|
||||||
seen.add(ev.id);
|
|
||||||
result.push({ ...ev, _calName: calName });
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildMeetingCard(m) {
|
|
||||||
const startStr =
|
|
||||||
(m.start && (m.start.dateTime || m.start.date)) || "";
|
|
||||||
const endStr = (m.end && (m.end.dateTime || m.end.date)) || "";
|
|
||||||
const soon =
|
|
||||||
startStr && endStr
|
|
||||||
? isSoonOrOngoing(startStr, endStr)
|
|
||||||
: false;
|
|
||||||
const today = startStr ? isToday(startStr) : false;
|
|
||||||
|
|
||||||
const badges = (m._cals || [])
|
|
||||||
.map((c) => {
|
|
||||||
if (c === "ODOO4projects")
|
|
||||||
return `<span class="badge badge-odoo">ODOO4</span>`;
|
|
||||||
if (c === "Brandize")
|
|
||||||
return `<span class="badge badge-brandize">Brandize</span>`;
|
|
||||||
return "";
|
|
||||||
})
|
|
||||||
.join("");
|
|
||||||
|
|
||||||
const dateLabel =
|
|
||||||
today || !startStr
|
|
||||||
? ""
|
|
||||||
: `<span style="font-size:10px;opacity:0.55;margin-right:4px;">${formatLocalDate(startStr)}</span>`;
|
|
||||||
|
|
||||||
const timeLabel =
|
|
||||||
startStr && endStr
|
|
||||||
? `${formatLocalTime(startStr)} – ${formatLocalTime(endStr)}`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return `
|
|
||||||
<div class="meeting${soon ? " soon" : ""}">
|
|
||||||
<div class="meeting-header">
|
|
||||||
<div class="meeting-name">${m.summary || "Meeting"}</div>
|
|
||||||
<div class="meeting-badges">${badges}</div>
|
|
||||||
</div>
|
|
||||||
<div class="time">${dateLabel}${timeLabel}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Todos
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function buildTodoItem(t) {
|
|
||||||
const done = !!t.done;
|
|
||||||
const checkInner = done
|
|
||||||
? `<svg width="10" height="10" viewBox="0 0 10 10"><polyline points="1.5,5 4,7.5 8.5,2.5" stroke="#fff" stroke-width="1.8" fill="none" stroke-linecap="round"/></svg>`
|
|
||||||
: "";
|
|
||||||
return `
|
|
||||||
<div class="todo${done ? " done-todo" : ""}">
|
|
||||||
<div class="todo-check${done ? " checked" : ""}">${checkInner}</div>
|
|
||||||
<span>${t.Todotodo || ""}</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Latest Signup item
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function buildSignupItem(ct) {
|
|
||||||
if (!ct) return "";
|
|
||||||
const tagBadges = (ct.tags || "")
|
|
||||||
.split(",")
|
|
||||||
.map((t) => t.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.map((t) => `<span class="signup-tag">${t}</span>`)
|
|
||||||
.join("");
|
|
||||||
return `
|
|
||||||
<div class="signup-item">
|
|
||||||
<div class="signup-email">${ct.email || ""}</div>
|
|
||||||
<div class="signup-meta">
|
|
||||||
<span class="signup-server">🖥 ${ct.server || ""}</span>
|
|
||||||
${tagBadges}
|
|
||||||
<span style="opacity:0.5;font-size:10px;">${ct.status || ""}</span>
|
|
||||||
</div>
|
|
||||||
<div style="margin-top:4px;opacity:0.55;font-size:10px;">
|
|
||||||
${ct.Domains ? ct.Domains : ""}${ct.expires ? " · expires " + ct.expires : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSignupItems(raw) {
|
|
||||||
if (!raw) return { html: "", count: 0 };
|
|
||||||
const items = Array.isArray(raw) ? raw : [raw];
|
|
||||||
const html = items.map(buildSignupItem).join("");
|
|
||||||
return { html, count: items.length };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Render
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
function render(data) {
|
|
||||||
const serverStatusRaw = data["Server Status"] || [];
|
|
||||||
const calOdoo = data["Calender ODOO4projects"] || [];
|
|
||||||
const calBrandize = data["Calender Brandize"] || [];
|
|
||||||
const todos = data["Todos"] || [];
|
|
||||||
const currentTestsRaw = data["Current Tests"] || null;
|
|
||||||
|
|
||||||
// Deduplicate servers by Server key
|
|
||||||
const serverMap = {};
|
|
||||||
for (const s of serverStatusRaw) {
|
|
||||||
if (!serverMap[s.Server]) serverMap[s.Server] = s;
|
|
||||||
}
|
|
||||||
const servers = Object.values(serverMap);
|
|
||||||
|
|
||||||
// Server cards
|
|
||||||
const serverCards = servers.map(buildServerCard);
|
|
||||||
const overallColor = worstColor(
|
|
||||||
serverCards.map((c) => c.lightColor),
|
|
||||||
);
|
|
||||||
const serverCardsHtml = serverCards.map((c) => c.html).join("");
|
|
||||||
|
|
||||||
// Meetings – merge & deduplicate across both calendars
|
|
||||||
const odooMeetings = parseMeetings(calOdoo, "ODOO4projects");
|
|
||||||
const brandizeMeetings = parseMeetings(calBrandize, "Brandize");
|
|
||||||
const allMeetings = [...odooMeetings, ...brandizeMeetings];
|
|
||||||
|
|
||||||
const meetingById = {};
|
|
||||||
for (const m of allMeetings) {
|
|
||||||
if (!meetingById[m.id]) {
|
|
||||||
meetingById[m.id] = { ...m, _cals: [m._calName] };
|
|
||||||
} else if (!meetingById[m.id]._cals.includes(m._calName)) {
|
|
||||||
meetingById[m.id]._cals.push(m._calName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const dedupedMeetings = Object.values(meetingById).sort(
|
|
||||||
(a, b) => {
|
|
||||||
const aStart =
|
|
||||||
(a.start && (a.start.dateTime || a.start.date)) ||
|
|
||||||
"";
|
|
||||||
const bStart =
|
|
||||||
(b.start && (b.start.dateTime || b.start.date)) ||
|
|
||||||
"";
|
|
||||||
return new Date(aStart) - new Date(bStart);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const meetingsHtml = dedupedMeetings
|
|
||||||
.map(buildMeetingCard)
|
|
||||||
.join("");
|
|
||||||
const todosHtml = todos.map(buildTodoItem).join("");
|
|
||||||
const signups = buildSignupItems(currentTestsRaw);
|
|
||||||
|
|
||||||
document.getElementById("dashboard").innerHTML = `
|
|
||||||
|
|
||||||
<!-- SERVER STATUS -->
|
|
||||||
<div class="box" style="grid-column:1; grid-row:1/3;">
|
|
||||||
<div class="title">Server Status</div>
|
|
||||||
<div class="status-light ${overallColor}"></div>
|
|
||||||
<div class="servers">
|
|
||||||
${serverCardsHtml}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TODAY'S MEETINGS -->
|
|
||||||
<div class="box" style="grid-column:2; grid-row:1;">
|
|
||||||
<div class="title">Meetings</div>
|
|
||||||
<div class="meetings-scroll">
|
|
||||||
${meetingsHtml || '<div style="opacity:0.4;font-size:13px;">No meetings</div>'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- LATEST SIGNUPS -->
|
|
||||||
<div class="box" style="grid-column:3; grid-row:1/3;">
|
|
||||||
<div class="title">Latest Signups <span style="font-size:13px;font-weight:normal;opacity:0.6;background:#2f3e4e;border-radius:10px;padding:1px 9px;margin-left:6px;">${signups.count}</span></div>
|
|
||||||
<div class="signups-scroll">
|
|
||||||
${signups.html || '<div style="opacity:0.4;font-size:13px;">No signups</div>'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- TODOS -->
|
|
||||||
<div class="box" style="grid-column:2; grid-row:2;">
|
|
||||||
<div class="title">Today's Todos</div>
|
|
||||||
<div class="todos-scroll">
|
|
||||||
${todosHtml || '<div style="opacity:0.4;font-size:13px;">No todos</div>'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Fetch & refresh loop
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
async function loadData() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(WEBHOOK_URL);
|
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
||||||
const data = await res.json();
|
|
||||||
render(data);
|
|
||||||
} catch (err) {
|
|
||||||
document.getElementById("dashboard").innerHTML = `
|
|
||||||
<div class="error-msg" style="grid-column:1/-1;grid-row:1/-1;padding:40px;">
|
|
||||||
⚠ Failed to load dashboard data: ${err.message}<br>
|
|
||||||
<span style="font-size:11px;opacity:0.6;">Retrying in ${REFRESH_INTERVAL_MS / 1000}s…</span>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadData();
|
|
||||||
setInterval(loadData, REFRESH_INTERVAL_MS);
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
@@ -1 +0,0 @@
|
|||||||
google-site-verification: google261cafffa78d8c3d.html
|
|
||||||
+570
-2685
File diff suppressed because it is too large
Load Diff
Generated
+8
-6
@@ -537,10 +537,11 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/picomatch": {
|
"node_modules/picomatch": {
|
||||||
"version": "2.3.2",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
|
||||||
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.6"
|
"node": ">=8.6"
|
||||||
},
|
},
|
||||||
@@ -967,10 +968,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tinyglobby/node_modules/picomatch": {
|
"node_modules/tinyglobby/node_modules/picomatch": {
|
||||||
"version": "4.0.4",
|
"version": "4.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,8 +4,6 @@
|
|||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:css": "tailwindcss -i src/input.css -o assets/site.css --minify",
|
|
||||||
"watch:css": "tailwindcss -i src/input.css -o assets/site.css --watch",
|
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
+4
-34
@@ -3,45 +3,15 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@apply font-brand;
|
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
#lang-selector {
|
#lang-selector {
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
#lang-selector:focus,
|
#lang-selector:focus,
|
||||||
#lang-selector:focus-visible {
|
#lang-selector:focus-visible {
|
||||||
color: #201824;
|
color: #201824;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Blog post modal animations ───────────────────────── */
|
|
||||||
|
|
||||||
@keyframes modal-slide-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: scale(0.95) translateY(20px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1) translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes modal-fade-in {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-animate-box {
|
|
||||||
animation: modal-slide-in 0.3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-animate-backdrop {
|
|
||||||
animation: modal-fade-in 0.2s ease both;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-15
@@ -1,28 +1,27 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ["./index.html"],
|
content: ['./index.html'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
"brand-ink": "#201824",
|
'brand-ink': '#201824',
|
||||||
"brand-primary": "#603F57",
|
'brand-primary': '#603F57',
|
||||||
"brand-accent": "#F762B4",
|
'brand-accent': '#F762B4',
|
||||||
"brand-sunrise": "#F8B84A",
|
'brand-sunrise': '#F8B84A',
|
||||||
"brand-emerald": "#39B982",
|
'brand-emerald': '#39B982',
|
||||||
"brand-snow": "#F5F2F7",
|
'brand-snow': '#F5F2F7',
|
||||||
"brand-smoke": "#E5DCE8",
|
'brand-smoke': '#E5DCE8'
|
||||||
},
|
},
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
brand: ["system-ui", "-apple-system", "Segoe UI", "sans-serif"],
|
brand: ['Inter', 'Segoe UI', 'system-ui', 'sans-serif']
|
||||||
},
|
},
|
||||||
boxShadow: {
|
boxShadow: {
|
||||||
brand: "0 20px 60px rgba(32, 24, 36, 0.15)",
|
brand: '0 20px 60px rgba(32, 24, 36, 0.15)'
|
||||||
},
|
},
|
||||||
backgroundImage: {
|
backgroundImage: {
|
||||||
"hero-gradient":
|
'hero-gradient': 'radial-gradient(circle at top left, rgba(247,98,180,0.35), rgba(32,24,36,0.95))'
|
||||||
"radial-gradient(circle at top left, rgba(247,98,180,0.35), rgba(32,24,36,0.95))",
|
}
|
||||||
},
|
}
|
||||||
},
|
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: []
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user