Security: deep /api/health (DB+MinIO probes, 503 on failure) + /api/live; rate-limited public forms with honeypots; real client IP behind Traefik (--proxy-headers); TipTap URL sanitization on write (nh3); non-root containers + slim .dockerignore; prod MinIO console bound to 127.0.0.1; HSTS/nosniff on TLS routers; JWT 8h; no default credentials in code. Reliability: Alembic migrations (create_all baseline); graceful shutdown (exec uvicorn); hardened backups (checksums, sanity check, freshness gauge, optional rclone off-site) + restore.sh + hourly db-backup sidecar (ops profile); stack.sh ops tooling (deploy/logs/health/backup/restore/monitoring); .gitattributes LF for shell scripts. Orchestration: json-file log rotation, pinned images, per-service memory limits, edge/internal networks, Traefik ping/accesslog/metrics + flood limiter, conditional minio-setup, pgvector db image, one-shot db-init (umami + aramesh_chatbot databases). Chatbot: unified from site-chatbot branch (RAG + pgvector, SSE, ChatWidget). Monitoring: Prometheus/Alertmanager/Loki/Promtail/Grafana + cAdvisor, node/postgres/blackbox exporters, provisioned dashboards and 10 alert rules; alerts delivered to Bale (tapi.bale.ai — Telegram-compatible). Analytics: Umami on shared Postgres, build-time tracker injection, custom events (contact/newsletter/phone/chat/lang) via frontend/src/analytics.ts. Hygiene: CSV export for contact messages and subscribers; upload image optimization (Pillow); CI script + GitHub-compatible workflow; audit.sh; gen-env.sh for strong credentials.
57 lines
2.5 KiB
TypeScript
57 lines
2.5 KiB
TypeScript
import React from 'react'
|
|
import { Reveal } from '../Reveal'
|
|
import { ServiceIcon } from '../ServiceIcon'
|
|
import { useSectionHeading, useSite } from '../../api'
|
|
import { useFieldSaver } from '../../admin/AdminContext'
|
|
import { InlineEdit } from '../admin/InlineEdit'
|
|
import { AddItemButton, EditableItem } from '../../admin/Editable'
|
|
|
|
export function Services() {
|
|
const { services } = useSite()
|
|
const heading = useSectionHeading('services')
|
|
const save = useFieldSaver()
|
|
|
|
const saveHeading = (field: 'title' | 'lead', value: string) =>
|
|
save('section-headings', heading.id, { [field]: value }, (d) => {
|
|
const h = d.section_headings.find((s) => s.id === heading.id)
|
|
if (h) h[field] = value
|
|
})
|
|
|
|
return (
|
|
<section className="bg-ink py-24 lg:py-32" aria-labelledby="services-title">
|
|
<div className="mx-auto max-w-site px-6 lg:px-10">
|
|
<Reveal className="max-w-2xl text-start">
|
|
|
|
<h2 id="services-title" className="text-section font-bold text-ivory">
|
|
<InlineEdit value={heading.title} onSave={(v) => saveHeading('title', v)} />
|
|
</h2>
|
|
<p className="mt-5 text-base font-light leading-9 text-ivory/60">
|
|
<InlineEdit multiline value={heading.lead} onSave={(v) => saveHeading('lead', v)} />
|
|
</p>
|
|
<div className="mt-6 h-px w-24 bg-copper/70" />
|
|
</Reveal>
|
|
|
|
<div className="mt-14 grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
|
|
{services.map((service, i) => (
|
|
<Reveal key={service.id} delay={0.04 * i} className="h-full">
|
|
<EditableItem resource="services" item={service} className="h-full">
|
|
<article className="group flex h-full flex-col items-center border border-ivory/10 bg-char p-8 text-center transition-all duration-300 ease-out hover:-translate-y-2 hover:border-copper/40 hover:shadow-lift transform-gpu will-change-transform">
|
|
<ServiceIcon
|
|
name={service.icon}
|
|
className="h-12 w-12 text-copper/85 transition-colors duration-300 ease-out group-hover:text-copper"
|
|
/>
|
|
<h3 className="mt-6 text-lg font-bold text-ivory transition-colors duration-300 ease-out group-hover:text-copper">
|
|
{service.title}
|
|
</h3>
|
|
<p className="mt-3 text-sm font-light leading-8 text-ivory/55">{service.desc}</p>
|
|
</article>
|
|
</EditableItem>
|
|
</Reveal>
|
|
))}
|
|
<AddItemButton resource="services" />
|
|
</div>
|
|
</div>
|
|
</section>
|
|
)
|
|
}
|