From b0b5c3bd08d31081b22da797e3cce672a67c3c91 Mon Sep 17 00:00:00 2001 From: V Date: Sat, 16 May 2026 20:30:20 +0100 Subject: [PATCH] Initial commit --- .dockerignore | 11 + .env.example | 6 + .gitignore | 7 + Dockerfile | 23 + PLAN.md | 82 +++ ROUND_2.md | 61 +++ cmd/server/main.go | 96 ++++ data/uploads/.gitkeep | 1 + docker-compose.yml | 21 + go.mod | 8 + go.sum | 4 + internal/app/app.go | 697 ++++++++++++++++++++++++++ internal/app/app_test.go | 312 ++++++++++++ internal/store/store.go | 432 ++++++++++++++++ internal/store/store_test.go | 33 ++ web/static/app.js | 40 ++ web/static/placeholders/about.svg | 5 + web/static/placeholders/hero.svg | 6 + web/static/placeholders/project-1.svg | 5 + web/static/placeholders/project-2.svg | 5 + web/static/placeholders/project-3.svg | 5 + web/static/styles.css | 95 ++++ web/templates/about.html | 34 ++ web/templates/admin.html | 52 ++ web/templates/admin_contact.html | 42 ++ web/templates/admin_login.html | 18 + web/templates/admin_main.html | 35 ++ web/templates/admin_projects.html | 73 +++ web/templates/base.html | 35 ++ web/templates/contact_result.html | 2 + web/templates/home.html | 44 ++ web/templates/overlay.html | 7 + web/templates/project.html | 31 ++ web/templates/projects.html | 28 ++ 34 files changed, 2356 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 PLAN.md create mode 100644 ROUND_2.md create mode 100644 cmd/server/main.go create mode 100644 data/uploads/.gitkeep create mode 100644 docker-compose.yml create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/app/app.go create mode 100644 internal/app/app_test.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 web/static/app.js create mode 100644 web/static/placeholders/about.svg create mode 100644 web/static/placeholders/hero.svg create mode 100644 web/static/placeholders/project-1.svg create mode 100644 web/static/placeholders/project-2.svg create mode 100644 web/static/placeholders/project-3.svg create mode 100644 web/static/styles.css create mode 100644 web/templates/about.html create mode 100644 web/templates/admin.html create mode 100644 web/templates/admin_contact.html create mode 100644 web/templates/admin_login.html create mode 100644 web/templates/admin_main.html create mode 100644 web/templates/admin_projects.html create mode 100644 web/templates/base.html create mode 100644 web/templates/contact_result.html create mode 100644 web/templates/home.html create mode 100644 web/templates/overlay.html create mode 100644 web/templates/project.html create mode 100644 web/templates/projects.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fc1bf16 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git +.codex +.agents +data/*.db +data/*.db-* +data/uploads/* +!data/uploads/.gitkeep +.env +.env.* +!.env.example +tmp diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b65ed6b --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +ADDR=:8080 +DATABASE_PATH=/app/data/app.db +SESSION_SECRET=replace-with-a-long-random-secret +ADMIN_USERNAME=admin +ADMIN_PASSWORD=replace-with-a-strong-password +UPLOAD_DIR=/app/data/uploads diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..46b2cc9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/.env +/.env.* +!/.env.example +data/*.db +data/*.db-* +data/uploads/* +!data/uploads/.gitkeep diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a867512 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +# syntax=docker/dockerfile:1 + +FROM golang:1.24-bookworm AS build + +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . +RUN CGO_ENABLED=1 GOOS=linux go build -o /out/archi-folio ./cmd/server + +FROM gcr.io/distroless/cc-debian12:nonroot + +WORKDIR /app + +COPY --from=build /out/archi-folio /usr/local/bin/archi-folio +COPY --chown=nonroot:nonroot web ./web +COPY --chown=nonroot:nonroot data/uploads/.gitkeep ./data/uploads/.gitkeep + +EXPOSE 8080 + +CMD ["/usr/local/bin/archi-folio"] diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..4b0005a --- /dev/null +++ b/PLAN.md @@ -0,0 +1,82 @@ +# Go HTMX Architecture Portfolio Monolith + +## Summary + +Build a SQLite-backed Go monolith that serves server-rendered HTML templates enhanced with HTMX and styled with Tailwind CSS. The public site will use a minimal editorial visual style inspired by MAD Architects: large photography, restrained typography, smooth scrolling, a responsive image-led layout, and a header that starts large on the homepage then compacts after scroll. + +Use local image uploads, password-protected admin access, and saved contact requests visible in the admin panel. + +## Key Changes + +- Create a Go 1.24 app using `net/http`, `html/template`, `github.com/mattn/go-sqlite3`, HTMX from a CDN, and Tailwind CSS for styling. +- Add routes: + - `GET /` home landing page with large centerpiece image, intro text, featured projects, and scroll-responsive header. + - `GET /projects` square project grid. + - `GET /projects/{slug}` project detail with title, location, year, category, narrative text, cover image, and ordered gallery images. + - `GET /projects/{slug}/images/{imageID}/overlay` returns an HTMX overlay fragment for a full-screen, full-resolution project image viewer. + - `GET /about` profile image, bio/details, and contact form. + - `POST /contact` saves visitor request and returns an HTMX success/error fragment. + - `GET /admin/login`, `POST /admin/login`, `POST /admin/logout`. + - `/admin` protected dashboard for editing site text, homepage/about images, projects, galleries, and contact requests. +- Store data in SQLite with tables for: + - site settings/content blocks + - projects + - project images + - uploaded assets + - contact requests + - admin users/sessions +- Store uploaded image files under `data/uploads/`; database rows store file metadata and public paths. +- Add startup migration logic that creates missing tables and seeds one admin user plus placeholder portfolio content if the database is empty. +- Configure admin credentials via environment variables, defaulting only for local development: + - `ADMIN_USERNAME` + - `ADMIN_PASSWORD` + - `SESSION_SECRET` + - `DATABASE_PATH`, default `data/app.db` + +## Interface Plan + +- Public UI: + - Use a clean black/white base, generous image scale, compact metadata, and no decorative gradients or card-heavy marketing layout. + - Header is large and transparent/airy on the home hero; after scroll it becomes fixed, compact, opaque, and mobile-friendly. + - Projects render as a responsive square image grid: 1 column mobile, 2 tablet, 3-4 desktop. + - Project detail pages prioritize full-width imagery and simple narrative sections; every project image is clickable and opens a full-screen overlay with the original-resolution image. + - The image overlay supports close controls, backdrop click, `Escape` key dismissal, scroll locking while open, and mobile-safe image sizing. + - About page combines portrait/profile image, short bio, contact details, and an HTMX contact form. +- Admin UI: + - Hidden by navigation but accessible by URL. + - Login required with username/password and secure HTTP-only session cookie. + - Dashboard sections: site content, projects, image uploads/galleries, and contact requests. + - Use HTMX for create/update/delete actions so admin edits update page fragments without a full reload. +- Interactivity: + - Use HTMX swaps with the View Transitions API for smooth page and overlay transitions. + - Add a small local JavaScript module for behavior HTMX does not own well: scroll-based header compaction, overlay focus/keyboard handling, and body scroll locking. + - Do not add a larger animation or UI framework for v1; consider Alpine.js later only if admin interactions become state-heavy. + +## Test Plan + +- Add Go tests for: + - route handlers returning expected status codes + - auth middleware blocking unauthenticated admin requests + - login/session behavior + - contact form validation and persistence + - project CRUD and gallery ordering + - project image overlay endpoint authorization/data lookup behavior +- Add manual acceptance checks: + - homepage header compacts on scroll + - project grid and details work on mobile and desktop widths + - clicking any project image opens a full-screen overlay, loads the full-resolution image, and closes with button, backdrop, and `Escape` + - admin can upload images, add/remove projects, edit text, and view contact requests + - public forms show success and validation errors through HTMX +- Run: + - `go test ./...` + - `go run ./cmd/server` and verify the app locally in browser + +## Assumptions + +- This is a first production-shaped version, not just a static prototype. +- SQLite is the source of truth for editable content and contact requests. +- Images are uploaded locally, not stored externally. +- Tailwind CSS is the styling layer for both public and admin interfaces. +- HTMX plus the View Transitions API and a small local JavaScript module are enough for v1 smoothness; no additional JS UI library is required initially. +- Admin MFA is intentionally out of scope for v1, but auth structure should allow adding it later. +- Contact submissions are saved in the admin panel; email sending is out of scope for v1. diff --git a/ROUND_2.md b/ROUND_2.md new file mode 100644 index 0000000..2d81647 --- /dev/null +++ b/ROUND_2.md @@ -0,0 +1,61 @@ +# Round 2 Plan: Tabbed Admin Interface + +## Summary + +Refactor the admin area from one long dashboard into a tabbed interface with three subsections: Main, Projects, and Contact Details. Keep the existing password-protected admin shell, reuse the current CRUD behavior, and add HTMX-powered tab loading so the interface feels fast while each tab remains accessible by URL. + +## Key Changes + +- Add protected admin tab routes: + - `GET /admin` redirects to `/admin/main`. + - `GET /admin/main` shows homepage/about content editing. + - `GET /admin/projects` shows project creation, project editing, gallery upload/removal. + - `GET /admin/contact-details` shows public contact fields and saved contact requests. +- Keep existing mutation routes but redirect or HTMX-refresh back to the relevant tab: + - `POST /admin/content` returns to `/admin/main` unless only contact fields were submitted. + - `POST /admin/contact-details` updates email, phone, and location, then returns to `/admin/contact-details`. + - Project mutation routes return to `/admin/projects`. +- Split `web/templates/admin.html` into a reusable admin layout plus tab partials: + - Admin layout: header, logout/view-site actions, flash message area, tab navigation, and `#admin-panel` content target. + - Main tab partial: hero title/subtitle, intro copy, about name/role/bio, hero image, about image. + - Projects tab partial: add project form, project edit forms, cover image changes, gallery images. + - Contact details tab partial: email/phone/location form plus contact request inbox. + +## Interface Behavior + +- Use a horizontal tab bar under the admin header with active state for `main`, `projects`, and `contact details`. +- Tabs are normal links for direct navigation and browser refresh support. +- Add `hx-get`, `hx-target="#admin-panel"`, `hx-push-url="true"`, and `hx-swap="innerHTML transition:true"` to tab links for smooth in-page tab changes. +- On direct page loads, render the full admin layout with the requested tab already active. +- On HTMX requests, return only the tab panel partial and update the tab active state with an out-of-band fragment or by returning the tab nav with `hx-swap-oob`. +- Keep mobile behavior simple: tabs scroll horizontally if needed, forms remain single-column on small screens. + +## Data and Handler Notes + +- Reuse the existing `adminData` loading pattern, but allow tab-specific data loading to avoid fetching project galleries on the Main tab. +- Keep all admin routes behind `requireAdmin`. +- Add a small `AdminTab` field to page data so templates can render active tab state. +- Move email, phone, and location out of the Main form into Contact Details while still storing them in the existing `site_content` row. +- Do not change the SQLite schema for this refactor. + +## Test Plan + +- Update handler tests: + - `/admin` redirects to `/admin/main`. + - unauthenticated requests to each tab redirect to `/admin/login`. + - authenticated requests to `/admin/main`, `/admin/projects`, and `/admin/contact-details` return `200`. + - HTMX tab requests return partial content rather than the full admin document. + - content updates redirect to the correct tab. + - project create/update/delete routes still work and redirect to `/admin/projects`. +- Manual checks: + - tab links work with and without JavaScript. + - HTMX tab changes update browser URL. + - active tab styling is correct after direct load, HTMX navigation, and form redirects. + - mobile tab bar and forms remain usable. + +## Assumptions + +- "Main" means homepage/about presentation content and showcase images. +- "Projects" owns all project and gallery management. +- "Contact Details" owns public contact fields and the contact request inbox. +- This is a UI/routing refactor only; no database migration is needed. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..a139fe8 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,96 @@ +package main + +import ( + "bufio" + "fmt" + "log" + "net/http" + "os" + "strings" + "time" + + "archi_folio/internal/app" + "archi_folio/internal/store" +) + +func main() { + if err := loadDotEnv(".env"); err != nil { + log.Fatal(err) + } + + cfg := app.Config{ + Addr: requiredEnv("ADDR"), + DatabasePath: requiredEnv("DATABASE_PATH"), + SessionSecret: requiredEnv("SESSION_SECRET"), + AdminUsername: requiredEnv("ADMIN_USERNAME"), + AdminPassword: requiredEnv("ADMIN_PASSWORD"), + UploadDir: requiredEnv("UPLOAD_DIR"), + } + + db, err := store.Open(cfg.DatabasePath) + if err != nil { + log.Fatal(err) + } + defer db.Close() + + if err := db.Migrate(cfg.AdminUsername, cfg.AdminPassword); err != nil { + log.Fatal(err) + } + + srv, err := app.New(cfg, db) + if err != nil { + log.Fatal(err) + } + + server := &http.Server{ + Addr: cfg.Addr, + Handler: srv.Routes(), + ReadHeaderTimeout: 5 * time.Second, + } + + log.Printf("serving on http://localhost%s", cfg.Addr) + log.Fatal(server.ListenAndServe()) +} + +func loadDotEnv(path string) error { + file, err := os.Open(path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for lineNumber := 1; scanner.Scan(); lineNumber++ { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + return fmt.Errorf("%s:%d: expected KEY=value", path, lineNumber) + } + key = strings.TrimSpace(key) + value = strings.TrimSpace(value) + value = strings.Trim(value, `"'`) + if key == "" { + return fmt.Errorf("%s:%d: empty key", path, lineNumber) + } + if _, exists := os.LookupEnv(key); !exists { + if err := os.Setenv(key, value); err != nil { + return err + } + } + } + return scanner.Err() +} + +func requiredEnv(key string) string { + value, ok := os.LookupEnv(key) + if !ok || strings.TrimSpace(value) == "" { + log.Fatalf("missing required environment variable %s", key) + } + return value +} diff --git a/data/uploads/.gitkeep b/data/uploads/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/data/uploads/.gitkeep @@ -0,0 +1 @@ + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a489c6a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,21 @@ +services: + web: + build: + context: . + env_file: + - .env + environment: + ADDR: ":8080" + DATABASE_PATH: "/app/data/app.db" + UPLOAD_DIR: "/app/data/uploads" + SESSION_SECRET: "${SESSION_SECRET:?SESSION_SECRET is required}" + ADMIN_USERNAME: "${ADMIN_USERNAME:?ADMIN_USERNAME is required}" + ADMIN_PASSWORD: "${ADMIN_PASSWORD:?ADMIN_PASSWORD is required}" + ports: + - "8080:8080" + volumes: + - app-data:/app/data + restart: unless-stopped + +volumes: + app-data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d7159ed --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module archi_folio + +go 1.24.5 + +require ( + github.com/mattn/go-sqlite3 v1.14.24 + golang.org/x/crypto v0.32.0 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..b996447 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= +golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..26ba7ea --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,697 @@ +package app + +import ( + "bytes" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "html/template" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" + + "archi_folio/internal/store" + + "golang.org/x/crypto/bcrypt" +) + +const ( + maxUploadBytes = 20 << 20 + maxFormBytes = 24 << 20 +) + +type Config struct { + Addr string + DatabasePath string + SessionSecret string + AdminUsername string + AdminPassword string + UploadDir string +} + +type Server struct { + cfg Config + store *store.Store + templates *template.Template +} + +type pageData struct { + Title string + Active string + Content store.SiteContent + Projects []store.Project + Project store.Project + Image store.ProjectImage + Contacts []store.ContactRequest + Admin bool + AdminTab string + Error string + Success string + CurrentPath string +} + +func New(cfg Config, st *store.Store) (*Server, error) { + tmpl, err := template.ParseGlob(filepath.Join(assetRoot(), "templates", "*.html")) + if err != nil { + return nil, err + } + if err := os.MkdirAll(cfg.UploadDir, 0o755); err != nil { + return nil, err + } + return &Server{cfg: cfg, store: st, templates: tmpl}, nil +} + +func projectRoot() string { + _, file, _, ok := runtime.Caller(0) + if !ok { + return "." + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} + +func assetRoot() string { + if _, err := os.Stat(filepath.Join("web", "templates")); err == nil { + return "web" + } + return filepath.Join(projectRoot(), "web") +} + +func (s *Server) Routes() http.Handler { + mux := http.NewServeMux() + mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(assetRoot(), "static"))))) + mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(s.cfg.UploadDir)))) + + mux.HandleFunc("GET /", s.home) + mux.HandleFunc("GET /projects", s.projects) + mux.HandleFunc("GET /projects/{slug}", s.projectDetail) + mux.HandleFunc("GET /projects/{slug}/images/{imageID}/overlay", s.projectImageOverlay) + mux.HandleFunc("GET /about", s.about) + mux.HandleFunc("POST /contact", s.contact) + + mux.HandleFunc("GET /admin/login", s.adminLogin) + mux.HandleFunc("POST /admin/login", s.adminLoginPost) + mux.HandleFunc("POST /admin/logout", s.adminLogout) + + mux.Handle("GET /admin", s.requireAdmin(http.HandlerFunc(s.adminRedirect))) + mux.Handle("GET /admin/main", s.requireAdmin(http.HandlerFunc(s.adminMain))) + mux.Handle("GET /admin/projects", s.requireAdmin(http.HandlerFunc(s.adminProjects))) + mux.Handle("GET /admin/contact-details", s.requireAdmin(http.HandlerFunc(s.adminContactDetails))) + mux.Handle("POST /admin/content", s.requireAdmin(http.HandlerFunc(s.adminUpdateContent))) + mux.Handle("POST /admin/contact-details", s.requireAdmin(http.HandlerFunc(s.adminUpdateContactDetails))) + mux.Handle("POST /admin/projects", s.requireAdmin(http.HandlerFunc(s.adminCreateProject))) + mux.Handle("POST /admin/projects/{id}", s.requireAdmin(http.HandlerFunc(s.adminUpdateProject))) + mux.Handle("POST /admin/projects/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteProject))) + mux.Handle("POST /admin/projects/{id}/images", s.requireAdmin(http.HandlerFunc(s.adminAddProjectImage))) + mux.Handle("POST /admin/project-images/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteProjectImage))) + + return securityHeaders(mux) +} + +func (s *Server) home(w http.ResponseWriter, r *http.Request) { + content, err := s.store.SiteContent(r.Context()) + if err != nil { + s.error(w, err) + return + } + projects, err := s.store.Projects(r.Context(), true) + if err != nil { + s.error(w, err) + return + } + s.render(w, "home.html", pageData{Title: content.HeroTitle, Active: "home", Content: content, Projects: projects, CurrentPath: r.URL.Path}) +} + +func (s *Server) projects(w http.ResponseWriter, r *http.Request) { + content, err := s.store.SiteContent(r.Context()) + if err != nil { + s.error(w, err) + return + } + projects, err := s.store.Projects(r.Context(), false) + if err != nil { + s.error(w, err) + return + } + s.render(w, "projects.html", pageData{Title: "Projects", Active: "projects", Content: content, Projects: projects, CurrentPath: r.URL.Path}) +} + +func (s *Server) projectDetail(w http.ResponseWriter, r *http.Request) { + content, err := s.store.SiteContent(r.Context()) + if err != nil { + s.error(w, err) + return + } + project, err := s.store.ProjectBySlug(r.Context(), r.PathValue("slug")) + if store.IsNotFound(err) { + http.NotFound(w, r) + return + } + if err != nil { + s.error(w, err) + return + } + s.render(w, "project.html", pageData{Title: project.Title, Active: "projects", Content: content, Project: project, CurrentPath: r.URL.Path}) +} + +func (s *Server) projectImageOverlay(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("imageID"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + project, image, err := s.store.ProjectImageForSlug(r.Context(), r.PathValue("slug"), id) + if store.IsNotFound(err) { + http.NotFound(w, r) + return + } + if err != nil { + s.error(w, err) + return + } + s.render(w, "overlay.html", pageData{Title: project.Title, Project: project, Image: image}) +} + +func (s *Server) about(w http.ResponseWriter, r *http.Request) { + content, err := s.store.SiteContent(r.Context()) + if err != nil { + s.error(w, err) + return + } + s.render(w, "about.html", pageData{Title: "About", Active: "about", Content: content, CurrentPath: r.URL.Path}) +} + +func (s *Server) contact(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.render(w, "contact_result.html", pageData{Error: "Please check the form and try again."}) + return + } + name := strings.TrimSpace(r.FormValue("name")) + email := strings.TrimSpace(r.FormValue("email")) + message := strings.TrimSpace(r.FormValue("message")) + if name == "" || email == "" || message == "" || !strings.Contains(email, "@") { + s.render(w, "contact_result.html", pageData{Error: "Please provide your name, a valid email, and a short message."}) + return + } + if err := s.store.SaveContact(r.Context(), name, email, message); err != nil { + s.render(w, "contact_result.html", pageData{Error: "The request could not be saved. Please try again."}) + return + } + s.render(w, "contact_result.html", pageData{Success: "Thanks. Your request has been saved and the studio will review it soon."}) +} + +func (s *Server) adminLogin(w http.ResponseWriter, r *http.Request) { + s.render(w, "admin_login.html", pageData{Title: "Admin login"}) +} + +func (s *Server) adminLoginPost(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.render(w, "admin_login.html", pageData{Title: "Admin login", Error: "Invalid login request."}) + return + } + user, err := s.store.AdminByUsername(r.Context(), r.FormValue("username")) + if err != nil || bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(r.FormValue("password"))) != nil { + s.render(w, "admin_login.html", pageData{Title: "Admin login", Error: "Incorrect username or password."}) + return + } + token, err := randomToken() + if err != nil { + s.error(w, err) + return + } + if err := s.store.CreateSession(r.Context(), s.hashToken(token), user.ID, time.Now().Add(24*time.Hour)); err != nil { + s.error(w, err) + return + } + http.SetCookie(w, &http.Cookie{ + Name: "archi_session", + Value: token, + Path: "/", + HttpOnly: true, + SameSite: http.SameSiteLaxMode, + MaxAge: 86400, + }) + http.Redirect(w, r, "/admin/main", http.StatusSeeOther) +} + +func (s *Server) adminLogout(w http.ResponseWriter, r *http.Request) { + if cookie, err := r.Cookie("archi_session"); err == nil { + _ = s.store.DeleteSession(r.Context(), s.hashToken(cookie.Value)) + } + http.SetCookie(w, &http.Cookie{Name: "archi_session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func (s *Server) adminRedirect(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/admin/main", http.StatusSeeOther) +} + +func (s *Server) adminMain(w http.ResponseWriter, r *http.Request) { + data, err := s.adminData(r, "main") + if err != nil { + s.error(w, err) + return + } + s.renderAdmin(w, r, "admin_main.html", "admin_main_partial.html", data) +} + +func (s *Server) adminProjects(w http.ResponseWriter, r *http.Request) { + data, err := s.adminData(r, "projects") + if err != nil { + s.error(w, err) + return + } + s.renderAdmin(w, r, "admin_projects.html", "admin_projects_partial.html", data) +} + +func (s *Server) adminContactDetails(w http.ResponseWriter, r *http.Request) { + data, err := s.adminData(r, "contact-details") + if err != nil { + s.error(w, err) + return + } + s.renderAdmin(w, r, "admin_contact_details.html", "admin_contact_details_partial.html", data) +} + +func (s *Server) adminUpdateContent(w http.ResponseWriter, r *http.Request) { + if err := parseAdminForm(w, r, maxFormBytes); err != nil { + s.redirectAdmin(w, r, "main", "content form failed") + return + } + current, err := s.store.SiteContent(r.Context()) + if err != nil { + s.redirectAdmin(w, r, "main", "content could not be loaded") + return + } + content := store.SiteContent{ + HeroTitle: strings.TrimSpace(r.FormValue("hero_title")), + HeroSubtitle: strings.TrimSpace(r.FormValue("hero_subtitle")), + IntroTitle: strings.TrimSpace(r.FormValue("intro_title")), + IntroText: strings.TrimSpace(r.FormValue("intro_text")), + AboutName: strings.TrimSpace(r.FormValue("about_name")), + AboutRole: strings.TrimSpace(r.FormValue("about_role")), + AboutBio: strings.TrimSpace(r.FormValue("about_bio")), + Email: current.Email, + Phone: current.Phone, + Location: current.Location, + HeroImage: r.FormValue("hero_image_current"), + AboutImage: r.FormValue("about_image_current"), + } + if err := validateContent(content); err != nil { + s.redirectAdmin(w, r, "main", err.Error()) + return + } + if path, ok, err := s.saveUpload(r, "hero_image"); err != nil { + s.redirectAdmin(w, r, "main", "hero image "+err.Error()) + return + } else if ok { + content.HeroImage = path + } + if path, ok, err := s.saveUpload(r, "about_image"); err != nil { + s.redirectAdmin(w, r, "main", "about image "+err.Error()) + return + } else if ok { + content.AboutImage = path + } + if err := s.store.UpdateSiteContent(r.Context(), content); err != nil { + s.redirectAdmin(w, r, "main", "content could not be saved") + return + } + s.redirectAdmin(w, r, "main", "content saved") +} + +func (s *Server) adminUpdateContactDetails(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + s.redirectAdmin(w, r, "contact-details", "contact details form failed") + return + } + content, err := s.store.SiteContent(r.Context()) + if err != nil { + s.redirectAdmin(w, r, "contact-details", "contact details could not be loaded") + return + } + content.Email = strings.TrimSpace(r.FormValue("email")) + content.Phone = strings.TrimSpace(r.FormValue("phone")) + content.Location = strings.TrimSpace(r.FormValue("location")) + if err := validateContactDetails(content); err != nil { + s.redirectAdmin(w, r, "contact-details", err.Error()) + return + } + if err := s.store.UpdateSiteContent(r.Context(), content); err != nil { + s.redirectAdmin(w, r, "contact-details", "contact details could not be saved") + return + } + s.redirectAdmin(w, r, "contact-details", "contact details saved") +} + +func (s *Server) adminCreateProject(w http.ResponseWriter, r *http.Request) { + if err := parseAdminForm(w, r, maxFormBytes); err != nil { + s.redirectAdmin(w, r, "projects", "project form failed") + return + } + cover := "/static/placeholders/project-1.svg" + if path, ok, err := s.saveUpload(r, "cover_image"); err != nil { + s.redirectAdmin(w, r, "projects", "cover upload "+err.Error()) + return + } else if ok { + cover = path + } + slug := strings.TrimSpace(r.FormValue("slug")) + if slug == "" { + slug = store.SlugFromTitle(r.FormValue("title")) + } else { + slug = store.SlugFromTitle(slug) + } + project := store.Project{ + Slug: slug, + Title: strings.TrimSpace(r.FormValue("title")), + Location: strings.TrimSpace(r.FormValue("location")), + Year: strings.TrimSpace(r.FormValue("year")), + Category: strings.TrimSpace(r.FormValue("category")), + Description: strings.TrimSpace(r.FormValue("description")), + CoverImage: cover, + Featured: r.FormValue("featured") == "on", + } + if err := validateProject(project); err != nil { + s.redirectAdmin(w, r, "projects", err.Error()) + return + } + id, err := s.store.CreateProject(r.Context(), project) + if err != nil { + s.redirectAdmin(w, r, "projects", "project could not be created") + return + } + _ = s.store.AddProjectImage(r.Context(), id, cover, r.FormValue("title")) + s.redirectAdmin(w, r, "projects", "project created") +} + +func (s *Server) adminUpdateProject(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + if err := parseAdminForm(w, r, maxFormBytes); err != nil { + s.redirectAdmin(w, r, "projects", "project form failed") + return + } + cover := r.FormValue("cover_image_current") + if path, ok, err := s.saveUpload(r, "cover_image"); err != nil { + s.redirectAdmin(w, r, "projects", "cover upload "+err.Error()) + return + } else if ok { + cover = path + } + slug := strings.TrimSpace(r.FormValue("slug")) + if slug == "" { + slug = store.SlugFromTitle(r.FormValue("title")) + } else { + slug = store.SlugFromTitle(slug) + } + project := store.Project{ + ID: id, + Slug: slug, + Title: strings.TrimSpace(r.FormValue("title")), + Location: strings.TrimSpace(r.FormValue("location")), + Year: strings.TrimSpace(r.FormValue("year")), + Category: strings.TrimSpace(r.FormValue("category")), + Description: strings.TrimSpace(r.FormValue("description")), + CoverImage: cover, + Featured: r.FormValue("featured") == "on", + } + if err := validateProject(project); err != nil { + s.redirectAdmin(w, r, "projects", err.Error()) + return + } + err = s.store.UpdateProject(r.Context(), project) + if err != nil { + s.redirectAdmin(w, r, "projects", "project could not be saved") + return + } + s.redirectAdmin(w, r, "projects", "project saved") +} + +func (s *Server) adminDeleteProject(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err == nil { + err = s.store.DeleteProject(r.Context(), id) + } + if err != nil { + s.redirectAdmin(w, r, "projects", "project could not be deleted") + return + } + s.redirectAdmin(w, r, "projects", "project deleted") +} + +func (s *Server) adminAddProjectImage(w http.ResponseWriter, r *http.Request) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil { + http.NotFound(w, r) + return + } + if err := parseAdminForm(w, r, maxFormBytes); err != nil { + s.redirectAdmin(w, r, "projects", "image form failed") + return + } + path, ok, err := s.saveUpload(r, "image") + if err != nil || !ok { + if err != nil { + s.redirectAdmin(w, r, "projects", "image upload "+err.Error()) + return + } + s.redirectAdmin(w, r, "projects", "image upload failed") + return + } + if err := s.store.AddProjectImage(r.Context(), projectID, path, r.FormValue("caption")); err != nil { + s.redirectAdmin(w, r, "projects", "image could not be added") + return + } + s.redirectAdmin(w, r, "projects", "image added") +} + +func (s *Server) adminDeleteProjectImage(w http.ResponseWriter, r *http.Request) { + id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err == nil { + err = s.store.DeleteProjectImage(r.Context(), id) + } + if err != nil { + s.redirectAdmin(w, r, "projects", "image could not be deleted") + return + } + s.redirectAdmin(w, r, "projects", "image deleted") +} + +func (s *Server) adminData(r *http.Request, tab string) (pageData, error) { + content, err := s.store.SiteContent(r.Context()) + if err != nil { + return pageData{}, err + } + data := pageData{Title: "Admin", Admin: true, AdminTab: tab, Content: content, Success: r.URL.Query().Get("ok"), Error: r.URL.Query().Get("err")} + if tab == "projects" { + projects, err := s.store.Projects(r.Context(), false) + if err != nil { + return pageData{}, err + } + imagesByProject, err := s.store.ProjectImagesByProject(r.Context()) + if err != nil { + return pageData{}, err + } + for i := range projects { + projects[i].Images = imagesByProject[projects[i].ID] + } + data.Projects = projects + } + if tab == "contact-details" { + contacts, err := s.store.ContactRequests(r.Context()) + if err != nil { + return pageData{}, err + } + data.Contacts = contacts + } + return data, nil +} + +func (s *Server) requireAdmin(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + cookie, err := r.Cookie("archi_session") + if err != nil { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + if _, err := s.store.SessionUser(r.Context(), s.hashToken(cookie.Value)); err != nil { + http.Redirect(w, r, "/admin/login", http.StatusSeeOther) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) saveUpload(r *http.Request, field string) (string, bool, error) { + if !strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + return "", false, nil + } + file, header, err := r.FormFile(field) + if errors.Is(err, http.ErrMissingFile) { + return "", false, nil + } + if err != nil { + return "", false, err + } + defer file.Close() + + ext := strings.ToLower(filepath.Ext(header.Filename)) + switch ext { + case ".jpg", ".jpeg", ".png", ".webp", ".gif": + default: + return "", false, fmt.Errorf("unsupported image type") + } + name, err := randomToken() + if err != nil { + return "", false, err + } + filename := name + ext + target := filepath.Join(s.cfg.UploadDir, filename) + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return "", false, err + } + defer out.Close() + written, err := io.Copy(out, io.LimitReader(file, maxUploadBytes+1)) + if err != nil { + return "", false, err + } + if written > maxUploadBytes { + _ = out.Close() + _ = os.Remove(target) + return "", false, fmt.Errorf("is too large") + } + return "/uploads/" + filename, true, nil +} + +func parseAdminForm(w http.ResponseWriter, r *http.Request, maxBytes int64) error { + r.Body = http.MaxBytesReader(w, r.Body, maxBytes) + if strings.HasPrefix(r.Header.Get("Content-Type"), "multipart/form-data") { + return r.ParseMultipartForm(maxBytes) + } + return r.ParseForm() +} + +func validateContent(c store.SiteContent) error { + switch { + case c.HeroTitle == "": + return errors.New("hero title is required") + case c.HeroSubtitle == "": + return errors.New("hero subtitle is required") + case c.IntroTitle == "": + return errors.New("intro title is required") + case c.IntroText == "": + return errors.New("intro text is required") + case c.AboutName == "": + return errors.New("about name is required") + case c.AboutRole == "": + return errors.New("about role is required") + case c.AboutBio == "": + return errors.New("about bio is required") + case c.HeroImage == "": + return errors.New("hero image is required") + case c.AboutImage == "": + return errors.New("about image is required") + default: + return nil + } +} + +func validateContactDetails(c store.SiteContent) error { + switch { + case c.Email == "" || !strings.Contains(c.Email, "@"): + return errors.New("valid email is required") + case c.Phone == "": + return errors.New("phone is required") + case c.Location == "": + return errors.New("location is required") + default: + return nil + } +} + +func validateProject(p store.Project) error { + switch { + case p.Slug == "": + return errors.New("project slug is required") + case p.Title == "": + return errors.New("project title is required") + case p.Location == "": + return errors.New("project location is required") + case p.Year == "": + return errors.New("project year is required") + case p.Category == "": + return errors.New("project category is required") + case p.Description == "": + return errors.New("project description is required") + case p.CoverImage == "": + return errors.New("project cover image is required") + default: + return nil + } +} + +func (s *Server) render(w http.ResponseWriter, name string, data pageData) { + var buf bytes.Buffer + if err := s.templates.ExecuteTemplate(&buf, name, data); err != nil { + http.Error(w, "template error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = buf.WriteTo(w) +} + +func (s *Server) renderAdmin(w http.ResponseWriter, r *http.Request, fullTemplate, partialTemplate string, data pageData) { + if r.Header.Get("HX-Request") == "true" { + s.render(w, partialTemplate, data) + return + } + s.render(w, fullTemplate, data) +} + +func (s *Server) error(w http.ResponseWriter, err error) { + http.Error(w, err.Error(), http.StatusInternalServerError) +} + +func (s *Server) redirectAdmin(w http.ResponseWriter, r *http.Request, tab, message string) { + key := "err" + for _, success := range []string{" saved", " created", " deleted", " added"} { + if strings.HasSuffix(message, success) { + key = "ok" + break + } + } + http.Redirect(w, r, "/admin/"+tab+"?"+key+"="+url.QueryEscape(message), http.StatusSeeOther) +} + +func randomToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func (s *Server) hashToken(token string) string { + sum := sha256.Sum256([]byte(s.cfg.SessionSecret + ":" + token)) + return hex.EncodeToString(sum[:]) +} + +func securityHeaders(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin") + next.ServeHTTP(w, r) + }) +} diff --git a/internal/app/app_test.go b/internal/app/app_test.go new file mode 100644 index 0000000..6d87e46 --- /dev/null +++ b/internal/app/app_test.go @@ -0,0 +1,312 @@ +package app + +import ( + "bytes" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/url" + "path/filepath" + "strconv" + "strings" + "testing" + + "archi_folio/internal/store" +) + +func newTestServer(t *testing.T) *Server { + t.Helper() + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate("admin", "changeme"); err != nil { + t.Fatal(err) + } + srv, err := New(Config{ + DatabasePath: filepath.Join(dir, "app.db"), + SessionSecret: "test-secret", + AdminUsername: "admin", + AdminPassword: "changeme", + UploadDir: filepath.Join(dir, "uploads"), + }, st) + if err != nil { + t.Fatal(err) + } + return srv +} + +func TestPublicRoutes(t *testing.T) { + srv := newTestServer(t) + handler := srv.Routes() + + for _, path := range []string{"/", "/projects", "/about", "/projects/courtyard-house"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s returned %d", path, rec.Code) + } + } +} + +func TestAdminRequiresLogin(t *testing.T) { + srv := newTestServer(t) + for _, path := range []string{"/admin", "/admin/main", "/admin/projects", "/admin/contact-details"} { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("%s expected redirect, got %d", path, rec.Code) + } + if location := rec.Header().Get("Location"); location != "/admin/login" { + t.Fatalf("%s expected login redirect, got %q", path, location) + } + } +} + +func TestAdminLogin(t *testing.T) { + srv := newTestServer(t) + form := url.Values{"username": {"admin"}, "password": {"changeme"}} + req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected redirect, got %d", rec.Code) + } + if location := rec.Header().Get("Location"); location != "/admin/main" { + t.Fatalf("expected admin main redirect, got %q", location) + } + if len(rec.Result().Cookies()) == 0 { + t.Fatal("expected session cookie") + } +} + +func TestAdminTabs(t *testing.T) { + srv := newTestServer(t) + handler := srv.Routes() + cookie := loginCookie(t, handler) + + req := httptest.NewRequest(http.MethodGet, "/admin", nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/main" { + t.Fatalf("expected /admin to redirect to /admin/main, got %d %q", rec.Code, rec.Header().Get("Location")) + } + + for _, test := range []struct { + path string + want string + }{ + {"/admin/main", "Main Content"}, + {"/admin/projects", "Add Project"}, + {"/admin/contact-details", "Contact Requests"}, + } { + req := httptest.NewRequest(http.MethodGet, test.path, nil) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("%s returned %d", test.path, rec.Code) + } + body, _ := io.ReadAll(rec.Result().Body) + if !strings.Contains(string(body), test.want) || !strings.Contains(string(body), "") { + t.Fatalf("%s did not render full tab page: %s", test.path, body) + } + } +} + +func TestAdminHTMXTabRequestReturnsPartial(t *testing.T) { + srv := newTestServer(t) + handler := srv.Routes() + cookie := loginCookie(t, handler) + + req := httptest.NewRequest(http.MethodGet, "/admin/projects", nil) + req.Header.Set("HX-Request", "true") + req.AddCookie(cookie) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected ok, got %d", rec.Code) + } + body, _ := io.ReadAll(rec.Result().Body) + text := string(body) + if strings.Contains(text, "") { + t.Fatalf("expected partial response, got full document: %s", text) + } + if !strings.Contains(text, `hx-swap-oob="true"`) || !strings.Contains(text, "Add Project") { + t.Fatalf("expected partial panel and out-of-band tab update: %s", text) + } +} + +func TestAdminMutationsRedirectToOwningTabs(t *testing.T) { + srv := newTestServer(t) + handler := srv.Routes() + cookie := loginCookie(t, handler) + + for _, test := range []struct { + path string + form url.Values + want string + }{ + { + path: "/admin/contact-details", + form: url.Values{"email": {"studio@example.com"}, "phone": {"123"}, "location": {"London"}}, + want: "/admin/contact-details?ok=contact+details+saved", + }, + { + path: "/admin/content", + form: url.Values{ + "hero_title": {"Hero"}, "hero_subtitle": {"Subtitle"}, "intro_title": {"Intro"}, "intro_text": {"Text"}, + "about_name": {"Name"}, "about_role": {"Role"}, "about_bio": {"Bio"}, + "hero_image_current": {"/static/placeholders/hero.svg"}, "about_image_current": {"/static/placeholders/about.svg"}, + }, + want: "/admin/main?ok=content+saved", + }, + } { + req := httptest.NewRequest(http.MethodPost, test.path, strings.NewReader(test.form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != test.want { + t.Fatalf("%s expected redirect %q, got %d %q", test.path, test.want, rec.Code, rec.Header().Get("Location")) + } + } +} + +func TestAdminProjectValidation(t *testing.T) { + srv := newTestServer(t) + handler := srv.Routes() + cookie := loginCookie(t, handler) + form := url.Values{"title": {" "}, "location": {"London"}, "year": {"2026"}, "category": {"Residential"}, "description": {"Text"}} + req := httptest.NewRequest(http.MethodPost, "/admin/projects", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.AddCookie(cookie) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected redirect, got %d", rec.Code) + } + if location := rec.Header().Get("Location"); !strings.Contains(location, "/admin/projects?err=") || !strings.Contains(location, "project+title+is+required") { + t.Fatalf("expected validation error redirect, got %q", location) + } +} + +func TestAdminRejectsSVGUpload(t *testing.T) { + srv := newTestServer(t) + handler := srv.Routes() + cookie := loginCookie(t, handler) + var body bytes.Buffer + writer := multipart.NewWriter(&body) + fields := map[string]string{ + "title": "Upload Test", + "location": "London", + "year": "2026", + "category": "Residential", + "description": "A project", + } + for key, value := range fields { + if err := writer.WriteField(key, value); err != nil { + t.Fatal(err) + } + } + file, err := writer.CreateFormFile("cover_image", "bad.svg") + if err != nil { + t.Fatal(err) + } + if _, err := file.Write([]byte(``)); err != nil { + t.Fatal(err) + } + if err := writer.Close(); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/admin/projects", &body) + req.Header.Set("Content-Type", writer.FormDataContentType()) + req.AddCookie(cookie) + rec := httptest.NewRecorder() + + handler.ServeHTTP(rec, req) + + if rec.Code != http.StatusSeeOther { + t.Fatalf("expected redirect, got %d", rec.Code) + } + if location := rec.Header().Get("Location"); !strings.Contains(location, "/admin/projects?err=") || !strings.Contains(location, "unsupported+image+type") { + t.Fatalf("expected unsupported image redirect, got %q", location) + } +} + +func TestContactSubmissionPersists(t *testing.T) { + srv := newTestServer(t) + form := url.Values{"name": {"Jane"}, "email": {"jane@example.com"}, "message": {"New project"}} + req := httptest.NewRequest(http.MethodPost, "/contact", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected ok, got %d", rec.Code) + } + body, _ := io.ReadAll(rec.Result().Body) + if !strings.Contains(string(body), "saved") { + t.Fatalf("expected success message, got %s", body) + } + requests, err := srv.store.ContactRequests(req.Context()) + if err != nil { + t.Fatal(err) + } + if len(requests) != 1 || requests[0].Email != "jane@example.com" { + t.Fatalf("unexpected contact requests: %+v", requests) + } +} + +func TestProjectImageOverlay(t *testing.T) { + srv := newTestServer(t) + project, err := srv.store.ProjectBySlug(t.Context(), "courtyard-house") + if err != nil { + t.Fatal(err) + } + if len(project.Images) == 0 { + t.Fatal("expected seeded project images") + } + path := "/projects/" + project.Slug + "/images/" + strconv.FormatInt(project.Images[0].ID, 10) + "/overlay" + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + + srv.Routes().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected ok, got %d", rec.Code) + } + body, _ := io.ReadAll(rec.Result().Body) + if !strings.Contains(string(body), "data-overlay") || !strings.Contains(string(body), project.Images[0].Path) { + t.Fatalf("overlay fragment missing expected content: %s", body) + } +} + +func loginCookie(t *testing.T, handler http.Handler) *http.Cookie { + t.Helper() + form := url.Values{"username": {"admin"}, "password": {"changeme"}} + req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode())) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rec := httptest.NewRecorder() + handler.ServeHTTP(rec, req) + cookies := rec.Result().Cookies() + if len(cookies) == 0 { + t.Fatal("expected login cookie") + } + return cookies[0] +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..fa3cfb1 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,432 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "time" + + _ "github.com/mattn/go-sqlite3" + "golang.org/x/crypto/bcrypt" +) + +type Store struct { + db *sql.DB +} + +type SiteContent struct { + HeroTitle string + HeroSubtitle string + IntroTitle string + IntroText string + AboutName string + AboutRole string + AboutBio string + Email string + Phone string + Location string + HeroImage string + AboutImage string +} + +type Project struct { + ID int64 + Slug string + Title string + Location string + Year string + Category string + Description string + CoverImage string + Featured bool + CreatedAt time.Time + Images []ProjectImage +} + +type ProjectImage struct { + ID int64 + ProjectID int64 + Path string + Caption string + Position int +} + +type ContactRequest struct { + ID int64 + Name string + Email string + Message string + CreatedAt time.Time +} + +type AdminUser struct { + ID int64 + Username string + PasswordHash []byte +} + +func Open(path string) (*Store, error) { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return nil, err + } + db, err := sql.Open("sqlite3", path+"?_foreign_keys=on") + if err != nil { + return nil, err + } + db.SetMaxOpenConns(1) + return &Store{db: db}, db.Ping() +} + +func (s *Store) Close() error { + return s.db.Close() +} + +func (s *Store) Migrate(adminUsername, adminPassword string) error { + stmts := []string{ + `create table if not exists site_content ( + id integer primary key check (id = 1), + hero_title text not null, + hero_subtitle text not null, + intro_title text not null, + intro_text text not null, + about_name text not null, + about_role text not null, + about_bio text not null, + email text not null, + phone text not null, + location text not null, + hero_image text not null, + about_image text not null + )`, + `create table if not exists projects ( + id integer primary key autoincrement, + slug text not null unique, + title text not null, + location text not null, + year text not null, + category text not null, + description text not null, + cover_image text not null, + featured integer not null default 0, + created_at datetime not null default current_timestamp + )`, + `create table if not exists project_images ( + id integer primary key autoincrement, + project_id integer not null references projects(id) on delete cascade, + path text not null, + caption text not null, + position integer not null default 0 + )`, + `create table if not exists contact_requests ( + id integer primary key autoincrement, + name text not null, + email text not null, + message text not null, + created_at datetime not null default current_timestamp + )`, + `create table if not exists admin_users ( + id integer primary key autoincrement, + username text not null unique, + password_hash blob not null + )`, + `create table if not exists sessions ( + token_hash text primary key, + user_id integer not null references admin_users(id) on delete cascade, + expires_at datetime not null + )`, + } + for _, stmt := range stmts { + if _, err := s.db.Exec(stmt); err != nil { + return err + } + } + return s.seed(adminUsername, adminPassword) +} + +func (s *Store) seed(adminUsername, adminPassword string) error { + var count int + if err := s.db.QueryRow(`select count(*) from site_content`).Scan(&count); err != nil { + return err + } + if count == 0 { + _, err := s.db.Exec(`insert into site_content ( + id, hero_title, hero_subtitle, intro_title, intro_text, about_name, about_role, about_bio, + email, phone, location, hero_image, about_image + ) values (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + "Archi Folio", + "Spatial design, architecture, and interiors shaped through quiet detail.", + "Selected residential and cultural spaces", + "A compact portfolio for showing image-led architectural work, interior concepts, and project narratives.", + "Alex Morgan", + "Architect & Interior Designer", + "I design calm, functional spaces with attention to proportion, material, light, and the daily rituals of the people who use them.", + "studio@example.com", + "+44 20 0000 0000", + "London, United Kingdom", + "/static/placeholders/hero.svg", + "/static/placeholders/about.svg", + ) + if err != nil { + return err + } + } + + if err := s.db.QueryRow(`select count(*) from projects`).Scan(&count); err != nil { + return err + } + if count == 0 { + projects := []Project{ + {Slug: "courtyard-house", Title: "Courtyard House", Location: "Bath, UK", Year: "2025", Category: "Residential", Description: "A private house organized around a quiet internal garden, using warm timber, stone, and filtered daylight.", CoverImage: "/static/placeholders/project-1.svg", Featured: true}, + {Slug: "atelier-apartment", Title: "Atelier Apartment", Location: "London, UK", Year: "2024", Category: "Interior", Description: "A compact apartment refit with integrated storage, gallery-like surfaces, and a flexible work area.", CoverImage: "/static/placeholders/project-2.svg", Featured: true}, + {Slug: "gallery-room", Title: "Gallery Room", Location: "Amsterdam, NL", Year: "2024", Category: "Cultural", Description: "A small exhibition environment designed for shifting light levels, sculpture, and intimate events.", CoverImage: "/static/placeholders/project-3.svg", Featured: true}, + } + for _, p := range projects { + id, err := s.CreateProject(context.Background(), p) + if err != nil { + return err + } + for i := 0; i < 3; i++ { + if _, err := s.db.Exec(`insert into project_images (project_id, path, caption, position) values (?, ?, ?, ?)`, id, p.CoverImage, p.Title, i); err != nil { + return err + } + } + } + } + + hash, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost) + if err != nil { + return err + } + var user AdminUser + err = s.db.QueryRow(`select id, username, password_hash from admin_users order by id asc limit 1`).Scan(&user.ID, &user.Username, &user.PasswordHash) + if errors.Is(err, sql.ErrNoRows) { + _, err = s.db.Exec(`insert into admin_users (username, password_hash) values (?, ?)`, adminUsername, hash) + return err + } + if err != nil { + return err + } + if user.Username == adminUsername && bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(adminPassword)) == nil { + return nil + } + _, err = s.db.Exec(`update admin_users set username = ?, password_hash = ? where id = ?`, adminUsername, hash, user.ID) + if err != nil { + return err + } + _, err = s.db.Exec(`delete from sessions`) + return err +} + +func (s *Store) SiteContent(ctx context.Context) (SiteContent, error) { + var c SiteContent + err := s.db.QueryRowContext(ctx, `select hero_title, hero_subtitle, intro_title, intro_text, about_name, about_role, about_bio, email, phone, location, hero_image, about_image from site_content where id = 1`). + Scan(&c.HeroTitle, &c.HeroSubtitle, &c.IntroTitle, &c.IntroText, &c.AboutName, &c.AboutRole, &c.AboutBio, &c.Email, &c.Phone, &c.Location, &c.HeroImage, &c.AboutImage) + return c, err +} + +func (s *Store) UpdateSiteContent(ctx context.Context, c SiteContent) error { + _, err := s.db.ExecContext(ctx, `update site_content set hero_title=?, hero_subtitle=?, intro_title=?, intro_text=?, about_name=?, about_role=?, about_bio=?, email=?, phone=?, location=?, hero_image=?, about_image=? where id=1`, + c.HeroTitle, c.HeroSubtitle, c.IntroTitle, c.IntroText, c.AboutName, c.AboutRole, c.AboutBio, c.Email, c.Phone, c.Location, c.HeroImage, c.AboutImage) + return err +} + +func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, error) { + query := `select id, slug, title, location, year, category, description, cover_image, featured, created_at from projects` + if featuredOnly { + query += ` where featured = 1` + } + query += ` order by created_at desc, id desc` + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + var projects []Project + for rows.Next() { + var p Project + var featured int + if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Description, &p.CoverImage, &featured, &p.CreatedAt); err != nil { + return nil, err + } + p.Featured = featured == 1 + projects = append(projects, p) + } + return projects, rows.Err() +} + +func (s *Store) ProjectBySlug(ctx context.Context, slug string) (Project, error) { + var p Project + var featured int + err := s.db.QueryRowContext(ctx, `select id, slug, title, location, year, category, description, cover_image, featured, created_at from projects where slug = ?`, slug). + Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Description, &p.CoverImage, &featured, &p.CreatedAt) + if err != nil { + return p, err + } + p.Featured = featured == 1 + p.Images, err = s.ProjectImages(ctx, p.ID) + return p, err +} + +func (s *Store) ProjectImages(ctx context.Context, projectID int64) ([]ProjectImage, error) { + rows, err := s.db.QueryContext(ctx, `select id, project_id, path, caption, position from project_images where project_id = ? order by position asc, id asc`, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var images []ProjectImage + for rows.Next() { + var img ProjectImage + if err := rows.Scan(&img.ID, &img.ProjectID, &img.Path, &img.Caption, &img.Position); err != nil { + return nil, err + } + images = append(images, img) + } + return images, rows.Err() +} + +func (s *Store) ProjectImagesByProject(ctx context.Context) (map[int64][]ProjectImage, error) { + rows, err := s.db.QueryContext(ctx, `select id, project_id, path, caption, position from project_images order by project_id asc, position asc, id asc`) + if err != nil { + return nil, err + } + defer rows.Close() + images := make(map[int64][]ProjectImage) + for rows.Next() { + var img ProjectImage + if err := rows.Scan(&img.ID, &img.ProjectID, &img.Path, &img.Caption, &img.Position); err != nil { + return nil, err + } + images[img.ProjectID] = append(images[img.ProjectID], img) + } + return images, rows.Err() +} + +func (s *Store) ProjectImageForSlug(ctx context.Context, slug string, imageID int64) (Project, ProjectImage, error) { + p, err := s.ProjectBySlug(ctx, slug) + if err != nil { + return Project{}, ProjectImage{}, err + } + for _, img := range p.Images { + if img.ID == imageID { + return p, img, nil + } + } + return Project{}, ProjectImage{}, sql.ErrNoRows +} + +func (s *Store) CreateProject(ctx context.Context, p Project) (int64, error) { + res, err := s.db.ExecContext(ctx, `insert into projects (slug, title, location, year, category, description, cover_image, featured) values (?, ?, ?, ?, ?, ?, ?, ?)`, + p.Slug, p.Title, p.Location, p.Year, p.Category, p.Description, p.CoverImage, boolInt(p.Featured)) + if err != nil { + return 0, err + } + return res.LastInsertId() +} + +func (s *Store) UpdateProject(ctx context.Context, p Project) error { + _, err := s.db.ExecContext(ctx, `update projects set slug=?, title=?, location=?, year=?, category=?, description=?, cover_image=?, featured=? where id=?`, + p.Slug, p.Title, p.Location, p.Year, p.Category, p.Description, p.CoverImage, boolInt(p.Featured), p.ID) + return err +} + +func (s *Store) DeleteProject(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `delete from projects where id = ?`, id) + return err +} + +func (s *Store) AddProjectImage(ctx context.Context, projectID int64, path, caption string) error { + var pos int + _ = s.db.QueryRowContext(ctx, `select coalesce(max(position), -1) + 1 from project_images where project_id = ?`, projectID).Scan(&pos) + _, err := s.db.ExecContext(ctx, `insert into project_images (project_id, path, caption, position) values (?, ?, ?, ?)`, projectID, path, caption, pos) + return err +} + +func (s *Store) DeleteProjectImage(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, `delete from project_images where id = ?`, id) + return err +} + +func (s *Store) SaveContact(ctx context.Context, name, email, message string) error { + _, err := s.db.ExecContext(ctx, `insert into contact_requests (name, email, message) values (?, ?, ?)`, name, email, message) + return err +} + +func (s *Store) ContactRequests(ctx context.Context) ([]ContactRequest, error) { + rows, err := s.db.QueryContext(ctx, `select id, name, email, message, created_at from contact_requests order by created_at desc, id desc`) + if err != nil { + return nil, err + } + defer rows.Close() + var requests []ContactRequest + for rows.Next() { + var r ContactRequest + if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Message, &r.CreatedAt); err != nil { + return nil, err + } + requests = append(requests, r) + } + return requests, rows.Err() +} + +func (s *Store) AdminByUsername(ctx context.Context, username string) (AdminUser, error) { + var u AdminUser + err := s.db.QueryRowContext(ctx, `select id, username, password_hash from admin_users where username = ?`, username).Scan(&u.ID, &u.Username, &u.PasswordHash) + return u, err +} + +func (s *Store) CreateSession(ctx context.Context, tokenHash string, userID int64, expiresAt time.Time) error { + _, err := s.db.ExecContext(ctx, `insert into sessions (token_hash, user_id, expires_at) values (?, ?, ?)`, tokenHash, userID, expiresAt) + return err +} + +func (s *Store) SessionUser(ctx context.Context, tokenHash string) (AdminUser, error) { + var u AdminUser + err := s.db.QueryRowContext(ctx, `select u.id, u.username, u.password_hash from sessions s join admin_users u on u.id = s.user_id where s.token_hash = ? and s.expires_at > ?`, tokenHash, time.Now()). + Scan(&u.ID, &u.Username, &u.PasswordHash) + return u, err +} + +func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error { + _, err := s.db.ExecContext(ctx, `delete from sessions where token_hash = ?`, tokenHash) + return err +} + +func IsNotFound(err error) bool { + return errors.Is(err, sql.ErrNoRows) +} + +func boolInt(v bool) int { + if v { + return 1 + } + return 0 +} + +func SlugFromTitle(title string) string { + out := make([]rune, 0, len(title)) + lastDash := false + for _, r := range title { + if r >= 'A' && r <= 'Z' { + r += 'a' - 'A' + } + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + out = append(out, r) + lastDash = false + continue + } + if !lastDash && len(out) > 0 { + out = append(out, '-') + lastDash = true + } + } + for len(out) > 0 && out[len(out)-1] == '-' { + out = out[:len(out)-1] + } + if len(out) == 0 { + return fmt.Sprintf("project-%d", time.Now().Unix()) + } + return string(out) +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..29c82e9 --- /dev/null +++ b/internal/store/store_test.go @@ -0,0 +1,33 @@ +package store + +import ( + "path/filepath" + "testing" + + "golang.org/x/crypto/bcrypt" +) + +func TestMigrateUpdatesAdminCredentials(t *testing.T) { + st, err := Open(filepath.Join(t.TempDir(), "app.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = st.Close() }) + if err := st.Migrate("admin", "old-password"); err != nil { + t.Fatal(err) + } + if err := st.Migrate("owner", "new-password"); err != nil { + t.Fatal(err) + } + + user, err := st.AdminByUsername(t.Context(), "owner") + if err != nil { + t.Fatal(err) + } + if err := bcrypt.CompareHashAndPassword(user.PasswordHash, []byte("new-password")); err != nil { + t.Fatalf("expected updated password hash: %v", err) + } + if _, err := st.AdminByUsername(t.Context(), "admin"); !IsNotFound(err) { + t.Fatalf("expected old username to be removed, got %v", err) + } +} diff --git a/web/static/app.js b/web/static/app.js new file mode 100644 index 0000000..0038b7d --- /dev/null +++ b/web/static/app.js @@ -0,0 +1,40 @@ +(function () { + const updateHeader = () => { + const header = document.querySelector("[data-site-header]"); + if (!header) return; + const compact = window.scrollY > 24; + header.classList.toggle("is-compact", compact); + }; + updateHeader(); + window.addEventListener("scroll", updateHeader, { passive: true }); + + function closeOverlay() { + const root = document.getElementById("overlay-root"); + if (root) root.innerHTML = ""; + document.documentElement.classList.remove("overflow-hidden"); + } + + document.addEventListener("htmx:afterSwap", function (event) { + if (event.detail.target && event.detail.target.id === "overlay-root") { + const overlay = event.detail.target.querySelector("[data-overlay]"); + if (!overlay) return; + document.documentElement.classList.add("overflow-hidden"); + const close = overlay.querySelector("[data-overlay-close]"); + if (close) close.focus(); + } + }); + + document.addEventListener("htmx:afterSettle", updateHeader); + + document.addEventListener("click", function (event) { + if (event.target.matches("[data-overlay], [data-overlay-close]")) { + closeOverlay(); + } + }); + + document.addEventListener("keydown", function (event) { + if (event.key === "Escape") { + closeOverlay(); + } + }); +})(); diff --git a/web/static/placeholders/about.svg b/web/static/placeholders/about.svg new file mode 100644 index 0000000..5aa092f --- /dev/null +++ b/web/static/placeholders/about.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/static/placeholders/hero.svg b/web/static/placeholders/hero.svg new file mode 100644 index 0000000..e3b5143 --- /dev/null +++ b/web/static/placeholders/hero.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/static/placeholders/project-1.svg b/web/static/placeholders/project-1.svg new file mode 100644 index 0000000..17c4be8 --- /dev/null +++ b/web/static/placeholders/project-1.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/static/placeholders/project-2.svg b/web/static/placeholders/project-2.svg new file mode 100644 index 0000000..44b7cab --- /dev/null +++ b/web/static/placeholders/project-2.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/static/placeholders/project-3.svg b/web/static/placeholders/project-3.svg new file mode 100644 index 0000000..bf1c670 --- /dev/null +++ b/web/static/placeholders/project-3.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/web/static/styles.css b/web/static/styles.css new file mode 100644 index 0000000..6752f8a --- /dev/null +++ b/web/static/styles.css @@ -0,0 +1,95 @@ +html { + scroll-behavior: smooth; +} + +body { + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; +} + +[data-site-header].is-compact { + padding-top: 0.85rem; + padding-bottom: 0.85rem; + background: rgba(250, 250, 250, 0.95); + color: #0a0a0a; + box-shadow: 0 1px 0 rgba(10, 10, 10, 0.08); + backdrop-filter: blur(18px); +} + +[data-site-header].is-compact [data-header-brand] { + font-size: 1.25rem; +} + +@keyframes page-fade-out { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +@keyframes page-fade-in { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes panel-fade-out { + from { + opacity: 1; + transform: translateY(0); + } + to { + opacity: 0; + transform: translateY(6px); + } +} + +@keyframes panel-fade-in { + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +#admin-panel { + view-transition-name: admin-panel; +} + +::view-transition-old(root) { + animation: 160ms ease both page-fade-out; +} + +::view-transition-new(root) { + animation: 220ms ease both page-fade-in; +} + +::view-transition-old(admin-panel) { + animation: 140ms ease both panel-fade-out; +} + +::view-transition-new(admin-panel) { + animation: 180ms ease both panel-fade-in; +} + +@media (prefers-reduced-motion: reduce) { + *, + ::before, + ::after { + scroll-behavior: auto !important; + } + + ::view-transition-old(root), + ::view-transition-new(root), + ::view-transition-old(admin-panel), + ::view-transition-new(admin-panel) { + animation-duration: 1ms; + } +} diff --git a/web/templates/about.html b/web/templates/about.html new file mode 100644 index 0000000..ede9991 --- /dev/null +++ b/web/templates/about.html @@ -0,0 +1,34 @@ +{{template "head" .}} +{{template "site_header" .}} +
+
+
+ {{.Content.AboutName}} +
+
+

{{.Content.AboutRole}}

+

{{.Content.AboutName}}

+

{{.Content.AboutBio}}

+
+

{{.Content.Email}}

+

{{.Content.Phone}}

+

{{.Content.Location}}

+
+
+
+ +
+

Contact

+
+ + + + +
+
+
+
+{{template "footer" .}} +
+ + diff --git a/web/templates/admin.html b/web/templates/admin.html new file mode 100644 index 0000000..325591f --- /dev/null +++ b/web/templates/admin.html @@ -0,0 +1,52 @@ +{{define "admin_shell_start"}} +{{template "head" .}} +
+
+ Archi Folio Admin +
+ View site +
+
+
+
+ {{template "admin_tabs_inner" .}} +
+
+
+
+ {{template "admin_flash_inner" .}} +
+
+{{end}} + +{{define "admin_shell_end"}} +
+
+ + +{{end}} + +{{define "admin_tabs_inner"}} + +{{end}} + +{{define "admin_tabs_oob"}} +
+ {{template "admin_tabs_inner" .}} +
+{{end}} + +{{define "admin_flash_inner"}} +{{if .Success}}

{{.Success}}

{{end}} +{{if .Error}}

{{.Error}}

{{end}} +{{end}} + +{{define "admin_flash_oob"}} +
+ {{template "admin_flash_inner" .}} +
+{{end}} diff --git a/web/templates/admin_contact.html b/web/templates/admin_contact.html new file mode 100644 index 0000000..dcde0e2 --- /dev/null +++ b/web/templates/admin_contact.html @@ -0,0 +1,42 @@ +{{define "admin_contact_details.html"}} +{{template "admin_shell_start" .}} +{{template "admin_contact_details_panel" .}} +{{template "admin_shell_end" .}} +{{end}} + +{{define "admin_contact_details_partial.html"}} +{{template "admin_tabs_oob" .}} +{{template "admin_flash_oob" .}} +{{template "admin_contact_details_panel" .}} +{{end}} + +{{define "admin_contact_details_panel"}} +
+
+

Contact Details

+
+ + + + +
+
+ +
+

Contact Requests

+
+ {{range .Contacts}} +
+
+

{{.Name}} · {{.Email}}

+

{{.CreatedAt.Format "2006-01-02 15:04"}}

+
+

{{.Message}}

+
+ {{else}} +

No contact requests yet.

+ {{end}} +
+
+
+{{end}} diff --git a/web/templates/admin_login.html b/web/templates/admin_login.html new file mode 100644 index 0000000..93e01cb --- /dev/null +++ b/web/templates/admin_login.html @@ -0,0 +1,18 @@ +{{template "head" .}} +
+
+

Admin

+ {{if .Error}}

{{.Error}}

{{end}} + + + +
+
+ + diff --git a/web/templates/admin_main.html b/web/templates/admin_main.html new file mode 100644 index 0000000..9381cb2 --- /dev/null +++ b/web/templates/admin_main.html @@ -0,0 +1,35 @@ +{{define "admin_main.html"}} +{{template "admin_shell_start" .}} +{{template "admin_main_panel" .}} +{{template "admin_shell_end" .}} +{{end}} + +{{define "admin_main_partial.html"}} +{{template "admin_tabs_oob" .}} +{{template "admin_flash_oob" .}} +{{template "admin_main_panel" .}} +{{end}} + +{{define "admin_main_panel"}} +
+

Main Content

+
+ + +
+ + + + + +
+ + +
+ + +
+ +
+
+{{end}} diff --git a/web/templates/admin_projects.html b/web/templates/admin_projects.html new file mode 100644 index 0000000..2531e6c --- /dev/null +++ b/web/templates/admin_projects.html @@ -0,0 +1,73 @@ +{{define "admin_projects.html"}} +{{template "admin_shell_start" .}} +{{template "admin_projects_panel" .}} +{{template "admin_shell_end" .}} +{{end}} + +{{define "admin_projects_partial.html"}} +{{template "admin_tabs_oob" .}} +{{template "admin_flash_oob" .}} +{{template "admin_projects_panel" .}} +{{end}} + +{{define "admin_projects_panel"}} +
+
+

Add Project

+
+ + + + + + + + + +
+
+ +
+

Projects

+ {{range .Projects}} +
+
+ + + + + + + + +
+ + +
+ +
+
+ +
+ +
+

Gallery

+
+ {{range .Images}} +
+ {{.Caption}} +
+
+ {{end}} +
+
+ + + +
+
+
+ {{end}} +
+
+{{end}} diff --git a/web/templates/base.html b/web/templates/base.html new file mode 100644 index 0000000..36a3bfa --- /dev/null +++ b/web/templates/base.html @@ -0,0 +1,35 @@ +{{define "head"}} + + + + + + {{if .Title}}{{.Title}} | {{end}}Archi Folio + + + + + + +{{end}} + +{{define "site_header"}} +
+
+ Archi Folio + +
+
+{{end}} + +{{define "footer"}} + +{{end}} diff --git a/web/templates/contact_result.html b/web/templates/contact_result.html new file mode 100644 index 0000000..0c1432e --- /dev/null +++ b/web/templates/contact_result.html @@ -0,0 +1,2 @@ +{{if .Success}}

{{.Success}}

{{end}} +{{if .Error}}

{{.Error}}

{{end}} diff --git a/web/templates/home.html b/web/templates/home.html new file mode 100644 index 0000000..dd2e950 --- /dev/null +++ b/web/templates/home.html @@ -0,0 +1,44 @@ +{{template "head" .}} +{{template "site_header" .}} +
+
+ +
+
+

{{.Content.HeroSubtitle}}

+

{{.Content.HeroTitle}}

+
+
+ +
+

{{.Content.IntroTitle}}

+

{{.Content.IntroText}}

+
+ +
+
+

Featured Projects

+ All work +
+ +
+
+{{template "footer" .}} +
+ + diff --git a/web/templates/overlay.html b/web/templates/overlay.html new file mode 100644 index 0000000..ce482c8 --- /dev/null +++ b/web/templates/overlay.html @@ -0,0 +1,7 @@ + diff --git a/web/templates/project.html b/web/templates/project.html new file mode 100644 index 0000000..5ce5fa3 --- /dev/null +++ b/web/templates/project.html @@ -0,0 +1,31 @@ +{{template "head" .}} +{{template "site_header" .}} +
+
+

{{.Project.Category}}

+
+

{{.Project.Title}}

+
+

Location

{{.Project.Location}}

+

Year

{{.Project.Year}}

+

Type

{{.Project.Category}}

+
+
+

{{.Project.Description}}

+
+ +
+
+ {{range .Project.Images}} + + {{end}} +
+
+
+{{template "footer" .}} +
+ + diff --git a/web/templates/projects.html b/web/templates/projects.html new file mode 100644 index 0000000..208e800 --- /dev/null +++ b/web/templates/projects.html @@ -0,0 +1,28 @@ +{{template "head" .}} +{{template "site_header" .}} +
+
+
+

Selected work

+

Projects

+
+

A visual index of architectural and interior design work.

+
+ +
+{{template "footer" .}} +
+ +