Initial commit
This commit is contained in:
commit
b0b5c3bd08
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@ -0,0 +1,11 @@
|
||||
.git
|
||||
.codex
|
||||
.agents
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
data/uploads/*
|
||||
!data/uploads/.gitkeep
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
tmp
|
||||
6
.env.example
Normal file
6
.env.example
Normal file
@ -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
|
||||
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
/.env
|
||||
/.env.*
|
||||
!/.env.example
|
||||
data/*.db
|
||||
data/*.db-*
|
||||
data/uploads/*
|
||||
!data/uploads/.gitkeep
|
||||
23
Dockerfile
Normal file
23
Dockerfile
Normal file
@ -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"]
|
||||
82
PLAN.md
Normal file
82
PLAN.md
Normal file
@ -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.
|
||||
61
ROUND_2.md
Normal file
61
ROUND_2.md
Normal file
@ -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.
|
||||
96
cmd/server/main.go
Normal file
96
cmd/server/main.go
Normal file
@ -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
|
||||
}
|
||||
1
data/uploads/.gitkeep
Normal file
1
data/uploads/.gitkeep
Normal file
@ -0,0 +1 @@
|
||||
|
||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@ -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:
|
||||
8
go.mod
Normal file
8
go.mod
Normal file
@ -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
|
||||
)
|
||||
4
go.sum
Normal file
4
go.sum
Normal file
@ -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=
|
||||
697
internal/app/app.go
Normal file
697
internal/app/app.go
Normal file
@ -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)
|
||||
})
|
||||
}
|
||||
312
internal/app/app_test.go
Normal file
312
internal/app/app_test.go
Normal file
@ -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), "<!doctype html>") {
|
||||
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, "<!doctype html>") {
|
||||
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(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`)); 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]
|
||||
}
|
||||
432
internal/store/store.go
Normal file
432
internal/store/store.go
Normal file
@ -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)
|
||||
}
|
||||
33
internal/store/store_test.go
Normal file
33
internal/store/store_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
40
web/static/app.js
Normal file
40
web/static/app.js
Normal file
@ -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();
|
||||
}
|
||||
});
|
||||
})();
|
||||
5
web/static/placeholders/about.svg
Normal file
5
web/static/placeholders/about.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 1100">
|
||||
<rect width="900" height="1100" fill="#e5e1d8"/>
|
||||
<circle cx="450" cy="350" r="160" fill="#a7a197"/>
|
||||
<path d="M180 980c35-250 190-380 270-380s235 130 270 380z" fill="#2b2b2b"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
6
web/static/placeholders/hero.svg
Normal file
6
web/static/placeholders/hero.svg
Normal file
@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 1000">
|
||||
<rect width="1600" height="1000" fill="#d8d5cf"/>
|
||||
<path d="M0 760 420 430l290 210 290-360 600 500v220H0z" fill="#9d9a92"/>
|
||||
<path d="M220 710h1160v95H220z" fill="#1f2933" opacity=".85"/>
|
||||
<path d="M290 295h390v360H290zM760 210h520v445H760z" fill="#f4f0e8" opacity=".78"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 350 B |
5
web/static/placeholders/project-1.svg
Normal file
5
web/static/placeholders/project-1.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 900">
|
||||
<rect width="900" height="900" fill="#d7d1c5"/>
|
||||
<path d="M120 650h660v110H120z" fill="#242424"/>
|
||||
<path d="M190 230h210v420H190zM460 160h250v490H460z" fill="#f7f2e8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
5
web/static/placeholders/project-2.svg
Normal file
5
web/static/placeholders/project-2.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 900">
|
||||
<rect width="900" height="900" fill="#c7ced0"/>
|
||||
<path d="M130 200h640v500H130z" fill="#f4f4ef"/>
|
||||
<path d="M220 300h170v300H220zM500 300h170v300H500z" fill="#6d7476"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 243 B |
5
web/static/placeholders/project-3.svg
Normal file
5
web/static/placeholders/project-3.svg
Normal file
@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 900">
|
||||
<rect width="900" height="900" fill="#ddd9d0"/>
|
||||
<path d="M170 180h560v560H170z" fill="#f8f7f2"/>
|
||||
<circle cx="450" cy="460" r="165" fill="#a1907c"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 224 B |
95
web/static/styles.css
Normal file
95
web/static/styles.css
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
34
web/templates/about.html
Normal file
34
web/templates/about.html
Normal file
@ -0,0 +1,34 @@
|
||||
{{template "head" .}}
|
||||
{{template "site_header" .}}
|
||||
<main class="mx-auto max-w-7xl px-5 pb-24 pt-32 md:px-8 md:pt-40">
|
||||
<section class="grid gap-10 md:grid-cols-[0.9fr_1.1fr] md:items-start">
|
||||
<div class="aspect-[4/5] overflow-hidden bg-neutral-200">
|
||||
<img src="{{.Content.AboutImage}}" alt="{{.Content.AboutName}}" class="h-full w-full object-cover">
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-4 text-sm uppercase tracking-[0.2em] text-neutral-500">{{.Content.AboutRole}}</p>
|
||||
<h1 class="text-5xl font-semibold md:text-7xl">{{.Content.AboutName}}</h1>
|
||||
<p class="mt-8 max-w-2xl text-xl leading-relaxed text-neutral-600">{{.Content.AboutBio}}</p>
|
||||
<div class="mt-8 grid gap-2 text-sm text-neutral-600">
|
||||
<p>{{.Content.Email}}</p>
|
||||
<p>{{.Content.Phone}}</p>
|
||||
<p>{{.Content.Location}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-20 max-w-2xl">
|
||||
<h2 class="mb-6 text-3xl font-semibold">Contact</h2>
|
||||
<form hx-post="/contact" hx-target="#contact-result" hx-swap="innerHTML" class="grid gap-4">
|
||||
<input name="name" required placeholder="Name" class="border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
|
||||
<input name="email" type="email" required placeholder="Email" class="border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
|
||||
<textarea name="message" required rows="6" placeholder="Project request" class="border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950"></textarea>
|
||||
<button class="bg-neutral-950 px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-white hover:bg-neutral-700">Submit request</button>
|
||||
</form>
|
||||
<div id="contact-result" class="mt-4"></div>
|
||||
</section>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
<div id="overlay-root"></div>
|
||||
</body>
|
||||
</html>
|
||||
52
web/templates/admin.html
Normal file
52
web/templates/admin.html
Normal file
@ -0,0 +1,52 @@
|
||||
{{define "admin_shell_start"}}
|
||||
{{template "head" .}}
|
||||
<header class="sticky top-0 z-30 border-b border-neutral-200 bg-white/95 backdrop-blur">
|
||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-5 py-4 md:px-8">
|
||||
<a href="/admin/main" class="text-xl font-semibold">Archi Folio Admin</a>
|
||||
<div class="flex items-center gap-4 text-sm">
|
||||
<a href="/" class="text-neutral-500 hover:text-neutral-950">View site</a>
|
||||
<form method="post" action="/admin/logout"><button class="text-neutral-500 hover:text-neutral-950">Log out</button></form>
|
||||
</div>
|
||||
</div>
|
||||
<div id="admin-tabs" class="border-t border-neutral-200">
|
||||
{{template "admin_tabs_inner" .}}
|
||||
</div>
|
||||
</header>
|
||||
<main class="mx-auto grid max-w-7xl gap-6 px-5 py-8 md:px-8">
|
||||
<div id="admin-flash">
|
||||
{{template "admin_flash_inner" .}}
|
||||
</div>
|
||||
<section id="admin-panel">
|
||||
{{end}}
|
||||
|
||||
{{define "admin_shell_end"}}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_tabs_inner"}}
|
||||
<nav class="mx-auto flex max-w-7xl gap-2 overflow-x-auto px-5 py-3 text-sm md:px-8" aria-label="Admin sections">
|
||||
<a href="/admin/main" hx-get="/admin/main" hx-target="#admin-panel" hx-push-url="true" hx-swap="innerHTML transition:true" class="whitespace-nowrap px-3 py-2 {{if eq .AdminTab "main"}}bg-neutral-950 text-white{{else}}text-neutral-500 hover:bg-neutral-100 hover:text-neutral-950{{end}}">Main</a>
|
||||
<a href="/admin/projects" hx-get="/admin/projects" hx-target="#admin-panel" hx-push-url="true" hx-swap="innerHTML transition:true" class="whitespace-nowrap px-3 py-2 {{if eq .AdminTab "projects"}}bg-neutral-950 text-white{{else}}text-neutral-500 hover:bg-neutral-100 hover:text-neutral-950{{end}}">Projects</a>
|
||||
<a href="/admin/contact-details" hx-get="/admin/contact-details" hx-target="#admin-panel" hx-push-url="true" hx-swap="innerHTML transition:true" class="whitespace-nowrap px-3 py-2 {{if eq .AdminTab "contact-details"}}bg-neutral-950 text-white{{else}}text-neutral-500 hover:bg-neutral-100 hover:text-neutral-950{{end}}">Contact Details</a>
|
||||
</nav>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_tabs_oob"}}
|
||||
<div id="admin-tabs" class="border-t border-neutral-200" hx-swap-oob="true">
|
||||
{{template "admin_tabs_inner" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "admin_flash_inner"}}
|
||||
{{if .Success}}<p class="border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">{{.Success}}</p>{{end}}
|
||||
{{if .Error}}<p class="border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">{{.Error}}</p>{{end}}
|
||||
{{end}}
|
||||
|
||||
{{define "admin_flash_oob"}}
|
||||
<div id="admin-flash" hx-swap-oob="true">
|
||||
{{template "admin_flash_inner" .}}
|
||||
</div>
|
||||
{{end}}
|
||||
42
web/templates/admin_contact.html
Normal file
42
web/templates/admin_contact.html
Normal file
@ -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"}}
|
||||
<section class="grid gap-6">
|
||||
<section class="bg-white p-6 shadow-sm">
|
||||
<h1 class="mb-6 text-2xl font-semibold">Contact Details</h1>
|
||||
<form method="post" action="/admin/contact-details" class="grid gap-4 md:grid-cols-3">
|
||||
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Email</span><input name="email" value="{{.Content.Email}}" class="w-full border px-3 py-2"></label>
|
||||
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Phone</span><input name="phone" value="{{.Content.Phone}}" class="w-full border px-3 py-2"></label>
|
||||
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Location</span><input name="location" value="{{.Content.Location}}" class="w-full border px-3 py-2"></label>
|
||||
<button class="w-fit bg-neutral-950 px-5 py-3 text-sm uppercase tracking-[0.18em] text-white md:col-span-3">Save contact details</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="bg-white p-6 shadow-sm">
|
||||
<h2 class="mb-6 text-2xl font-semibold">Contact Requests</h2>
|
||||
<div class="grid gap-4">
|
||||
{{range .Contacts}}
|
||||
<article class="border border-neutral-200 p-4">
|
||||
<div class="mb-2 flex flex-col gap-1 text-sm md:flex-row md:items-center md:justify-between">
|
||||
<p class="font-medium">{{.Name}} · {{.Email}}</p>
|
||||
<p class="text-neutral-500">{{.CreatedAt.Format "2006-01-02 15:04"}}</p>
|
||||
</div>
|
||||
<p class="text-neutral-700">{{.Message}}</p>
|
||||
</article>
|
||||
{{else}}
|
||||
<p class="text-neutral-500">No contact requests yet.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
{{end}}
|
||||
18
web/templates/admin_login.html
Normal file
18
web/templates/admin_login.html
Normal file
@ -0,0 +1,18 @@
|
||||
{{template "head" .}}
|
||||
<main class="flex min-h-screen items-center justify-center bg-neutral-950 px-5 text-neutral-950">
|
||||
<form method="post" action="/admin/login" class="w-full max-w-sm bg-white p-8 shadow-2xl">
|
||||
<h1 class="mb-6 text-3xl font-semibold">Admin</h1>
|
||||
{{if .Error}}<p class="mb-4 border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">{{.Error}}</p>{{end}}
|
||||
<label class="mb-4 block text-sm">
|
||||
<span class="mb-2 block text-neutral-500">Username</span>
|
||||
<input name="username" required autofocus class="w-full border border-neutral-300 px-4 py-3 outline-none focus:border-neutral-950">
|
||||
</label>
|
||||
<label class="mb-6 block text-sm">
|
||||
<span class="mb-2 block text-neutral-500">Password</span>
|
||||
<input name="password" type="password" required class="w-full border border-neutral-300 px-4 py-3 outline-none focus:border-neutral-950">
|
||||
</label>
|
||||
<button class="w-full bg-neutral-950 px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-white hover:bg-neutral-700">Log in</button>
|
||||
</form>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
35
web/templates/admin_main.html
Normal file
35
web/templates/admin_main.html
Normal file
@ -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"}}
|
||||
<section class="bg-white p-6 shadow-sm">
|
||||
<h1 class="mb-6 text-2xl font-semibold">Main Content</h1>
|
||||
<form method="post" action="/admin/content" enctype="multipart/form-data" class="grid gap-5">
|
||||
<input type="hidden" name="hero_image_current" value="{{.Content.HeroImage}}">
|
||||
<input type="hidden" name="about_image_current" value="{{.Content.AboutImage}}">
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Hero title</span><input name="hero_title" value="{{.Content.HeroTitle}}" class="w-full border px-3 py-2"></label>
|
||||
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Hero subtitle</span><input name="hero_subtitle" value="{{.Content.HeroSubtitle}}" class="w-full border px-3 py-2"></label>
|
||||
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Intro title</span><input name="intro_title" value="{{.Content.IntroTitle}}" class="w-full border px-3 py-2"></label>
|
||||
<label class="block text-sm"><span class="mb-2 block text-neutral-500">About name</span><input name="about_name" value="{{.Content.AboutName}}" class="w-full border px-3 py-2"></label>
|
||||
<label class="block text-sm"><span class="mb-2 block text-neutral-500">About role</span><input name="about_role" value="{{.Content.AboutRole}}" class="w-full border px-3 py-2"></label>
|
||||
</div>
|
||||
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Intro text</span><textarea name="intro_text" rows="3" class="w-full border px-3 py-2">{{.Content.IntroText}}</textarea></label>
|
||||
<label class="block text-sm"><span class="mb-2 block text-neutral-500">About bio</span><textarea name="about_bio" rows="4" class="w-full border px-3 py-2">{{.Content.AboutBio}}</textarea></label>
|
||||
<div class="grid gap-4 md:grid-cols-2">
|
||||
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Hero image</span><input name="hero_image" type="file" accept="image/png,image/jpeg,image/webp,image/gif" class="w-full border px-3 py-2"></label>
|
||||
<label class="block text-sm"><span class="mb-2 block text-neutral-500">About image</span><input name="about_image" type="file" accept="image/png,image/jpeg,image/webp,image/gif" class="w-full border px-3 py-2"></label>
|
||||
</div>
|
||||
<button class="w-fit bg-neutral-950 px-5 py-3 text-sm uppercase tracking-[0.18em] text-white">Save content</button>
|
||||
</form>
|
||||
</section>
|
||||
{{end}}
|
||||
73
web/templates/admin_projects.html
Normal file
73
web/templates/admin_projects.html
Normal file
@ -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"}}
|
||||
<section class="grid gap-6">
|
||||
<section class="bg-white p-6 shadow-sm">
|
||||
<h1 class="mb-6 text-2xl font-semibold">Add Project</h1>
|
||||
<form method="post" action="/admin/projects" enctype="multipart/form-data" class="grid gap-4 md:grid-cols-2">
|
||||
<input name="title" required placeholder="Title" class="border px-3 py-2">
|
||||
<input name="slug" placeholder="Slug, optional" class="border px-3 py-2">
|
||||
<input name="location" required placeholder="Location" class="border px-3 py-2">
|
||||
<input name="year" required placeholder="Year" class="border px-3 py-2">
|
||||
<input name="category" required placeholder="Category" class="border px-3 py-2">
|
||||
<label class="flex items-center gap-2 border px-3 py-2 text-sm"><input name="featured" type="checkbox"> Featured</label>
|
||||
<textarea name="description" required rows="4" placeholder="Description" class="border px-3 py-2 md:col-span-2"></textarea>
|
||||
<input name="cover_image" type="file" accept="image/png,image/jpeg,image/webp,image/gif" class="border px-3 py-2 md:col-span-2">
|
||||
<button class="w-fit bg-neutral-950 px-5 py-3 text-sm uppercase tracking-[0.18em] text-white md:col-span-2">Create project</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="grid gap-6">
|
||||
<h2 class="text-2xl font-semibold">Projects</h2>
|
||||
{{range .Projects}}
|
||||
<article class="bg-white p-6 shadow-sm">
|
||||
<form method="post" action="/admin/projects/{{.ID}}" enctype="multipart/form-data" class="grid gap-4 md:grid-cols-2">
|
||||
<input type="hidden" name="cover_image_current" value="{{.CoverImage}}">
|
||||
<input name="title" value="{{.Title}}" class="border px-3 py-2">
|
||||
<input name="slug" value="{{.Slug}}" class="border px-3 py-2">
|
||||
<input name="location" value="{{.Location}}" class="border px-3 py-2">
|
||||
<input name="year" value="{{.Year}}" class="border px-3 py-2">
|
||||
<input name="category" value="{{.Category}}" class="border px-3 py-2">
|
||||
<label class="flex items-center gap-2 border px-3 py-2 text-sm"><input name="featured" type="checkbox" {{if .Featured}}checked{{end}}> Featured</label>
|
||||
<textarea name="description" rows="4" class="border px-3 py-2 md:col-span-2">{{.Description}}</textarea>
|
||||
<div class="grid gap-3 md:col-span-2 md:grid-cols-[120px_1fr] md:items-center">
|
||||
<img src="{{.CoverImage}}" alt="" class="h-24 w-24 object-cover">
|
||||
<input name="cover_image" type="file" accept="image/png,image/jpeg,image/webp,image/gif" class="border px-3 py-2">
|
||||
</div>
|
||||
<button class="w-fit bg-neutral-950 px-5 py-3 text-sm uppercase tracking-[0.18em] text-white">Save</button>
|
||||
</form>
|
||||
<form method="post" action="/admin/projects/{{.ID}}/delete" class="mt-3">
|
||||
<button class="text-sm text-red-700">Delete project</button>
|
||||
</form>
|
||||
|
||||
<div class="mt-6 border-t pt-5">
|
||||
<h3 class="mb-3 font-semibold">Gallery</h3>
|
||||
<div class="mb-4 grid gap-3 sm:grid-cols-3 md:grid-cols-5">
|
||||
{{range .Images}}
|
||||
<div>
|
||||
<img src="{{.Path}}" alt="{{.Caption}}" class="aspect-square w-full object-cover">
|
||||
<form method="post" action="/admin/project-images/{{.ID}}/delete" class="mt-1"><button class="text-xs text-red-700">Remove</button></form>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
<form method="post" action="/admin/projects/{{.ID}}/images" enctype="multipart/form-data" class="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
|
||||
<input name="caption" placeholder="Caption" class="border px-3 py-2">
|
||||
<input name="image" type="file" accept="image/png,image/jpeg,image/webp,image/gif" required class="border px-3 py-2">
|
||||
<button class="bg-neutral-950 px-5 py-2 text-sm uppercase tracking-[0.18em] text-white">Add image</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
</section>
|
||||
</section>
|
||||
{{end}}
|
||||
35
web/templates/base.html
Normal file
35
web/templates/base.html
Normal file
@ -0,0 +1,35 @@
|
||||
{{define "head"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{if .Title}}{{.Title}} | {{end}}Archi Folio</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
<link rel="stylesheet" href="/static/styles.css">
|
||||
<script defer src="/static/app.js"></script>
|
||||
</head>
|
||||
<body class="bg-neutral-50 text-neutral-950 antialiased">
|
||||
{{end}}
|
||||
|
||||
{{define "site_header"}}
|
||||
<header data-site-header class="fixed inset-x-0 top-0 z-40 transition-all duration-300 {{if eq .Active "home"}}py-7 text-white{{else}}bg-neutral-50/95 py-4 shadow-sm backdrop-blur text-neutral-950{{end}}">
|
||||
<div class="mx-auto flex max-w-7xl items-center justify-between px-5 md:px-8">
|
||||
<a href="/" class="text-xl font-semibold tracking-normal md:text-3xl" data-header-brand hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">Archi Folio</a>
|
||||
<nav class="flex items-center gap-4 text-sm uppercase tracking-[0.18em] md:gap-8" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">
|
||||
<a class="hover:opacity-60 {{if eq .Active "projects"}}font-semibold{{end}}" href="/projects">Projects</a>
|
||||
<a class="hover:opacity-60 {{if eq .Active "about"}}font-semibold{{end}}" href="/about">About</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
{{end}}
|
||||
|
||||
{{define "footer"}}
|
||||
<footer class="border-t border-neutral-200 px-5 py-10 text-sm text-neutral-500 md:px-8">
|
||||
<div class="mx-auto flex max-w-7xl flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<p>© 2026 Archi Folio</p>
|
||||
<p>{{.Content.Email}} · {{.Content.Location}}</p>
|
||||
</div>
|
||||
</footer>
|
||||
{{end}}
|
||||
2
web/templates/contact_result.html
Normal file
2
web/templates/contact_result.html
Normal file
@ -0,0 +1,2 @@
|
||||
{{if .Success}}<p class="border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900">{{.Success}}</p>{{end}}
|
||||
{{if .Error}}<p class="border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-900">{{.Error}}</p>{{end}}
|
||||
44
web/templates/home.html
Normal file
44
web/templates/home.html
Normal file
@ -0,0 +1,44 @@
|
||||
{{template "head" .}}
|
||||
{{template "site_header" .}}
|
||||
<main>
|
||||
<section class="relative min-h-[92vh] overflow-hidden bg-neutral-950">
|
||||
<img src="{{.Content.HeroImage}}" alt="" class="absolute inset-0 h-full w-full object-cover opacity-90">
|
||||
<div class="absolute inset-0 bg-gradient-to-b from-black/35 via-black/5 to-black/55"></div>
|
||||
<div class="relative mx-auto flex min-h-[92vh] max-w-7xl flex-col justify-end px-5 pb-16 text-white md:px-8 md:pb-20">
|
||||
<p class="mb-4 max-w-xl text-sm uppercase tracking-[0.22em] text-white/75">{{.Content.HeroSubtitle}}</p>
|
||||
<h1 class="max-w-5xl text-5xl font-semibold leading-[0.95] md:text-8xl">{{.Content.HeroTitle}}</h1>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto grid max-w-7xl gap-8 px-5 py-20 md:grid-cols-[0.8fr_1.2fr] md:px-8 md:py-28">
|
||||
<h2 class="text-3xl font-semibold md:text-5xl">{{.Content.IntroTitle}}</h2>
|
||||
<p class="max-w-2xl text-xl leading-relaxed text-neutral-600">{{.Content.IntroText}}</p>
|
||||
</section>
|
||||
|
||||
<section class="px-5 pb-24 md:px-8" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">
|
||||
<div class="mx-auto mb-8 flex max-w-7xl items-end justify-between">
|
||||
<h2 class="text-2xl font-semibold md:text-4xl">Featured Projects</h2>
|
||||
<a href="/projects" class="text-sm uppercase tracking-[0.18em] text-neutral-500 hover:text-neutral-950">All work</a>
|
||||
</div>
|
||||
<div class="mx-auto grid max-w-7xl gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{{range .Projects}}
|
||||
<a href="/projects/{{.Slug}}" class="group block">
|
||||
<div class="aspect-square overflow-hidden bg-neutral-200">
|
||||
<img src="{{.CoverImage}}" alt="{{.Title}}" class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
|
||||
</div>
|
||||
<div class="mt-3 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium">{{.Title}}</h3>
|
||||
<p class="text-sm text-neutral-500">{{.Location}}</p>
|
||||
</div>
|
||||
<p class="text-sm text-neutral-500">{{.Year}}</p>
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
<div id="overlay-root"></div>
|
||||
</body>
|
||||
</html>
|
||||
7
web/templates/overlay.html
Normal file
7
web/templates/overlay.html
Normal file
@ -0,0 +1,7 @@
|
||||
<div data-overlay class="fixed inset-0 z-50 flex items-center justify-center bg-black/95 p-4 text-white md:p-8" role="dialog" aria-modal="true" aria-label="{{.Project.Title}} image viewer">
|
||||
<button type="button" data-overlay-close class="absolute right-4 top-4 z-10 rounded-full border border-white/30 px-4 py-2 text-sm uppercase tracking-[0.16em] text-white hover:bg-white hover:text-black">Close</button>
|
||||
<img src="{{.Image.Path}}" alt="{{.Image.Caption}}" class="max-h-full max-w-full object-contain">
|
||||
{{if .Image.Caption}}
|
||||
<p class="absolute bottom-4 left-4 right-4 text-center text-sm text-white/70">{{.Image.Caption}}</p>
|
||||
{{end}}
|
||||
</div>
|
||||
31
web/templates/project.html
Normal file
31
web/templates/project.html
Normal file
@ -0,0 +1,31 @@
|
||||
{{template "head" .}}
|
||||
{{template "site_header" .}}
|
||||
<main class="pt-28 md:pt-36">
|
||||
<section class="mx-auto max-w-7xl px-5 md:px-8">
|
||||
<p class="mb-4 text-sm uppercase tracking-[0.2em] text-neutral-500">{{.Project.Category}}</p>
|
||||
<div class="grid gap-8 md:grid-cols-[1.2fr_0.8fr] md:items-end">
|
||||
<h1 class="text-5xl font-semibold leading-none md:text-8xl">{{.Project.Title}}</h1>
|
||||
<div class="grid grid-cols-3 gap-4 border-t border-neutral-200 pt-4 text-sm">
|
||||
<div><p class="text-neutral-500">Location</p><p>{{.Project.Location}}</p></div>
|
||||
<div><p class="text-neutral-500">Year</p><p>{{.Project.Year}}</p></div>
|
||||
<div><p class="text-neutral-500">Type</p><p>{{.Project.Category}}</p></div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-10 max-w-3xl text-xl leading-relaxed text-neutral-600">{{.Project.Description}}</p>
|
||||
</section>
|
||||
|
||||
<section class="mx-auto mt-14 max-w-7xl px-5 pb-24 md:px-8">
|
||||
<div class="grid gap-5">
|
||||
{{range .Project.Images}}
|
||||
<button type="button" class="group block w-full text-left" hx-get="/projects/{{$.Project.Slug}}/images/{{.ID}}/overlay" hx-target="#overlay-root" hx-swap="innerHTML transition:true">
|
||||
<img src="{{.Path}}" alt="{{.Caption}}" class="max-h-[86vh] w-full bg-neutral-200 object-cover transition duration-500 group-hover:brightness-90">
|
||||
{{if .Caption}}<span class="mt-2 block text-sm text-neutral-500">{{.Caption}}</span>{{end}}
|
||||
</button>
|
||||
{{end}}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
<div id="overlay-root"></div>
|
||||
</body>
|
||||
</html>
|
||||
28
web/templates/projects.html
Normal file
28
web/templates/projects.html
Normal file
@ -0,0 +1,28 @@
|
||||
{{template "head" .}}
|
||||
{{template "site_header" .}}
|
||||
<main class="mx-auto max-w-7xl px-5 pb-24 pt-32 md:px-8 md:pt-40">
|
||||
<div class="mb-10 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||
<div>
|
||||
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">Selected work</p>
|
||||
<h1 class="text-5xl font-semibold md:text-7xl">Projects</h1>
|
||||
</div>
|
||||
<p class="max-w-md text-neutral-600">A visual index of architectural and interior design work.</p>
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">
|
||||
{{range .Projects}}
|
||||
<a href="/projects/{{.Slug}}" class="group block">
|
||||
<div class="aspect-square overflow-hidden bg-neutral-200">
|
||||
<img src="{{.CoverImage}}" alt="{{.Title}}" class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<h2 class="text-lg font-medium">{{.Title}}</h2>
|
||||
<p class="text-sm text-neutral-500">{{.Location}} · {{.Year}}</p>
|
||||
</div>
|
||||
</a>
|
||||
{{end}}
|
||||
</div>
|
||||
</main>
|
||||
{{template "footer" .}}
|
||||
<div id="overlay-root"></div>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user