Compare commits

..

No commits in common. "main" and "actions" have entirely different histories.

53 changed files with 1500 additions and 4094 deletions

View File

@ -9,22 +9,14 @@ jobs:
lint-test:
name: Lint and test
runs-on: ubuntu-latest
container:
image: golang:1.24-bookworm
steps:
- name: Checkout
env:
TOKEN: ${{ github.token }}
run: |
set -eu
git init .
if [ -n "${TOKEN:-}" ]; then
git remote add origin "https://x-access-token:${TOKEN}@k3gtpi.jumpingcrab.com/${GITHUB_REPOSITORY}.git"
else
git remote add origin "https://k3gtpi.jumpingcrab.com/${GITHUB_REPOSITORY}.git"
fi
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout --detach FETCH_HEAD
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Check formatting
run: |

View File

@ -6,25 +6,17 @@ on:
- main
jobs:
publish:
build-push:
name: Test, build, and push image
runs-on: ubuntu-latest
container:
image: golang:1.24-bookworm
steps:
- name: Checkout
env:
TOKEN: ${{ github.token }}
run: |
set -eu
git init .
if [ -n "${TOKEN:-}" ]; then
git remote add origin "https://x-access-token:${TOKEN}@k3gtpi.jumpingcrab.com/${GITHUB_REPOSITORY}.git"
else
git remote add origin "https://k3gtpi.jumpingcrab.com/${GITHUB_REPOSITORY}.git"
fi
git fetch --depth=1 origin "${GITHUB_SHA}"
git checkout --detach FETCH_HEAD
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Check formatting
run: |
@ -49,44 +41,16 @@ jobs:
exit 1
fi
echo "VERSION=$version" >> "$GITHUB_ENV"
echo "IMAGE_AMD64=k3crpi.jumpingcrab.com:5000/sabisan_amd64:$version" >> "$GITHUB_ENV"
echo "IMAGE_ARM64=k3crpi.jumpingcrab.com:5000/sabisan_arm64:$version" >> "$GITHUB_ENV"
- name: Install Docker CLI
run: |
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl docker.io
rm -rf /var/lib/apt/lists/*
mkdir -p ~/.docker/cli-plugins
curl -fsSL \
https://github.com/docker/buildx/releases/download/v0.19.3/buildx-v0.19.3.linux-amd64 \
-o ~/.docker/cli-plugins/docker-buildx
chmod +x ~/.docker/cli-plugins/docker-buildx
docker buildx version
echo "IMAGE=k3crpi.jumpingcrab.com:5000/sabisan:$version" >> "$GITHUB_ENV"
- name: Log in to registry
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login k3crpi.jumpingcrab.com:5000 \
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login k3crpi.jumpingcrab.com \
--username "${{ secrets.REGISTRY_USERNAME }}" \
--password-stdin
- name: Create Buildx builder
run: docker buildx create --use --name sabisan-builder
- name: Build image
run: docker build --pull --tag "$IMAGE" .
- name: Build and push amd64 image
run: |
docker buildx build \
--platform linux/amd64 \
--pull \
--tag "$IMAGE_AMD64" \
--push \
.
- name: Build and push arm64 image
run: |
docker buildx build \
--platform linux/arm64 \
--pull \
--tag "$IMAGE_ARM64" \
--push \
.
- name: Push image
run: docker push "$IMAGE"

1
.gitignore vendored
View File

@ -1,7 +1,6 @@
/.env
/.env.*
!/.env.example
infra/
data/*.db
data/*.db-*
data/uploads/*

View File

@ -1,28 +1,14 @@
# syntax=docker/dockerfile:1
ARG BUILDPLATFORM
FROM --platform=$BUILDPLATFORM golang:1.24-bookworm AS build
FROM golang:1.24-bookworm AS build
WORKDIR /src
ARG TARGETARCH
RUN apt-get update \
&& apt-get install -y --no-install-recommends gcc-aarch64-linux-gnu libc6-dev-arm64-cross \
&& rm -rf /var/lib/apt/lists/*
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN targetarch="${TARGETARCH:-amd64}" \
&& echo "building for TARGETARCH=${targetarch}" \
&& case "$targetarch" in \
amd64) export CC=gcc ;; \
arm64) export CC=aarch64-linux-gnu-gcc ;; \
*) echo "unsupported TARGETARCH=${targetarch}" >&2; exit 1 ;; \
esac \
&& CGO_ENABLED=1 GOOS=linux GOARCH="$targetarch" go build -o /out/archi-folio ./cmd/server
RUN CGO_ENABLED=1 GOOS=linux go build -o /out/archi-folio ./cmd/server
FROM gcr.io/distroless/cc-debian12:nonroot

View File

@ -1,316 +0,0 @@
# Round 3 Plan: Solo Architecture Studio Redesign
## Context
The current application is a compact server-rendered Go site with SQLite storage, Tailwind-loaded templates, HTMX navigation, image uploads, a password-protected admin area, project/gallery CRUD, a single editable `site_content` row, and a simple contact request inbox.
The public interface currently has:
- Home page with hero, intro statement, and featured projects.
- Projects index.
- Project detail pages with image overlays.
- About page that also contains the only public contact form.
- No dedicated Services page.
- No dedicated Contact page.
For a small single-person architecture practice, the redesign should not become a full agency website or heavy CMS. The site needs to build trust quickly, show a clear design position, make project work easy to inspect, and collect useful enquiries without making the owner maintain many pages.
## What From The Recommendation Fits
- Home page: relevant and already mostly present, but it needs stronger positioning, a clearer call to action, and a better preview of services/process.
- About / Studio page: highly relevant. For a solo practice, this should be one of the strongest trust-building pages because clients are hiring the person, not a large firm.
- Services page: relevant, but should be concise. A long list like architecture, interiors, renovation, consultation, space planning, furniture, project management, and 3D visualization may feel inflated for a one-person studio unless each item is genuinely offered.
- Contact page: relevant. The current form is buried on About, and the form does not pre-qualify leads.
## What To Avoid
- Do not create a large multi-page agency structure with too many thin pages.
- Do not add a complex awards/publications system unless the owner has real content to maintain.
- Do not overstate capabilities with every possible design service.
- Do not add map/social integrations until the real contact content exists.
- Do not build a full page builder. The admin should remain simple and opinionated.
## Proposed Public Site Structure
### 1. Home
Purpose: communicate the studio position quickly and send visitors to projects or enquiry.
Recommended sections:
- Full-viewport image-led hero, using a real project image when available.
- Positioning line such as "Residential architecture and interiors in London" rather than generic portfolio wording.
- One primary CTA: "Start an enquiry".
- One secondary CTA: "View projects".
- Short studio statement, 2-3 sentences.
- Featured projects, limited to 3-4.
- Compact services preview, 3 service groups maximum.
- Brief process preview, 3 steps maximum.
- Short about preview with portrait and link to Studio.
Current implementation impact:
- Keep the existing Home route.
- Rework `home.html` layout and copy fields.
- Continue using featured projects from the existing project model.
- Add admin-editable CTA text/link and positioning fields.
### 2. Studio
Purpose: make the solo practitioner credible, specific, and approachable.
This should replace or rename the current About page to "Studio". The route can remain `/about` for compatibility, but the navigation label should become `Studio`.
Recommended sections:
- Portrait or working photo.
- Name, role, and location.
- Short personal narrative.
- Design philosophy.
- Approach/process.
- Credentials, registrations, selected experience, or publications as a simple text block.
- Contact CTA.
Current implementation impact:
- Keep the existing about content fields but expand them.
- Add fields for philosophy, approach, credentials, and availability/service area.
- Remove the main contact form from this page or reduce it to a CTA linking to Contact.
### 3. Services
Purpose: help visitors self-qualify without making the practice look larger than it is.
Recommended services for a solo architecture company:
- Residential architecture.
- Renovation and extensions.
- Interior architecture and spatial planning.
- Early-stage consultation.
Optional only if accurate:
- Planning support.
- 3D visualization.
- Furniture and finish selection.
- Project coordination.
Recommended sections:
- Service overview.
- 3-4 editable service cards.
- "How projects work" process section.
- Typical timeline ranges.
- Geographic availability.
- Small FAQ.
- CTA to Contact.
Current implementation impact:
- Add `GET /services`.
- Add a `services.html` template.
- Add admin support for editable services and FAQs.
- For the first implementation, services can be stored as structured rows rather than hard-coded copy.
### 4. Contact
Purpose: create a proper conversion point and collect enough information to assess fit.
Recommended sections:
- Contact form.
- Email, phone, location.
- Short expectation statement, for example response time or preferred project types.
- Optional social links.
- Optional map later, not required now.
Inquiry questionnaire fields:
- Name.
- Email.
- Phone, optional.
- Project type.
- Location.
- Budget range.
- Timeline.
- Message.
Current implementation impact:
- Add `GET /contact`.
- Keep `POST /contact`, but expand the stored fields.
- Update admin contact inbox to show the structured enquiry details.
- Add validation for required fields and length limits.
## Proposed Navigation
Use five top-level links:
- Projects
- Studio
- Services
- Contact
Home remains the logo link. This keeps navigation focused and avoids a marketing-heavy structure.
## Backend And Data Plan
### Site Content
The existing `site_content` row is good for global editable content, but it needs more fields:
- `site_name`
- `positioning`
- `hero_cta_label`
- `hero_cta_url`
- `secondary_cta_label`
- `secondary_cta_url`
- `studio_philosophy`
- `studio_approach`
- `studio_credentials`
- `service_area`
- `response_note`
These can be added through a migration while preserving current data.
### Services
Add a `services` table:
- `id`
- `title`
- `summary`
- `details`
- `position`
- `active`
This keeps the Services page editable without turning it into a page builder.
### FAQs
Add a `faqs` table:
- `id`
- `question`
- `answer`
- `position`
- `active`
Use FAQs mainly on Services and optionally Contact.
### Contact Requests
Expand `contact_requests`:
- `phone`
- `project_type`
- `project_location`
- `budget_range`
- `timeline`
- `status`
- `notes`
`status` can start simple: `new`, `reviewed`, `archived`. `notes` is private admin-only text.
### Projects
The current project model is usable. Consider adding only:
- `summary` for cards and homepage previews.
- `scope` for project detail metadata.
- `status` or `completion_stage` if the studio wants to show built/in progress/concept.
- `position` for manual ordering.
Manual ordering matters more than created-at ordering for a portfolio.
## Admin Plan
Keep the tabbed admin from Round 2 and add two tabs:
- Main
- Projects
- Studio
- Services
- Contact
Recommended ownership:
- Main: home hero, positioning, intro, CTA labels/links, hero image.
- Projects: project CRUD, gallery uploads, featured flag, ordering.
- Studio: portrait, name, role, bio, philosophy, approach, credentials.
- Services: service rows, process copy, FAQ rows.
- Contact: public contact details, enquiry inbox, enquiry status, private notes.
The admin should stay form-based. Avoid rich text editing in this round unless there is a clear content requirement.
## Interface Direction
The current visual direction is image-led, quiet, and minimalist. That fits architecture, but the redesign should make the site feel more intentional and less like a generic template.
Recommended direction:
- Use generous image layouts for project pages.
- Keep typography restrained but improve hierarchy.
- Make the homepage less sparse by adding actionable service/process content.
- Use project facts and captions to make work inspectable.
- Keep color neutral, but avoid a flat all-neutral interface by using material-inspired accents such as stone, clay, brass, or muted green in small amounts.
- Do not use decorative cards as the main layout. Use full-width sections, grids, and strong image composition.
## Implementation Phases
### Phase 1: Public IA And Contact
- Add `/contact` page.
- Move the enquiry form there.
- Expand contact request fields.
- Add navigation item.
- Update admin contact inbox.
This creates immediate business value and fixes the weakest current conversion path.
### Phase 2: Studio And Home Rework
- Rename About navigation to Studio.
- Expand Studio content fields.
- Rework homepage sections and CTAs.
- Add home services/process preview using simple editable fields or seeded service rows.
This improves trust and positioning without a large backend expansion.
### Phase 3: Services
- Add `/services`.
- Add services and FAQs tables.
- Add admin Services tab.
- Add process/timeline/geography content.
This helps visitors self-qualify and reduces repetitive pre-sales explanations.
### Phase 4: Project Depth
- Add project summary/scope/status/manual ordering.
- Improve project detail pages with clearer metadata and richer captions.
- Add admin ordering controls.
This is valuable after the lead path and positioning are stronger.
## Testing Plan
- Add route tests for `/`, `/projects`, `/projects/{slug}`, `/about`, `/services`, and `/contact`.
- Add form tests for expanded contact validation and persistence.
- Add migration tests for new columns/tables.
- Add admin tests for new tabs and update actions.
- Manually verify HTMX navigation still works with direct URL fallback.
- Manually verify mobile layouts for Home, Studio, Services, Contact, and Project detail.
## Open Questions
- Should the public label be "Studio" while keeping `/about`, or should the route become `/studio` with `/about` redirecting?
- Which services are genuinely offered by the practitioner today?
- Does the studio want to show a phone number publicly, or keep initial contact email/form-only?
- Are there real awards, registrations, or publications to show, or should credentials remain a simple text block?
- Should contact requests trigger email notification, or is the admin inbox enough for now?
## Recommended Next Step
Start with Phase 1. A dedicated Contact page with a structured enquiry form is the highest-value backend and interface improvement, and it gives the later Home, Studio, and Services pages a clear conversion destination.

View File

@ -1,244 +0,0 @@
# Round 4 Plan: Public Shell, HTMX Partials, and Smoother Navigation
## Goal
Refactor the public interface so `base.html` is the single public shell for header, footer, DaisyUI drawer, overlay root, and the stable main content container. Public pages should render either as a full document on direct visits or as main-content partials during HTMX navigation.
This keeps the current architecture portfolio style and functionality while making page structure easier to maintain.
## Current State
- Public pages call `{{template "site_start" .}}`, then render their own `<main>`, then call `{{template "site_end" .}}`.
- Public navigation generally uses `hx-boost="true"` with `hx-target="body"` and full `body` replacement.
- The mobile sidebar now uses DaisyUI drawer structure.
- Admin already has a better partial pattern: full page for direct requests, partial panel for HTMX requests, plus out-of-band tab updates.
## Target Structure
Use `base.html` as the public shell.
Recommended template ownership:
- `base.html`
- `head`
- full public document shell
- header
- desktop nav
- DaisyUI mobile drawer
- footer
- `#main-content`
- `#overlay-root`
- OOB nav fragments
- Page templates
- define only page content partials, for example:
- `home_content`
- `projects_content`
- `project_content`
- `studio_content`
- `services_content`
- `contact_content`
## Rendering Model
### Direct Page Load
Normal browser requests render the full shell:
```html
<!doctype html>
<html>
<head>...</head>
<body>
<div class="drawer drawer-end">
...
<main id="main-content">
page content
</main>
...
</div>
</body>
</html>
```
### HTMX Navigation
HTMX requests return:
- the new `#main-content` element
- out-of-band desktop nav active state
- out-of-band drawer nav active state
- optionally an out-of-band document title update if needed later
This mirrors the admin partial strategy.
## HTMX Navigation Changes
Change public internal navigation from body replacement:
```html
hx-target="body"
hx-swap="outerHTML transition:true"
```
to main-content replacement:
```html
hx-target="#main-content"
hx-swap="outerHTML transition:true"
hx-push-url="true"
```
The header, drawer, footer, and overlay root remain stable across page transitions.
## Active Navigation
Because only the main content will swap, active navigation state needs to update separately.
Use out-of-band fragments:
- `#site-desktop-nav`
- `#site-drawer-nav`
The full shell and HTMX partial responses both render nav from the same template definitions, avoiding duplicated active-state logic.
## Drawer Behavior
Keep the DaisyUI drawer:
- `drawer drawer-end`
- `drawer-toggle`
- `drawer-content`
- `drawer-side`
- `drawer-overlay`
Improvements:
- Keep the drawer as part of the stable shell, not page content.
- Close drawer after clicking a drawer nav link.
- Close drawer on Escape.
- Keep the matted glass visual:
- `bg-neutral-950/45`
- `backdrop-blur-xl`
- white text
- active item underlined
## Smoother Transitions
### Page Content
Apply transitions to `#main-content`, not the full body.
Recommended CSS:
- old content: slight fade out and 4-6px downward motion
- new content: fade in and return to zero offset
- short duration, around 160-220ms
- respect `prefers-reduced-motion`
The existing View Transition CSS can be adjusted so:
- `#main-content` has `view-transition-name: main-content`
- root transitions are less dominant or removed for public navigation
- admin panel transitions remain independent
### Drawer
DaisyUI handles the structural entry/exit, but add a small custom polish layer if needed:
- slightly longer transform duration, around 240ms
- eased slide-in/out
- backdrop fade around 180-220ms
Avoid custom JavaScript animation. Prefer CSS.
## Backend Changes
Add a public render helper similar to admin rendering:
```go
func (s *Server) renderPublic(w http.ResponseWriter, r *http.Request, fullTemplate, partialTemplate string, data pageData)
```
Behavior:
- normal request: render full shell with the correct page content
- HTMX request: render partial content plus OOB nav fragments
Each public handler should call `renderPublic` instead of `render`.
## Template Naming Plan
Suggested full/partial template names:
- `home.html` full shell, `home_partial.html`
- `projects.html` full shell, `projects_partial.html`
- `project.html` full shell, `project_partial.html`
- `about.html` full shell, `about_partial.html`
- `services.html` full shell, `services_partial.html`
- `contact.html` full shell, `contact_partial.html`
Alternatively, each page file can define:
- `page_full`
- `page_partial`
- `page_content`
Pick one convention and use it everywhere.
## Preserve Current Functionality
Must continue working:
- direct URL visits
- browser back/forward with pushed URLs
- non-JavaScript navigation fallback
- project image overlay HTMX requests
- contact form HTMX submission
- admin tab partial behavior
- health endpoints
- DaisyUI mobile drawer
## Testing Plan
Update or add tests for:
- direct public routes return full HTML document
- HTMX public route requests return partial content, not full document
- HTMX public partial responses include OOB nav fragments
- active nav state updates for each page
- project image overlay still returns only overlay fragment
- contact form submission still returns only `contact_result.html`
- existing admin HTMX tests still pass
Manual checks:
- mobile drawer opens/closes on every public page
- drawer closes after nav click
- drawer closes with Escape
- page transitions feel smooth on desktop and mobile
- reduced-motion setting disables meaningful animation
- browser back/forward updates main content and nav state
## Implementation Order
1. Refactor `base.html` into the full public shell.
2. Convert one page, preferably Home, to full/partial/content templates.
3. Add `renderPublic`.
4. Convert remaining public pages.
5. Add OOB nav fragments.
6. Update HTMX targets in header, drawer, and internal CTAs.
7. Adjust transition CSS for `#main-content`.
8. Add drawer close-on-link/Escape behavior.
9. Update tests.
10. Run full test suite and manually verify key routes.
## Risks
- OOB nav fragments can become noisy if duplicated in every page template. Keep them in `base.html`.
- Contact form and overlay requests should not use `renderPublic`; they should keep returning small fragments.
- Page title will not automatically update if only `#main-content` swaps. This can be handled later with an OOB `<title>` strategy or small JS.
- DaisyUI CDN use is acceptable for now, but a proper Tailwind/DaisyUI build pipeline would be better for production performance and version control.
## Recommended Scope For Round 4
Implement the public shell and main-content HTMX navigation first. Do not refactor admin forms or public content sections in the same round. Keep this round focused on layout architecture, navigation maintainability, and smoother transitions.

View File

@ -1 +1 @@
0.2.0
0.1.0

699
internal/app/app.go Normal file
View File

@ -0,0 +1,699 @@
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
Version 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
Version 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"), Version: s.cfg.Version}
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)
})
}

313
internal/app/app_test.go Normal file
View File

@ -0,0 +1,313 @@
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"),
Version: "test-version",
}, 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>") || !strings.Contains(string(body), "Version test-version") {
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]
}

View File

@ -1,22 +0,0 @@
package app
import (
"os"
"path/filepath"
"runtime"
)
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")
}

View File

@ -1,81 +0,0 @@
package app
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/http"
"time"
"golang.org/x/crypto/bcrypt"
)
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) 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 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[:])
}

View File

@ -1,46 +0,0 @@
package app
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
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")
}
}

View File

@ -1,381 +0,0 @@
package app
import (
"net/http"
"strconv"
"strings"
"archi_folio/internal/store"
)
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: formValueOr(r, "hero_title", current.HeroTitle),
HeroSubtitle: formValueOr(r, "hero_subtitle", current.HeroSubtitle),
Positioning: formValueOr(r, "positioning", current.Positioning),
HeroCTALabel: formValueOr(r, "hero_cta_label", current.HeroCTALabel),
HeroCTAURL: formValueOr(r, "hero_cta_url", current.HeroCTAURL),
SecondaryCTALabel: formValueOr(r, "secondary_cta_label", current.SecondaryCTALabel),
SecondaryCTAURL: formValueOr(r, "secondary_cta_url", current.SecondaryCTAURL),
IntroTitle: formValueOr(r, "intro_title", current.IntroTitle),
IntroText: formValueOr(r, "intro_text", current.IntroText),
ServiceOneTitle: formValueOr(r, "service_one_title", current.ServiceOneTitle),
ServiceOneText: formValueOr(r, "service_one_text", current.ServiceOneText),
ServiceTwoTitle: formValueOr(r, "service_two_title", current.ServiceTwoTitle),
ServiceTwoText: formValueOr(r, "service_two_text", current.ServiceTwoText),
ServiceThreeTitle: formValueOr(r, "service_three_title", current.ServiceThreeTitle),
ServiceThreeText: formValueOr(r, "service_three_text", current.ServiceThreeText),
ProcessOneTitle: formValueOr(r, "process_one_title", current.ProcessOneTitle),
ProcessOneText: formValueOr(r, "process_one_text", current.ProcessOneText),
ProcessTwoTitle: formValueOr(r, "process_two_title", current.ProcessTwoTitle),
ProcessTwoText: formValueOr(r, "process_two_text", current.ProcessTwoText),
ProcessThreeTitle: formValueOr(r, "process_three_title", current.ProcessThreeTitle),
ProcessThreeText: formValueOr(r, "process_three_text", current.ProcessThreeText),
AboutName: formValueOr(r, "about_name", current.AboutName),
AboutRole: formValueOr(r, "about_role", current.AboutRole),
AboutBio: formValueOr(r, "about_bio", current.AboutBio),
StudioPhilosophy: formValueOr(r, "studio_philosophy", current.StudioPhilosophy),
StudioApproach: formValueOr(r, "studio_approach", current.StudioApproach),
StudioCredentials: formValueOr(r, "studio_credentials", current.StudioCredentials),
ServiceArea: formValueOr(r, "service_area", current.ServiceArea),
Email: current.Email,
Phone: current.Phone,
Location: current.Location,
HeroImage: formValueOr(r, "hero_image_current", current.HeroImage),
AboutImage: formValueOr(r, "about_image_current", current.AboutImage),
}
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")),
Summary: strings.TrimSpace(r.FormValue("summary")),
Scope: strings.TrimSpace(r.FormValue("scope")),
Status: strings.TrimSpace(r.FormValue("status")),
Position: formInt(r, "position"),
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")),
Summary: strings.TrimSpace(r.FormValue("summary")),
Scope: strings.TrimSpace(r.FormValue("scope")),
Status: strings.TrimSpace(r.FormValue("status")),
Position: formInt(r, "position"),
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) adminCreateService(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "services", "service form failed")
return
}
service := serviceFromForm(r, 0)
if err := validateService(service); err != nil {
s.redirectAdmin(w, r, "services", err.Error())
return
}
if err := s.store.CreateService(r.Context(), service); err != nil {
s.redirectAdmin(w, r, "services", "service could not be created")
return
}
s.redirectAdmin(w, r, "services", "service created")
}
func (s *Server) adminUpdateService(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 := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "services", "service form failed")
return
}
service := serviceFromForm(r, id)
if err := validateService(service); err != nil {
s.redirectAdmin(w, r, "services", err.Error())
return
}
if err := s.store.UpdateService(r.Context(), service); err != nil {
s.redirectAdmin(w, r, "services", "service could not be saved")
return
}
s.redirectAdmin(w, r, "services", "service saved")
}
func (s *Server) adminDeleteService(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err == nil {
err = s.store.DeleteService(r.Context(), id)
}
if err != nil {
s.redirectAdmin(w, r, "services", "service could not be deleted")
return
}
s.redirectAdmin(w, r, "services", "service deleted")
}
func (s *Server) adminCreateFAQ(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "services", "FAQ form failed")
return
}
faq := faqFromForm(r, 0)
if err := validateFAQ(faq); err != nil {
s.redirectAdmin(w, r, "services", err.Error())
return
}
if err := s.store.CreateFAQ(r.Context(), faq); err != nil {
s.redirectAdmin(w, r, "services", "FAQ could not be created")
return
}
s.redirectAdmin(w, r, "services", "FAQ created")
}
func (s *Server) adminUpdateFAQ(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 := r.ParseForm(); err != nil {
s.redirectAdmin(w, r, "services", "FAQ form failed")
return
}
faq := faqFromForm(r, id)
if err := validateFAQ(faq); err != nil {
s.redirectAdmin(w, r, "services", err.Error())
return
}
if err := s.store.UpdateFAQ(r.Context(), faq); err != nil {
s.redirectAdmin(w, r, "services", "FAQ could not be saved")
return
}
s.redirectAdmin(w, r, "services", "FAQ saved")
}
func (s *Server) adminDeleteFAQ(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err == nil {
err = s.store.DeleteFAQ(r.Context(), id)
}
if err != nil {
s.redirectAdmin(w, r, "services", "FAQ could not be deleted")
return
}
s.redirectAdmin(w, r, "services", "FAQ deleted")
}
func serviceFromForm(r *http.Request, id int64) store.Service {
return store.Service{
ID: id,
Title: strings.TrimSpace(r.FormValue("title")),
Summary: strings.TrimSpace(r.FormValue("summary")),
Details: strings.TrimSpace(r.FormValue("details")),
Position: formInt(r, "position"),
Active: r.FormValue("active") == "on",
}
}
func faqFromForm(r *http.Request, id int64) store.FAQ {
return store.FAQ{
ID: id,
Question: strings.TrimSpace(r.FormValue("question")),
Answer: strings.TrimSpace(r.FormValue("answer")),
Position: formInt(r, "position"),
Active: r.FormValue("active") == "on",
}
}
func formInt(r *http.Request, name string) int {
value, _ := strconv.Atoi(strings.TrimSpace(r.FormValue(name)))
return value
}
func formValueOr(r *http.Request, name, fallback string) string {
if _, ok := r.Form[name]; !ok {
return fallback
}
return strings.TrimSpace(r.FormValue(name))
}

View File

@ -1,158 +0,0 @@
package app
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
)
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"},
"positioning": {"Residential architect in London"}, "hero_cta_label": {"Enquire"}, "hero_cta_url": {"/contact"},
"secondary_cta_label": {"Projects"}, "secondary_cta_url": {"/projects"},
"service_one_title": {"Homes"}, "service_one_text": {"Home text"},
"service_two_title": {"Interiors"}, "service_two_text": {"Interior text"},
"service_three_title": {"Consulting"}, "service_three_text": {"Consulting text"},
"process_one_title": {"Listen"}, "process_one_text": {"Listen text"},
"process_two_title": {"Shape"}, "process_two_text": {"Shape text"},
"process_three_title": {"Refine"}, "process_three_text": {"Refine text"},
"studio_philosophy": {"Philosophy"}, "studio_approach": {"Approach"}, "studio_credentials": {"Credentials"}, "service_area": {"London"},
"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"}, "summary": {"Summary"}, "scope": {"Scope"}, "status": {"Completed"}, "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 TestAdminProjectCreatePersistsDepthFields(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
form := url.Values{
"title": {"Garden Studio"},
"location": {"London"},
"year": {"2026"},
"category": {"Residential"},
"summary": {"A compact studio in a rear garden."},
"scope": {"Architecture, interiors"},
"status": {"In progress"},
"position": {"12"},
"description": {"A small project with careful storage and daylight."},
"featured": {"on"},
}
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 || rec.Header().Get("Location") != "/admin/projects?ok=project+created" {
t.Fatalf("expected create redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
}
project, err := srv.store.ProjectBySlug(t.Context(), "garden-studio")
if err != nil {
t.Fatal(err)
}
if project.Summary != "A compact studio in a rear garden." || project.Scope != "Architecture, interiors" || project.Status != "In progress" || project.Position != 12 {
t.Fatalf("unexpected project depth fields: %+v", project)
}
}
func TestAdminServiceAndFAQMutations(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
serviceForm := url.Values{
"title": {"Planning advice"},
"summary": {"Early advice for planning routes."},
"details": {"Detailed planning route guidance."},
"position": {"8"},
"active": {"on"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/services", strings.NewReader(serviceForm.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") != "/admin/services?ok=service+created" {
t.Fatalf("expected service redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
}
faqForm := url.Values{
"question": {"Can you help before purchase?"},
"answer": {"Yes, early consultation can clarify feasibility."},
"position": {"9"},
"active": {"on"},
}
req = httptest.NewRequest(http.MethodPost, "/admin/faqs", strings.NewReader(faqForm.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") != "/admin/services?ok=FAQ+created" {
t.Fatalf("expected FAQ redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
}
services, err := srv.store.Services(t.Context(), false)
if err != nil {
t.Fatal(err)
}
faqs, err := srv.store.FAQs(t.Context(), false)
if err != nil {
t.Fatal(err)
}
if len(services) < 5 || len(faqs) < 4 {
t.Fatalf("expected created service and FAQ, got services=%d faqs=%d", len(services), len(faqs))
}
}

View File

@ -1,85 +0,0 @@
package app
import "net/http"
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) adminServices(w http.ResponseWriter, r *http.Request) {
data, err := s.adminData(r, "services")
if err != nil {
s.error(w, err)
return
}
s.renderAdmin(w, r, "admin_services.html", "admin_services_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) 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"), Version: s.cfg.Version}
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 == "services" {
services, err := s.store.Services(r.Context(), false)
if err != nil {
return pageData{}, err
}
faqs, err := s.store.FAQs(r.Context(), false)
if err != nil {
return pageData{}, err
}
data.Services = services
data.FAQs = faqs
}
if tab == "contact-details" {
contacts, err := s.store.ContactRequests(r.Context())
if err != nil {
return pageData{}, err
}
data.Contacts = contacts
}
return data, nil
}

View File

@ -1,69 +0,0 @@
package app
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
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/services", "Add service"},
{"/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>") || !strings.Contains(string(body), "Version test-version") {
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/services", 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 service") {
t.Fatalf("expected partial panel and out-of-band tab update: %s", text)
}
}

View File

@ -1,22 +0,0 @@
package app
import (
"encoding/json"
"net/http"
)
func (s *Server) healthz(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func (s *Server) readyz(w http.ResponseWriter, r *http.Request) {
dbHealthy := s.store.Ping(r.Context()) == nil
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(struct {
Version string `json:"version"`
DBHealthy bool `json:"db_healthy"`
}{
Version: s.cfg.Version,
DBHealthy: dbHealthy,
})
}

View File

@ -1,45 +0,0 @@
package app
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)
func TestHealthzReturnsOK(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
}
func TestReadyzReturnsVersionAndDBHealth(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/readyz", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
if got := rec.Header().Get("Content-Type"); got != "application/json; charset=utf-8" {
t.Fatalf("expected json content type, got %q", got)
}
var body struct {
Version string `json:"version"`
DBHealthy bool `json:"db_healthy"`
}
if err := json.NewDecoder(rec.Result().Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Version != "test-version" || !body.DBHealthy {
t.Fatalf("unexpected readyz body: %+v", body)
}
}

View File

@ -1,137 +0,0 @@
package app
import (
"net/http"
"strconv"
"strings"
"archi_folio/internal/store"
)
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.renderPublic(w, r, "home.html", "home_partial.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.renderPublic(w, r, "projects.html", "projects_partial.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.renderPublic(w, r, "project.html", "project_partial.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.renderPublic(w, r, "about.html", "about_partial.html", pageData{Title: "Studio", Active: "about", Content: content, CurrentPath: r.URL.Path})
}
func (s *Server) services(w http.ResponseWriter, r *http.Request) {
content, err := s.store.SiteContent(r.Context())
if err != nil {
s.error(w, err)
return
}
services, err := s.store.Services(r.Context(), true)
if err != nil {
s.error(w, err)
return
}
faqs, err := s.store.FAQs(r.Context(), true)
if err != nil {
s.error(w, err)
return
}
s.renderPublic(w, r, "services.html", "services_partial.html", pageData{Title: "Services", Active: "services", Content: content, Services: services, FAQs: faqs, CurrentPath: r.URL.Path})
}
func (s *Server) contactPage(w http.ResponseWriter, r *http.Request) {
content, err := s.store.SiteContent(r.Context())
if err != nil {
s.error(w, err)
return
}
s.renderPublic(w, r, "contact.html", "contact_partial.html", pageData{Title: "Contact", Active: "contact", 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
}
request := store.ContactRequest{
Name: strings.TrimSpace(r.FormValue("name")),
Email: strings.TrimSpace(r.FormValue("email")),
Phone: strings.TrimSpace(r.FormValue("phone")),
ProjectType: strings.TrimSpace(r.FormValue("project_type")),
ProjectLocation: strings.TrimSpace(r.FormValue("project_location")),
BudgetRange: strings.TrimSpace(r.FormValue("budget_range")),
Timeline: strings.TrimSpace(r.FormValue("timeline")),
Message: strings.TrimSpace(r.FormValue("message")),
Status: "new",
}
if err := validateContactRequest(request); err != nil {
s.render(w, "contact_result.html", pageData{Error: err.Error()})
return
}
if err := s.store.SaveContact(r.Context(), request); 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."})
}

View File

@ -1,219 +0,0 @@
package app
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"strings"
"testing"
)
func TestPublicRoutes(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
for _, path := range []string{"/", "/projects", "/about", "/services", "/contact", "/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 TestPublicRoutesRenderFullShellOnDirectLoad(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/services", 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)
text := string(body)
for _, want := range []string{"<!doctype html>", `class="drawer drawer-end"`, `id="main-content"`, `id="site-drawer"`} {
if !strings.Contains(text, want) {
t.Fatalf("direct public route missing %q: %s", want, text)
}
}
}
func TestPublicHTMXRoutesReturnMainContentPartial(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/services", nil)
req.Header.Set("HX-Request", "true")
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)
text := string(body)
if strings.Contains(text, "<!doctype html>") || strings.Contains(text, `class="drawer drawer-end"`) {
t.Fatalf("expected partial response, got full document: %s", text)
}
for _, want := range []string{`id="main-content"`, `id="site-header"`, `id="site-drawer-nav"`, `hx-swap-oob="true"`} {
if !strings.Contains(text, want) {
t.Fatalf("HTMX public route missing %q: %s", want, text)
}
}
}
func TestServicesRouteRendersServicesAndFAQs(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/services", 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)
text := string(body)
for _, want := range []string{"Residential architecture", "How projects work", "FAQs", "Start an enquiry"} {
if !strings.Contains(text, want) {
t.Fatalf("services page missing %q: %s", want, text)
}
}
}
func TestHomeRendersPhaseTwoSections(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", 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)
text := string(body)
for _, want := range []string{"Start an enquiry", "Focused support", "Process", "Studio profile"} {
if !strings.Contains(text, want) {
t.Fatalf("home missing %q: %s", want, text)
}
}
}
func TestStudioRendersExpandedContent(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/about", 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)
text := string(body)
for _, want := range []string{"Philosophy", "Approach", "Experience", "Service area"} {
if !strings.Contains(text, want) {
t.Fatalf("studio missing %q: %s", want, text)
}
}
}
func TestContactSubmissionPersists(t *testing.T) {
srv := newTestServer(t)
form := url.Values{
"name": {"Jane"},
"email": {"jane@example.com"},
"phone": {"123"},
"project_type": {"Renovation or extension"},
"project_location": {"London"},
"budget_range": {"GBP 250k-500k"},
"timeline": {"3-6 months"},
"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" || requests[0].ProjectType != "Renovation or extension" || requests[0].Status != "new" {
t.Fatalf("unexpected contact requests: %+v", requests)
}
}
func TestContactSubmissionRequiresQualificationFields(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), "project type is required") {
t.Fatalf("expected qualification validation message, got %s", body)
}
}
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 TestProjectDetailRendersDepthFields(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/projects/courtyard-house", 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)
text := string(body)
for _, want := range []string{"Scope", "Completed", "Architecture, interiors, material strategy", "A private house arranged around a quiet internal garden."} {
if !strings.Contains(text, want) {
t.Fatalf("project detail missing %q: %s", want, text)
}
}
}

View File

@ -1,11 +0,0 @@
package app
import "net/http"
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)
})
}

View File

@ -1,49 +0,0 @@
package app
import (
"bytes"
"net/http"
"net/url"
"strings"
)
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) renderPublic(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)
}

View File

@ -1,49 +0,0 @@
package app
import (
"net/http"
"path/filepath"
)
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 /healthz", s.healthz)
mux.HandleFunc("GET /readyz", s.readyz)
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("GET /services", s.services)
mux.HandleFunc("GET /contact", s.contactPage)
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/services", s.requireAdmin(http.HandlerFunc(s.adminServices)))
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/services", s.requireAdmin(http.HandlerFunc(s.adminCreateService)))
mux.Handle("POST /admin/services/{id}", s.requireAdmin(http.HandlerFunc(s.adminUpdateService)))
mux.Handle("POST /admin/services/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteService)))
mux.Handle("POST /admin/faqs", s.requireAdmin(http.HandlerFunc(s.adminCreateFAQ)))
mux.Handle("POST /admin/faqs/{id}", s.requireAdmin(http.HandlerFunc(s.adminUpdateFAQ)))
mux.Handle("POST /admin/faqs/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteFAQ)))
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)
}

View File

@ -1,26 +0,0 @@
package app
import (
"html/template"
"os"
"path/filepath"
"archi_folio/internal/store"
)
type Server struct {
cfg Config
store *store.Store
templates *template.Template
}
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
}

View File

@ -1,51 +0,0 @@
package app
import (
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"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"),
Version: "test-version",
}, st)
if err != nil {
t.Fatal(err)
}
return srv
}
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]
}

View File

@ -1,36 +0,0 @@
package app
import "archi_folio/internal/store"
const (
maxUploadBytes = 20 << 20
maxFormBytes = 24 << 20
)
type Config struct {
Addr string
DatabasePath string
SessionSecret string
AdminUsername string
AdminPassword string
UploadDir string
Version string
}
type pageData struct {
Title string
Active string
Content store.SiteContent
Projects []store.Project
Project store.Project
Image store.ProjectImage
Services []store.Service
FAQs []store.FAQ
Contacts []store.ContactRequest
Admin bool
AdminTab string
Error string
Success string
CurrentPath string
Version string
}

View File

@ -1,61 +0,0 @@
package app
import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
)
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()
}

View File

@ -1,53 +0,0 @@
package app
import (
"bytes"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
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)
}
}

View File

@ -1,168 +0,0 @@
package app
import (
"errors"
"strings"
"archi_folio/internal/store"
)
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.Positioning == "":
return errors.New("positioning is required")
case c.HeroCTALabel == "" || c.HeroCTAURL == "":
return errors.New("primary hero CTA is required")
case c.SecondaryCTALabel == "" || c.SecondaryCTAURL == "":
return errors.New("secondary hero CTA is required")
case c.IntroTitle == "":
return errors.New("intro title is required")
case c.IntroText == "":
return errors.New("intro text is required")
case c.ServiceOneTitle == "" || c.ServiceOneText == "" || c.ServiceTwoTitle == "" || c.ServiceTwoText == "" || c.ServiceThreeTitle == "" || c.ServiceThreeText == "":
return errors.New("three service preview items are required")
case c.ProcessOneTitle == "" || c.ProcessOneText == "" || c.ProcessTwoTitle == "" || c.ProcessTwoText == "" || c.ProcessThreeTitle == "" || c.ProcessThreeText == "":
return errors.New("three process steps are 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.StudioPhilosophy == "":
return errors.New("studio philosophy is required")
case c.StudioApproach == "":
return errors.New("studio approach is required")
case c.StudioCredentials == "":
return errors.New("studio credentials are required")
case c.ServiceArea == "":
return errors.New("service area 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 validateContactRequest(r store.ContactRequest) error {
switch {
case r.Name == "":
return errors.New("name is required")
case r.Email == "" || !strings.Contains(r.Email, "@"):
return errors.New("valid email is required")
case r.ProjectType == "":
return errors.New("project type is required")
case r.ProjectLocation == "":
return errors.New("project location is required")
case r.BudgetRange == "":
return errors.New("budget range is required")
case r.Timeline == "":
return errors.New("timeline is required")
case r.Message == "":
return errors.New("project message is required")
case len(r.Name) > 120:
return errors.New("name is too long")
case len(r.Email) > 180:
return errors.New("email is too long")
case len(r.Phone) > 80:
return errors.New("phone is too long")
case len(r.ProjectType) > 120:
return errors.New("project type is too long")
case len(r.ProjectLocation) > 180:
return errors.New("project location is too long")
case len(r.BudgetRange) > 120:
return errors.New("budget range is too long")
case len(r.Timeline) > 120:
return errors.New("timeline is too long")
case len(r.Message) > 3000:
return errors.New("project message is too long")
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.Summary == "":
return errors.New("project summary is required")
case p.Scope == "":
return errors.New("project scope is required")
case p.Status == "":
return errors.New("project status is required")
case p.Description == "":
return errors.New("project description is required")
case p.CoverImage == "":
return errors.New("project cover image is required")
case len(p.Summary) > 500:
return errors.New("project summary is too long")
case len(p.Scope) > 240:
return errors.New("project scope is too long")
case len(p.Status) > 80:
return errors.New("project status is too long")
default:
return nil
}
}
func validateService(service store.Service) error {
switch {
case service.Title == "":
return errors.New("service title is required")
case service.Summary == "":
return errors.New("service summary is required")
case service.Details == "":
return errors.New("service details are required")
case len(service.Title) > 160:
return errors.New("service title is too long")
case len(service.Summary) > 500:
return errors.New("service summary is too long")
case len(service.Details) > 2000:
return errors.New("service details are too long")
default:
return nil
}
}
func validateFAQ(faq store.FAQ) error {
switch {
case faq.Question == "":
return errors.New("FAQ question is required")
case faq.Answer == "":
return errors.New("FAQ answer is required")
case len(faq.Question) > 240:
return errors.New("FAQ question is too long")
case len(faq.Answer) > 2000:
return errors.New("FAQ answer is too long")
default:
return nil
}
}

View File

@ -1,29 +0,0 @@
package store
import (
"context"
"time"
)
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
}

View File

@ -1,45 +0,0 @@
package store
import "context"
func (s *Store) SaveContact(ctx context.Context, request ContactRequest) error {
_, err := s.db.ExecContext(ctx, `insert into contact_requests (
name, email, phone, project_type, project_location, budget_range, timeline, message, status, notes
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
request.Name,
request.Email,
request.Phone,
request.ProjectType,
request.ProjectLocation,
request.BudgetRange,
request.Timeline,
request.Message,
coalesceString(request.Status, "new"),
request.Notes,
)
return err
}
func (s *Store) ContactRequests(ctx context.Context) ([]ContactRequest, error) {
rows, err := s.db.QueryContext(ctx, `select id, name, email, phone, project_type, project_location, budget_range, timeline, message, status, notes, 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.Phone, &r.ProjectType, &r.ProjectLocation, &r.BudgetRange, &r.Timeline, &r.Message, &r.Status, &r.Notes, &r.CreatedAt); err != nil {
return nil, err
}
requests = append(requests, r)
}
return requests, rows.Err()
}
func coalesceString(value, fallback string) string {
if value == "" {
return fallback
}
return value
}

View File

@ -1,45 +0,0 @@
package store
import (
"database/sql"
"errors"
"fmt"
"time"
)
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)
}

View File

@ -1,332 +0,0 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"golang.org/x/crypto/bcrypt"
)
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,
positioning text not null default '',
hero_cta_label text not null default '',
hero_cta_url text not null default '',
secondary_cta_label text not null default '',
secondary_cta_url text not null default '',
intro_title text not null,
intro_text text not null,
service_one_title text not null default '',
service_one_text text not null default '',
service_two_title text not null default '',
service_two_text text not null default '',
service_three_title text not null default '',
service_three_text text not null default '',
process_one_title text not null default '',
process_one_text text not null default '',
process_two_title text not null default '',
process_two_text text not null default '',
process_three_title text not null default '',
process_three_text text not null default '',
about_name text not null,
about_role text not null,
about_bio text not null,
studio_philosophy text not null default '',
studio_approach text not null default '',
studio_credentials text not null default '',
service_area text not null default '',
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,
summary text not null default '',
scope text not null default '',
status text not null default '',
position integer not null default 0,
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 services (
id integer primary key autoincrement,
title text not null,
summary text not null,
details text not null,
position integer not null default 0,
active integer not null default 1,
created_at datetime not null default current_timestamp
)`,
`create table if not exists faqs (
id integer primary key autoincrement,
question text not null,
answer text not null,
position integer not null default 0,
active integer not null default 1,
created_at datetime not null default current_timestamp
)`,
`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
}
}
siteColumns := map[string]string{
"positioning": "text not null default 'Residential architecture and interiors in London'",
"hero_cta_label": "text not null default 'Start an enquiry'",
"hero_cta_url": "text not null default '/contact'",
"secondary_cta_label": "text not null default 'View projects'",
"secondary_cta_url": "text not null default '/projects'",
"service_one_title": "text not null default 'Residential architecture'",
"service_one_text": "text not null default 'Carefully planned homes, extensions, and spatial changes shaped around daily life.'",
"service_two_title": "text not null default 'Interior architecture'",
"service_two_text": "text not null default 'Layouts, materials, storage, lighting, and built-in elements considered as one whole.'",
"service_three_title": "text not null default 'Early consultation'",
"service_three_text": "text not null default 'Focused advice for feasibility, priorities, budgets, and the next practical steps.'",
"process_one_title": "text not null default 'Listen'",
"process_one_text": "text not null default 'Clarify the site, constraints, ambitions, and what the project needs to solve.'",
"process_two_title": "text not null default 'Shape'",
"process_two_text": "text not null default 'Develop a spatial direction through sketches, references, plans, and material thinking.'",
"process_three_title": "text not null default 'Refine'",
"process_three_text": "text not null default 'Coordinate details, decisions, and documentation so the work can move forward clearly.'",
"studio_philosophy": "text not null default 'The studio favours calm, durable spaces where proportion, daylight, materials, and storage do practical work without visual noise.'",
"studio_approach": "text not null default 'Projects begin with listening and careful briefing, then move through measured options, clear priorities, and detailed decisions at a pace suited to the client and site.'",
"studio_credentials": "text not null default 'Independent architecture and interior design practice working across residential projects, renovations, and compact cultural spaces.'",
"service_area": "text not null default 'London and selected UK projects'",
}
for column, definition := range siteColumns {
if err := s.ensureColumn("site_content", column, definition); err != nil {
return err
}
}
projectColumns := map[string]string{
"summary": "text not null default ''",
"scope": "text not null default ''",
"status": "text not null default 'Completed'",
"position": "integer not null default 0",
}
for column, definition := range projectColumns {
if err := s.ensureColumn("projects", column, definition); err != nil {
return err
}
}
if err := s.ensureColumn("contact_requests", "phone", "text not null default ''"); err != nil {
return err
}
if err := s.ensureColumn("contact_requests", "project_type", "text not null default ''"); err != nil {
return err
}
if err := s.ensureColumn("contact_requests", "project_location", "text not null default ''"); err != nil {
return err
}
if err := s.ensureColumn("contact_requests", "budget_range", "text not null default ''"); err != nil {
return err
}
if err := s.ensureColumn("contact_requests", "timeline", "text not null default ''"); err != nil {
return err
}
if err := s.ensureColumn("contact_requests", "status", "text not null default 'new'"); err != nil {
return err
}
if err := s.ensureColumn("contact_requests", "notes", "text not null default ''"); err != nil {
return err
}
return s.seed(adminUsername, adminPassword)
}
func (s *Store) ensureColumn(table, column, definition string) error {
rows, err := s.db.Query(fmt.Sprintf("pragma table_info(%s)", table))
if err != nil {
return err
}
defer rows.Close()
for rows.Next() {
var cid int
var name, typ string
var notNull int
var defaultValue sql.NullString
var pk int
if err := rows.Scan(&cid, &name, &typ, &notNull, &defaultValue, &pk); err != nil {
return err
}
if name == column {
return nil
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = s.db.Exec(fmt.Sprintf("alter table %s add column %s %s", table, column, definition))
return err
}
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, positioning, hero_cta_label, hero_cta_url, secondary_cta_label, secondary_cta_url,
intro_title, intro_text,
service_one_title, service_one_text, service_two_title, service_two_text, service_three_title, service_three_text,
process_one_title, process_one_text, process_two_title, process_two_text, process_three_title, process_three_text,
about_name, about_role, about_bio, studio_philosophy, studio_approach, studio_credentials, service_area,
email, phone, location, hero_image, about_image
) values (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
"Archi Folio",
"Spatial design, architecture, and interiors shaped through quiet detail.",
"Residential architecture and interiors in London",
"Start an enquiry",
"/contact",
"View projects",
"/projects",
"Selected residential and cultural spaces",
"A compact portfolio for showing image-led architectural work, interior concepts, and project narratives.",
"Residential architecture",
"Carefully planned homes, extensions, and spatial changes shaped around daily life.",
"Interior architecture",
"Layouts, materials, storage, lighting, and built-in elements considered as one whole.",
"Early consultation",
"Focused advice for feasibility, priorities, budgets, and the next practical steps.",
"Listen",
"Clarify the site, constraints, ambitions, and what the project needs to solve.",
"Shape",
"Develop a spatial direction through sketches, references, plans, and material thinking.",
"Refine",
"Coordinate details, decisions, and documentation so the work can move forward clearly.",
"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.",
"The studio favours calm, durable spaces where proportion, daylight, materials, and storage do practical work without visual noise.",
"Projects begin with listening and careful briefing, then move through measured options, clear priorities, and detailed decisions at a pace suited to the client and site.",
"Independent architecture and interior design practice working across residential projects, renovations, and compact cultural spaces.",
"London and selected UK projects",
"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", Summary: "A private house arranged around a quiet internal garden.", Scope: "Architecture, interiors, material strategy", Status: "Completed", Position: 1, 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", Summary: "A compact apartment refit with integrated storage and a flexible work area.", Scope: "Interior architecture, joinery, lighting", Status: "Completed", Position: 2, 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", Summary: "A small exhibition environment for sculpture, events, and shifting light.", Scope: "Spatial design, exhibition planning", Status: "Concept", Position: 3, 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
}
}
}
}
if err := s.db.QueryRow(`select count(*) from services`).Scan(&count); err != nil {
return err
}
if count == 0 {
services := []Service{
{Title: "Residential architecture", Summary: "New homes, extensions, and spatial reconfiguration for private clients.", Details: "Suitable for homeowners who need a clear architectural direction, measured priorities, planning support, and coordinated design decisions from early brief through detailed development.", Position: 1, Active: true},
{Title: "Renovation and extensions", Summary: "Careful upgrades to existing homes where light, storage, and flow need to work harder.", Details: "Useful for period properties, compact urban homes, and phased refurbishments where the existing building needs to be understood before design moves are made.", Position: 2, Active: true},
{Title: "Interior architecture", Summary: "Layouts, joinery, materials, lighting, and finishes developed as one spatial system.", Details: "For clients who need the interior to feel resolved rather than decorated, with attention to proportion, thresholds, storage, and daily use.", Position: 3, Active: true},
{Title: "Early-stage consultation", Summary: "Focused advice before committing to a larger scope of work.", Details: "A practical starting point for feasibility, budget alignment, project priorities, or deciding whether a property or idea has the right potential.", Position: 4, Active: true},
}
for _, service := range services {
if err := s.CreateService(context.Background(), service); err != nil {
return err
}
}
}
if err := s.db.QueryRow(`select count(*) from faqs`).Scan(&count); err != nil {
return err
}
if count == 0 {
faqs := []FAQ{
{Question: "What size projects are a good fit?", Answer: "The studio is best suited to residential projects, renovations, compact interiors, and early-stage design work where careful spatial thinking is valued.", Position: 1, Active: true},
{Question: "Where does the studio work?", Answer: "The studio is based in London and considers selected projects across the UK depending on scope, timing, and site needs.", Position: 2, Active: true},
{Question: "Can I book a consultation before a full project?", Answer: "Yes. Early consultation is useful for clarifying feasibility, budget, priorities, and whether a larger design process is appropriate.", Position: 3, Active: true},
}
for _, faq := range faqs {
if err := s.CreateFAQ(context.Background(), faq); 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
}

View File

@ -1,126 +0,0 @@
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)
}
}
func TestContactRequestsSupportQualificationFields(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", "password"); err != nil {
t.Fatal(err)
}
err = st.SaveContact(t.Context(), ContactRequest{
Name: "Jane",
Email: "jane@example.com",
Phone: "123",
ProjectType: "Renovation",
ProjectLocation: "London",
BudgetRange: "GBP 250k-500k",
Timeline: "3-6 months",
Message: "Project notes",
})
if err != nil {
t.Fatal(err)
}
requests, err := st.ContactRequests(t.Context())
if err != nil {
t.Fatal(err)
}
if len(requests) != 1 || requests[0].ProjectLocation != "London" || requests[0].Status != "new" {
t.Fatalf("unexpected request fields: %+v", requests)
}
}
func TestSiteContentIncludesPhaseTwoFields(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", "password"); err != nil {
t.Fatal(err)
}
content, err := st.SiteContent(t.Context())
if err != nil {
t.Fatal(err)
}
if content.Positioning == "" || content.HeroCTALabel == "" || content.ServiceOneTitle == "" || content.StudioPhilosophy == "" {
t.Fatalf("expected seeded phase two content, got %+v", content)
}
}
func TestServicesAndFAQsAreSeeded(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", "password"); err != nil {
t.Fatal(err)
}
services, err := st.Services(t.Context(), true)
if err != nil {
t.Fatal(err)
}
faqs, err := st.FAQs(t.Context(), true)
if err != nil {
t.Fatal(err)
}
if len(services) < 4 || len(faqs) < 3 {
t.Fatalf("expected seeded services and FAQs, got services=%d faqs=%d", len(services), len(faqs))
}
}
func TestSeededProjectsIncludeDepthFields(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", "password"); err != nil {
t.Fatal(err)
}
project, err := st.ProjectBySlug(t.Context(), "courtyard-house")
if err != nil {
t.Fatal(err)
}
if project.Summary == "" || project.Scope == "" || project.Status == "" || project.Position == 0 {
t.Fatalf("expected project depth fields, got %+v", project)
}
}

View File

@ -1,122 +0,0 @@
package store
import (
"context"
"database/sql"
)
func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, error) {
query := `select id, slug, title, location, year, category, summary, scope, status, position, description, cover_image, featured, created_at from projects`
if featuredOnly {
query += ` where featured = 1`
}
query += ` order by position asc, 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.Summary, &p.Scope, &p.Status, &p.Position, &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, summary, scope, status, position, 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.Summary, &p.Scope, &p.Status, &p.Position, &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, summary, scope, status, position, description, cover_image, featured) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Slug, p.Title, p.Location, p.Year, p.Category, p.Summary, p.Scope, p.Status, p.Position, 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=?, summary=?, scope=?, status=?, position=?, description=?, cover_image=?, featured=? where id=?`,
p.Slug, p.Title, p.Location, p.Year, p.Category, p.Summary, p.Scope, p.Status, p.Position, 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
}

View File

@ -1,85 +0,0 @@
package store
import "context"
func (s *Store) Services(ctx context.Context, activeOnly bool) ([]Service, error) {
query := `select id, title, summary, details, position, active, created_at from services`
if activeOnly {
query += ` where active = 1`
}
query += ` order by position asc, id asc`
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var services []Service
for rows.Next() {
var service Service
var active int
if err := rows.Scan(&service.ID, &service.Title, &service.Summary, &service.Details, &service.Position, &active, &service.CreatedAt); err != nil {
return nil, err
}
service.Active = active == 1
services = append(services, service)
}
return services, rows.Err()
}
func (s *Store) CreateService(ctx context.Context, service Service) error {
_, err := s.db.ExecContext(ctx, `insert into services (title, summary, details, position, active) values (?, ?, ?, ?, ?)`,
service.Title, service.Summary, service.Details, service.Position, boolInt(service.Active))
return err
}
func (s *Store) UpdateService(ctx context.Context, service Service) error {
_, err := s.db.ExecContext(ctx, `update services set title=?, summary=?, details=?, position=?, active=? where id=?`,
service.Title, service.Summary, service.Details, service.Position, boolInt(service.Active), service.ID)
return err
}
func (s *Store) DeleteService(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `delete from services where id = ?`, id)
return err
}
func (s *Store) FAQs(ctx context.Context, activeOnly bool) ([]FAQ, error) {
query := `select id, question, answer, position, active, created_at from faqs`
if activeOnly {
query += ` where active = 1`
}
query += ` order by position asc, id asc`
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
var faqs []FAQ
for rows.Next() {
var faq FAQ
var active int
if err := rows.Scan(&faq.ID, &faq.Question, &faq.Answer, &faq.Position, &active, &faq.CreatedAt); err != nil {
return nil, err
}
faq.Active = active == 1
faqs = append(faqs, faq)
}
return faqs, rows.Err()
}
func (s *Store) CreateFAQ(ctx context.Context, faq FAQ) error {
_, err := s.db.ExecContext(ctx, `insert into faqs (question, answer, position, active) values (?, ?, ?, ?)`,
faq.Question, faq.Answer, faq.Position, boolInt(faq.Active))
return err
}
func (s *Store) UpdateFAQ(ctx context.Context, faq FAQ) error {
_, err := s.db.ExecContext(ctx, `update faqs set question=?, answer=?, position=?, active=? where id=?`,
faq.Question, faq.Answer, faq.Position, boolInt(faq.Active), faq.ID)
return err
}
func (s *Store) DeleteFAQ(ctx context.Context, id int64) error {
_, err := s.db.ExecContext(ctx, `delete from faqs where id = ?`, id)
return err
}

View File

@ -1,42 +0,0 @@
package store
import "context"
func (s *Store) SiteContent(ctx context.Context) (SiteContent, error) {
var c SiteContent
err := s.db.QueryRowContext(ctx, `select
hero_title, hero_subtitle, positioning, hero_cta_label, hero_cta_url, secondary_cta_label, secondary_cta_url,
intro_title, intro_text,
service_one_title, service_one_text, service_two_title, service_two_text, service_three_title, service_three_text,
process_one_title, process_one_text, process_two_title, process_two_text, process_three_title, process_three_text,
about_name, about_role, about_bio, studio_philosophy, studio_approach, studio_credentials, service_area,
email, phone, location, hero_image, about_image
from site_content where id = 1`).
Scan(
&c.HeroTitle, &c.HeroSubtitle, &c.Positioning, &c.HeroCTALabel, &c.HeroCTAURL, &c.SecondaryCTALabel, &c.SecondaryCTAURL,
&c.IntroTitle, &c.IntroText,
&c.ServiceOneTitle, &c.ServiceOneText, &c.ServiceTwoTitle, &c.ServiceTwoText, &c.ServiceThreeTitle, &c.ServiceThreeText,
&c.ProcessOneTitle, &c.ProcessOneText, &c.ProcessTwoTitle, &c.ProcessTwoText, &c.ProcessThreeTitle, &c.ProcessThreeText,
&c.AboutName, &c.AboutRole, &c.AboutBio, &c.StudioPhilosophy, &c.StudioApproach, &c.StudioCredentials, &c.ServiceArea,
&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=?, positioning=?, hero_cta_label=?, hero_cta_url=?, secondary_cta_label=?, secondary_cta_url=?,
intro_title=?, intro_text=?,
service_one_title=?, service_one_text=?, service_two_title=?, service_two_text=?, service_three_title=?, service_three_text=?,
process_one_title=?, process_one_text=?, process_two_title=?, process_two_text=?, process_three_title=?, process_three_text=?,
about_name=?, about_role=?, about_bio=?, studio_philosophy=?, studio_approach=?, studio_credentials=?, service_area=?,
email=?, phone=?, location=?, hero_image=?, about_image=?
where id=1`,
c.HeroTitle, c.HeroSubtitle, c.Positioning, c.HeroCTALabel, c.HeroCTAURL, c.SecondaryCTALabel, c.SecondaryCTAURL,
c.IntroTitle, c.IntroText,
c.ServiceOneTitle, c.ServiceOneText, c.ServiceTwoTitle, c.ServiceTwoText, c.ServiceThreeTitle, c.ServiceThreeText,
c.ProcessOneTitle, c.ProcessOneText, c.ProcessTwoTitle, c.ProcessTwoText, c.ProcessThreeTitle, c.ProcessThreeText,
c.AboutName, c.AboutRole, c.AboutBio, c.StudioPhilosophy, c.StudioApproach, c.StudioCredentials, c.ServiceArea,
c.Email, c.Phone, c.Location, c.HeroImage, c.AboutImage)
return err
}

View File

@ -3,11 +3,14 @@ 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 {
@ -15,39 +18,18 @@ type Store struct {
}
type SiteContent struct {
HeroTitle string
HeroSubtitle string
Positioning string
HeroCTALabel string
HeroCTAURL string
SecondaryCTALabel string
SecondaryCTAURL string
IntroTitle string
IntroText string
ServiceOneTitle string
ServiceOneText string
ServiceTwoTitle string
ServiceTwoText string
ServiceThreeTitle string
ServiceThreeText string
ProcessOneTitle string
ProcessOneText string
ProcessTwoTitle string
ProcessTwoText string
ProcessThreeTitle string
ProcessThreeText string
AboutName string
AboutRole string
AboutBio string
StudioPhilosophy string
StudioApproach string
StudioCredentials string
ServiceArea string
Email string
Phone string
Location string
HeroImage string
AboutImage string
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 {
@ -57,10 +39,6 @@ type Project struct {
Location string
Year string
Category string
Summary string
Scope string
Status string
Position int
Description string
CoverImage string
Featured bool
@ -76,38 +54,12 @@ type ProjectImage struct {
Position int
}
type Service struct {
ID int64
Title string
Summary string
Details string
Position int
Active bool
CreatedAt time.Time
}
type FAQ struct {
ID int64
Question string
Answer string
Position int
Active bool
CreatedAt time.Time
}
type ContactRequest struct {
ID int64
Name string
Email string
Phone string
ProjectType string
ProjectLocation string
BudgetRange string
Timeline string
Message string
Status string
Notes string
CreatedAt time.Time
ID int64
Name string
Email string
Message string
CreatedAt time.Time
}
type AdminUser struct {
@ -132,6 +84,349 @@ func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) Ping(ctx context.Context) error {
return s.db.PingContext(ctx)
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)
}

View 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)
}
}

View File

@ -8,31 +8,6 @@
updateHeader();
window.addEventListener("scroll", updateHeader, { passive: true });
function currentTheme() {
try {
const saved = localStorage.getItem("archi-theme");
if (saved === "dark" || saved === "light") return saved;
} catch (_) {}
return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
}
function setTheme(theme) {
const normalized = theme === "dark" ? "dark" : "light";
document.documentElement.setAttribute("data-theme", normalized);
document.querySelectorAll("[data-theme-toggle]").forEach(function (input) {
input.checked = normalized === "dark";
});
try {
localStorage.setItem("archi-theme", normalized);
} catch (_) {}
}
function syncThemeControls() {
setTheme(currentTheme());
}
syncThemeControls();
function closeOverlay() {
const root = document.getElementById("overlay-root");
if (root) root.innerHTML = "";
@ -50,29 +25,15 @@
});
document.addEventListener("htmx:afterSettle", updateHeader);
document.addEventListener("htmx:afterSettle", syncThemeControls);
document.addEventListener("click", function (event) {
if (event.target.closest("[data-drawer-link]")) {
const drawer = document.getElementById("site-drawer");
if (drawer) drawer.checked = false;
}
if (event.target.matches("[data-overlay], [data-overlay-close]")) {
closeOverlay();
}
});
document.addEventListener("change", function (event) {
const toggle = event.target.closest("[data-theme-toggle]");
if (toggle) {
setTheme(toggle.checked ? "dark" : "light");
}
});
document.addEventListener("keydown", function (event) {
if (event.key === "Escape") {
const drawer = document.getElementById("site-drawer");
if (drawer) drawer.checked = false;
closeOverlay();
}
});

View File

@ -6,72 +6,6 @@ body {
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
html[data-theme="dark"] body,
html[data-theme="dark"] .drawer-content {
background: #0f0f0e;
color: #f5f5f4;
}
html[data-theme="dark"] .bg-neutral-50 {
background-color: #0f0f0e !important;
}
html[data-theme="dark"] .bg-white {
background-color: #171716 !important;
}
html[data-theme="dark"] .bg-neutral-950 {
background-color: #050505 !important;
}
html[data-theme="dark"] .bg-neutral-200 {
background-color: #2a2926 !important;
}
html[data-theme="dark"] .text-neutral-950,
html[data-theme="dark"] .text-neutral-800 {
color: #f5f5f4 !important;
}
html[data-theme="dark"] .text-neutral-700,
html[data-theme="dark"] .text-neutral-600,
html[data-theme="dark"] .text-neutral-500 {
color: #c7c2b8 !important;
}
html[data-theme="dark"] .text-neutral-400 {
color: #a8a29a !important;
}
html[data-theme="dark"] .border-neutral-300,
html[data-theme="dark"] .border-neutral-200 {
border-color: rgba(245, 245, 244, 0.16) !important;
}
html[data-theme="dark"] input,
html[data-theme="dark"] textarea,
html[data-theme="dark"] select {
background-color: #111110;
border-color: rgba(245, 245, 244, 0.22);
color: #f5f5f4;
}
html[data-theme="dark"] input::placeholder,
html[data-theme="dark"] textarea::placeholder {
color: #78716c;
}
.form-select {
appearance: none;
border-radius: 0;
min-height: 50px;
background-image: linear-gradient(45deg, transparent 50%, #525252 50%), linear-gradient(135deg, #525252 50%, transparent 50%);
background-position: calc(100% - 18px) 21px, calc(100% - 12px) 21px;
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
padding-right: 2.75rem;
}
[data-site-header].is-compact {
padding-top: 0.85rem;
padding-bottom: 0.85rem;
@ -85,32 +19,6 @@ html[data-theme="dark"] textarea::placeholder {
font-size: 1.25rem;
}
html[data-theme="dark"] [data-site-header] {
background: rgba(15, 15, 14, 0.95) !important;
color: #f5f5f4 !important;
box-shadow: 0 1px 0 rgba(245, 245, 244, 0.12);
backdrop-filter: blur(18px);
}
html[data-theme="dark"] [data-site-header].is-compact {
background: rgba(15, 15, 14, 0.95) !important;
color: #f5f5f4 !important;
box-shadow: 0 1px 0 rgba(245, 245, 244, 0.12);
}
#main-content {
view-transition-name: main-content;
}
.drawer-side > aside {
transition-duration: 260ms;
transition-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
}
.drawer-overlay {
transition-duration: 200ms;
}
@keyframes page-fade-out {
from {
opacity: 1;
@ -171,14 +79,6 @@ html[data-theme="dark"] [data-site-header].is-compact {
animation: 180ms ease both panel-fade-in;
}
::view-transition-old(main-content) {
animation: 150ms ease both panel-fade-out;
}
::view-transition-new(main-content) {
animation: 210ms ease both panel-fade-in;
}
@media (prefers-reduced-motion: reduce) {
*,
::before,
@ -188,8 +88,6 @@ html[data-theme="dark"] [data-site-header].is-compact {
::view-transition-old(root),
::view-transition-new(root),
::view-transition-old(main-content),
::view-transition-new(main-content),
::view-transition-old(admin-panel),
::view-transition-new(admin-panel) {
animation-duration: 1ms;

View File

@ -1,79 +1,34 @@
{{define "about.html"}}
{{template "public_shell_start" .}}
{{template "about_content" .}}
{{template "public_shell_end" .}}
{{end}}
{{define "about_partial.html"}}
{{template "public_nav_oob" .}}
{{template "about_content" .}}
{{end}}
{{define "about_content"}}
<main id="main-content" class="mx-auto max-w-7xl px-5 pb-24 pt-32 md:px-8 md:pt-40">
{{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">Studio</p>
<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-3 text-xl text-neutral-500">{{.Content.AboutRole}}</p>
<p class="mt-8 max-w-2xl text-xl leading-relaxed text-neutral-600">{{.Content.AboutBio}}</p>
<div class="mt-8 grid gap-4 border-t border-neutral-200 pt-6 text-sm text-neutral-600 md:grid-cols-3">
<p><span class="block text-neutral-400">Base</span>{{.Content.Location}}</p>
<p><span class="block text-neutral-400">Service area</span>{{.Content.ServiceArea}}</p>
<p><span class="block text-neutral-400">Contact</span>{{.Content.Email}}</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 grid gap-10 border-t border-neutral-200 pt-12 md:grid-cols-[0.75fr_1.25fr]">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">Philosophy</p>
<h2 class="text-3xl font-semibold md:text-5xl">Quiet spaces shaped around real use</h2>
</div>
<div class="grid gap-8">
<p class="max-w-3xl text-xl leading-relaxed text-neutral-600">{{.Content.StudioPhilosophy}}</p>
<div class="grid gap-6 md:grid-cols-2">
<article class="border-t border-neutral-300 pt-5">
<h3 class="mb-3 text-xl font-medium">Approach</h3>
<p class="leading-relaxed text-neutral-600">{{.Content.StudioApproach}}</p>
</article>
<article class="border-t border-neutral-300 pt-5">
<h3 class="mb-3 text-xl font-medium">Experience</h3>
<p class="leading-relaxed text-neutral-600">{{.Content.StudioCredentials}}</p>
</article>
</div>
</div>
</section>
<section class="mt-20 grid gap-8 bg-neutral-950 px-5 py-12 text-white md:grid-cols-3 md:px-8">
<article>
<p class="mb-4 text-sm text-white/45">01</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessOneTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessOneText}}</p>
</article>
<article>
<p class="mb-4 text-sm text-white/45">02</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessTwoTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessTwoText}}</p>
</article>
<article>
<p class="mb-4 text-sm text-white/45">03</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessThreeTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessThreeText}}</p>
</article>
</section>
<section class="mt-20 border-t border-neutral-200 pt-10">
<div class="grid gap-6 md:grid-cols-[0.8fr_1.2fr] md:items-end">
<h2 class="text-3xl font-semibold">Discuss a project</h2>
<div class="max-w-2xl">
<p class="text-lg leading-relaxed text-neutral-600">Share the project type, location, budget range, and timeline so the studio can assess whether the work is a good fit.</p>
<a href="/contact" class="mt-6 inline-flex bg-neutral-950 px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-white hover:bg-neutral-700" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">Start an enquiry</a>
</div>
</div>
<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>
{{end}}
{{template "footer" .}}
<div id="overlay-root"></div>
</body>
</html>

View File

@ -33,7 +33,6 @@
<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/services" hx-get="/admin/services" hx-target="#admin-panel" hx-push-url="true" hx-swap="innerHTML transition:true" class="whitespace-nowrap px-3 py-2 {{if eq .AdminTab "services"}}bg-neutral-950 text-white{{else}}text-neutral-500 hover:bg-neutral-100 hover:text-neutral-950{{end}}">Services</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}}

View File

@ -28,15 +28,9 @@
{{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}}{{if .Phone}} · {{.Phone}}{{end}}</p>
<p class="text-neutral-500">{{.Status}} · {{.CreatedAt.Format "2006-01-02 15:04"}}</p>
<p class="font-medium">{{.Name}} · {{.Email}}</p>
<p class="text-neutral-500">{{.CreatedAt.Format "2006-01-02 15:04"}}</p>
</div>
<dl class="mb-3 grid gap-2 text-sm text-neutral-600 md:grid-cols-4">
<div><dt class="text-neutral-400">Type</dt><dd>{{.ProjectType}}</dd></div>
<div><dt class="text-neutral-400">Location</dt><dd>{{.ProjectLocation}}</dd></div>
<div><dt class="text-neutral-400">Budget</dt><dd>{{.BudgetRange}}</dd></div>
<div><dt class="text-neutral-400">Timeline</dt><dd>{{.Timeline}}</dd></div>
</dl>
<p class="text-neutral-700">{{.Message}}</p>
</article>
{{else}}

View File

@ -16,53 +16,15 @@
<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}}">
<h2 class="text-lg font-semibold">Home Hero</h2>
<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">Positioning</span><input name="positioning" value="{{.Content.Positioning}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Primary CTA label</span><input name="hero_cta_label" value="{{.Content.HeroCTALabel}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Primary CTA URL</span><input name="hero_cta_url" value="{{.Content.HeroCTAURL}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Secondary CTA label</span><input name="secondary_cta_label" value="{{.Content.SecondaryCTALabel}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Secondary CTA URL</span><input name="secondary_cta_url" value="{{.Content.SecondaryCTAURL}}" 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>
<h2 class="border-t border-neutral-200 pt-5 text-lg font-semibold">Service Preview</h2>
<div class="grid gap-4 md:grid-cols-3">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 1 title</span><input name="service_one_title" value="{{.Content.ServiceOneTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 2 title</span><input name="service_two_title" value="{{.Content.ServiceTwoTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 3 title</span><input name="service_three_title" value="{{.Content.ServiceThreeTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 1 text</span><textarea name="service_one_text" rows="4" class="w-full border px-3 py-2">{{.Content.ServiceOneText}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 2 text</span><textarea name="service_two_text" rows="4" class="w-full border px-3 py-2">{{.Content.ServiceTwoText}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 3 text</span><textarea name="service_three_text" rows="4" class="w-full border px-3 py-2">{{.Content.ServiceThreeText}}</textarea></label>
</div>
<h2 class="border-t border-neutral-200 pt-5 text-lg font-semibold">Process Preview</h2>
<div class="grid gap-4 md:grid-cols-3">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 1 title</span><input name="process_one_title" value="{{.Content.ProcessOneTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 2 title</span><input name="process_two_title" value="{{.Content.ProcessTwoTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 3 title</span><input name="process_three_title" value="{{.Content.ProcessThreeTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 1 text</span><textarea name="process_one_text" rows="4" class="w-full border px-3 py-2">{{.Content.ProcessOneText}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 2 text</span><textarea name="process_two_text" rows="4" class="w-full border px-3 py-2">{{.Content.ProcessTwoText}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 3 text</span><textarea name="process_three_text" rows="4" class="w-full border px-3 py-2">{{.Content.ProcessThreeText}}</textarea></label>
</div>
<h2 class="border-t border-neutral-200 pt-5 text-lg font-semibold">Studio</h2>
<div class="grid gap-4 md:grid-cols-3">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Studio 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">Role</span><input name="about_role" value="{{.Content.AboutRole}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service area</span><input name="service_area" value="{{.Content.ServiceArea}}" class="w-full border px-3 py-2"></label>
</div>
<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-3">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Studio philosophy</span><textarea name="studio_philosophy" rows="5" class="w-full border px-3 py-2">{{.Content.StudioPhilosophy}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Studio approach</span><textarea name="studio_approach" rows="5" class="w-full border px-3 py-2">{{.Content.StudioApproach}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Credentials / experience</span><textarea name="studio_credentials" rows="5" class="w-full border px-3 py-2">{{.Content.StudioCredentials}}</textarea></label>
</div>
<h2 class="border-t border-neutral-200 pt-5 text-lg font-semibold">Images</h2>
<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>

View File

@ -20,11 +20,7 @@
<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">
<input name="scope" required placeholder="Scope" class="border px-3 py-2">
<input name="status" required placeholder="Status" value="Completed" class="border px-3 py-2">
<input name="position" type="number" value="0" 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="summary" required rows="3" placeholder="Short card summary" class="border px-3 py-2 md:col-span-2"></textarea>
<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>
@ -42,11 +38,7 @@
<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">
<input name="scope" value="{{.Scope}}" class="border px-3 py-2">
<input name="status" value="{{.Status}}" class="border px-3 py-2">
<input name="position" type="number" value="{{.Position}}" 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="summary" rows="3" class="border px-3 py-2 md:col-span-2">{{.Summary}}</textarea>
<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">

View File

@ -1,73 +0,0 @@
{{define "admin_services.html"}}
{{template "admin_shell_start" .}}
{{template "admin_services_panel" .}}
{{template "admin_shell_end" .}}
{{end}}
{{define "admin_services_partial.html"}}
{{template "admin_tabs_oob" .}}
{{template "admin_flash_oob" .}}
{{template "admin_services_panel" .}}
{{end}}
{{define "admin_services_panel"}}
<section class="grid gap-6">
<section class="bg-white p-6 shadow-sm">
<h1 class="mb-6 text-2xl font-semibold">Services</h1>
<form method="post" action="/admin/services" class="grid gap-4 md:grid-cols-[1fr_1fr_120px_120px]">
<input name="title" required placeholder="Title" class="border px-3 py-2">
<input name="summary" required placeholder="Summary" class="border px-3 py-2">
<input name="position" type="number" value="0" class="border px-3 py-2">
<label class="flex items-center gap-2 border px-3 py-2 text-sm"><input name="active" type="checkbox" checked> Active</label>
<textarea name="details" required rows="4" placeholder="Details" class="border px-3 py-2 md:col-span-4"></textarea>
<button class="w-fit bg-neutral-950 px-5 py-3 text-sm uppercase tracking-[0.18em] text-white md:col-span-4">Add service</button>
</form>
<div class="mt-8 grid gap-4">
{{range .Services}}
<article class="border border-neutral-200 p-4">
<form method="post" action="/admin/services/{{.ID}}" class="grid gap-4 md:grid-cols-[1fr_1fr_120px_120px]">
<input name="title" value="{{.Title}}" class="border px-3 py-2">
<input name="summary" value="{{.Summary}}" class="border px-3 py-2">
<input name="position" type="number" value="{{.Position}}" class="border px-3 py-2">
<label class="flex items-center gap-2 border px-3 py-2 text-sm"><input name="active" type="checkbox" {{if .Active}}checked{{end}}> Active</label>
<textarea name="details" rows="4" class="border px-3 py-2 md:col-span-4">{{.Details}}</textarea>
<button class="w-fit bg-neutral-950 px-5 py-2 text-sm uppercase tracking-[0.18em] text-white">Save</button>
</form>
<form method="post" action="/admin/services/{{.ID}}/delete" class="mt-2">
<button class="text-sm text-red-700">Delete service</button>
</form>
</article>
{{end}}
</div>
</section>
<section class="bg-white p-6 shadow-sm">
<h2 class="mb-6 text-2xl font-semibold">FAQs</h2>
<form method="post" action="/admin/faqs" class="grid gap-4 md:grid-cols-[1fr_120px_120px]">
<input name="question" required placeholder="Question" class="border px-3 py-2">
<input name="position" type="number" value="0" class="border px-3 py-2">
<label class="flex items-center gap-2 border px-3 py-2 text-sm"><input name="active" type="checkbox" checked> Active</label>
<textarea name="answer" required rows="4" placeholder="Answer" class="border px-3 py-2 md:col-span-3"></textarea>
<button class="w-fit bg-neutral-950 px-5 py-3 text-sm uppercase tracking-[0.18em] text-white md:col-span-3">Add FAQ</button>
</form>
<div class="mt-8 grid gap-4">
{{range .FAQs}}
<article class="border border-neutral-200 p-4">
<form method="post" action="/admin/faqs/{{.ID}}" class="grid gap-4 md:grid-cols-[1fr_120px_120px]">
<input name="question" value="{{.Question}}" class="border px-3 py-2">
<input name="position" type="number" value="{{.Position}}" class="border px-3 py-2">
<label class="flex items-center gap-2 border px-3 py-2 text-sm"><input name="active" type="checkbox" {{if .Active}}checked{{end}}> Active</label>
<textarea name="answer" rows="4" class="border px-3 py-2 md:col-span-3">{{.Answer}}</textarea>
<button class="w-fit bg-neutral-950 px-5 py-2 text-sm uppercase tracking-[0.18em] text-white">Save</button>
</form>
<form method="post" action="/admin/faqs/{{.ID}}/delete" class="mt-2">
<button class="text-sm text-red-700">Delete FAQ</button>
</form>
</article>
{{end}}
</div>
</section>
</section>
{{end}}

View File

@ -5,17 +5,6 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{if .Title}}{{.Title}} | {{end}}Archi Folio</title>
<script>
(function () {
try {
var theme = localStorage.getItem("archi-theme");
if (theme === "dark" || theme === "light") {
document.documentElement.setAttribute("data-theme", theme);
}
} catch (_) {}
})();
</script>
<link href="https://cdn.jsdelivr.net/npm/daisyui@5" rel="stylesheet" type="text/css">
<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">
@ -24,59 +13,16 @@
<body class="bg-neutral-50 text-neutral-950 antialiased">
{{end}}
{{define "public_shell_start"}}
{{template "head" .}}
<div class="drawer drawer-end">
<input id="site-drawer" type="checkbox" class="drawer-toggle">
<div class="drawer-content min-h-screen bg-neutral-50 text-neutral-950">
{{template "site_header" .}}
{{end}}
{{define "site_header"}}
<header id="site-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}}">
{{template "site_header_inner" .}}
</header>
{{end}}
{{define "site_header_oob"}}
<header id="site-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}}" hx-swap-oob="true">
{{template "site_header_inner" .}}
</header>
{{end}}
{{define "site_header_inner"}}
<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="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">Archi Folio</a>
<div class="hidden items-center gap-6 md:flex">
{{template "site_desktop_nav" .}}
{{template "theme_toggle" .}}
</div>
<label for="site-drawer" class="grid h-11 w-11 cursor-pointer place-items-center border border-current/30 md:hidden" aria-label="Open menu">
<span class="grid gap-1.5">
<span class="block h-px w-5 bg-current"></span>
<span class="block h-px w-5 bg-current"></span>
<span class="block h-px w-5 bg-current"></span>
</span>
</label>
<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>
{{end}}
{{define "site_desktop_nav"}}
<nav id="site-desktop-nav" class="hidden items-center gap-4 text-sm uppercase tracking-[0.18em] md:flex md:gap-8" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="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">Studio</a>
<a class="hover:opacity-60 {{if eq .Active "services"}}font-semibold{{end}}" href="/services">Services</a>
<a class="hover:opacity-60 {{if eq .Active "contact"}}font-semibold{{end}}" href="/contact">Contact</a>
</nav>
{{end}}
{{define "site_desktop_nav_oob"}}
<nav id="site-desktop-nav" class="hidden items-center gap-4 text-sm uppercase tracking-[0.18em] md:flex md:gap-8" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true" hx-swap-oob="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">Studio</a>
<a class="hover:opacity-60 {{if eq .Active "services"}}font-semibold{{end}}" href="/services">Services</a>
<a class="hover:opacity-60 {{if eq .Active "contact"}}font-semibold{{end}}" href="/contact">Contact</a>
</nav>
</header>
{{end}}
{{define "footer"}}
@ -87,69 +33,3 @@
</div>
</footer>
{{end}}
{{define "theme_toggle"}}
<label class="swap swap-rotate grid h-10 w-10 cursor-pointer place-items-center text-current" aria-label="Toggle dark theme">
<input type="checkbox" value="dark" class="theme-controller" data-theme-toggle>
<svg class="swap-off h-6 w-6 fill-current" aria-label="sun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M5.64,17l-.71.71a1,1,0,0,0,0,1.41,1,1,0,0,0,1.41,0l.71-.71A1,1,0,0,0,5.64,17ZM5,12a1,1,0,0,0-1-1H3a1,1,0,0,0,0,2H4A1,1,0,0,0,5,12Zm7-7a1,1,0,0,0,1-1V3a1,1,0,0,0-2,0V4A1,1,0,0,0,12,5ZM5.64,7.05a1,1,0,0,0,.7.29,1,1,0,0,0,.71-.29,1,1,0,0,0,0-1.41l-.71-.71A1,1,0,0,0,4.93,6.34Zm12,.29a1,1,0,0,0,.7-.29l.71-.71a1,1,0,1,0-1.41-1.41L17,5.64a1,1,0,0,0,0,1.41A1,1,0,0,0,17.66,7.34ZM21,11H20a1,1,0,0,0,0,2h1a1,1,0,0,0,0-2Zm-9,8a1,1,0,0,0-1,1v1a1,1,0,0,0,2,0V20A1,1,0,0,0,12,19ZM18.36,17A1,1,0,0,0,17,18.36l.71.71a1,1,0,0,0,1.41,0,1,1,0,0,0,0-1.41ZM12,6.5A5.5,5.5,0,1,0,17.5,12,5.51,5.51,0,0,0,12,6.5Zm0,9A3.5,3.5,0,1,1,15.5,12,3.5,3.5,0,0,1,12,15.5Z"></path>
</svg>
<svg class="swap-on h-6 w-6 fill-current" aria-label="moon" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M21.64,13a1,1,0,0,0-1.05-.14,8.05,8.05,0,0,1-3.37.73A8.15,8.15,0,0,1,9.08,5.49a8.59,8.59,0,0,1,.25-2A1,1,0,0,0,8,2.36,10.14,10.14,0,1,0,22,14.05,1,1,0,0,0,21.64,13Zm-9.5,6.69A8.14,8.14,0,0,1,7.08,5.22v.27A10.15,10.15,0,0,0,17.22,15.63a9.79,9.79,0,0,0,2.1-.22A8.11,8.11,0,0,1,12.14,19.73Z"></path>
</svg>
</label>
{{end}}
{{define "public_nav_oob"}}
{{template "site_header_oob" .}}
{{template "site_drawer_nav_oob" .}}
{{end}}
{{define "public_shell_end"}}
{{template "footer" .}}
<div id="overlay-root"></div>
</div>
<div class="drawer-side z-50 md:hidden">
<label for="site-drawer" aria-label="Close menu" class="drawer-overlay bg-neutral-950/45"></label>
<aside class="flex min-h-full w-[min(22rem,86vw)] flex-col bg-neutral-950/45 text-white shadow-2xl backdrop-blur-xl">
<div class="flex items-center justify-between border-b border-white/15 px-5 py-4">
<a href="/" class="text-xl font-semibold text-white" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true" data-drawer-link>Archi Folio</a>
<div class="flex items-center gap-3">
{{template "theme_toggle" .}}
<label for="site-drawer" class="grid h-10 w-10 cursor-pointer place-items-center border border-white/25 text-white" aria-label="Close menu">
<span class="relative block h-5 w-5">
<span class="absolute left-0 top-1/2 block h-px w-5 rotate-45 bg-current"></span>
<span class="absolute left-0 top-1/2 block h-px w-5 -rotate-45 bg-current"></span>
</span>
</label>
</div>
</div>
{{template "site_drawer_nav" .}}
<div class="mt-auto px-5 pb-6 text-sm leading-relaxed text-white">
<p>{{.Content.Email}}</p>
<p>{{.Content.Location}}</p>
</div>
</aside>
</div>
</div>
</body>
</html>
{{end}}
{{define "site_drawer_nav"}}
<nav id="site-drawer-nav" class="grid px-5 py-6 text-2xl font-medium text-white" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "projects"}}underline underline-offset-8{{end}}" href="/projects" data-drawer-link>Projects</a>
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "about"}}underline underline-offset-8{{end}}" href="/about" data-drawer-link>Studio</a>
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "services"}}underline underline-offset-8{{end}}" href="/services" data-drawer-link>Services</a>
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "contact"}}underline underline-offset-8{{end}}" href="/contact" data-drawer-link>Contact</a>
</nav>
{{end}}
{{define "site_drawer_nav_oob"}}
<nav id="site-drawer-nav" class="grid px-5 py-6 text-2xl font-medium text-white" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true" hx-swap-oob="true">
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "projects"}}underline underline-offset-8{{end}}" href="/projects" data-drawer-link>Projects</a>
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "about"}}underline underline-offset-8{{end}}" href="/about" data-drawer-link>Studio</a>
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "services"}}underline underline-offset-8{{end}}" href="/services" data-drawer-link>Services</a>
<a class="border-b border-white/15 py-4 text-white {{if eq .Active "contact"}}underline underline-offset-8{{end}}" href="/contact" data-drawer-link>Contact</a>
</nav>
{{end}}

View File

@ -1,101 +0,0 @@
{{define "contact.html"}}
{{template "public_shell_start" .}}
{{template "contact_content" .}}
{{template "public_shell_end" .}}
{{end}}
{{define "contact_partial.html"}}
{{template "public_nav_oob" .}}
{{template "contact_content" .}}
{{end}}
{{define "contact_content"}}
<main id="main-content" class="mx-auto max-w-7xl px-5 pb-24 pt-32 md:px-8 md:pt-40">
<section class="grid gap-10 border-b border-neutral-200 pb-14 md:grid-cols-[0.85fr_1.15fr] md:items-end">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">Project enquiry</p>
<h1 class="text-5xl font-semibold md:text-7xl">Contact</h1>
</div>
<p class="max-w-2xl text-xl leading-relaxed text-neutral-600">Share a few details about the site, scope, budget, and timing. The studio will review the enquiry and respond if the project is a suitable fit.</p>
</section>
<section class="grid gap-12 pt-14 lg:grid-cols-[0.72fr_1.28fr]">
<aside class="grid content-start gap-8">
<div>
<h2 class="mb-4 text-2xl font-semibold">Studio details</h2>
<div class="grid gap-3 text-neutral-600">
<p><span class="block text-sm uppercase tracking-[0.18em] text-neutral-400">Email</span>{{.Content.Email}}</p>
<p><span class="block text-sm uppercase tracking-[0.18em] text-neutral-400">Phone</span>{{.Content.Phone}}</p>
<p><span class="block text-sm uppercase tracking-[0.18em] text-neutral-400">Location</span>{{.Content.Location}}</p>
</div>
</div>
<div class="border-t border-neutral-200 pt-8">
<h2 class="mb-3 text-2xl font-semibold">Useful to include</h2>
<p class="leading-relaxed text-neutral-600">Approximate property location, whether the project is new build or renovation, target budget, and any planning or timing constraints.</p>
</div>
</aside>
<form hx-post="/contact" hx-target="#contact-result" hx-swap="innerHTML" class="grid gap-5 bg-white p-5 shadow-sm md:p-8">
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm">
<span class="mb-2 block text-neutral-500">Name</span>
<input name="name" required autocomplete="name" class="w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
</label>
<label class="block text-sm">
<span class="mb-2 block text-neutral-500">Email</span>
<input name="email" type="email" required autocomplete="email" class="w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
</label>
<label class="block text-sm">
<span class="mb-2 block text-neutral-500">Phone, optional</span>
<input name="phone" autocomplete="tel" class="w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
</label>
<label class="block text-sm">
<span class="mb-2 block text-neutral-500">Project type</span>
<select name="project_type" required class="form-select w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
<option value="">Select one</option>
<option>Residential architecture</option>
<option>Renovation or extension</option>
<option>Interior architecture</option>
<option>Early-stage consultation</option>
<option>Other</option>
</select>
</label>
<label class="block text-sm">
<span class="mb-2 block text-neutral-500">Project location</span>
<input name="project_location" required class="w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
</label>
<label class="block text-sm">
<span class="mb-2 block text-neutral-500">Budget range</span>
<select name="budget_range" required class="form-select w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
<option value="">Select one</option>
<option>Under GBP 100k</option>
<option>GBP 100k-250k</option>
<option>GBP 250k-500k</option>
<option>GBP 500k+</option>
<option>Not sure yet</option>
</select>
</label>
<label class="block text-sm md:col-span-2">
<span class="mb-2 block text-neutral-500">Timeline</span>
<select name="timeline" required class="form-select w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950">
<option value="">Select one</option>
<option>As soon as possible</option>
<option>Within 3 months</option>
<option>3-6 months</option>
<option>6-12 months</option>
<option>Still exploring</option>
</select>
</label>
</div>
<label class="block text-sm">
<span class="mb-2 block text-neutral-500">Project notes</span>
<textarea name="message" required rows="7" class="w-full border border-neutral-300 bg-white px-4 py-3 outline-none focus:border-neutral-950"></textarea>
</label>
<div class="flex flex-col gap-4 md:flex-row md:items-center">
<button class="w-fit bg-neutral-950 px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-white hover:bg-neutral-700">Submit enquiry</button>
<div id="contact-result" class="text-sm"></div>
</div>
</form>
</section>
</main>
{{end}}

View File

@ -1,62 +1,21 @@
{{define "home.html"}}
{{template "public_shell_start" .}}
{{template "home_content" .}}
{{template "public_shell_end" .}}
{{end}}
{{define "home_partial.html"}}
{{template "public_nav_oob" .}}
{{template "home_content" .}}
{{end}}
{{define "home_content"}}
<main id="main-content">
<section class="relative min-h-[88vh] overflow-hidden bg-neutral-950">
{{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/40 via-black/10 to-black/65"></div>
<div class="relative mx-auto flex min-h-[88vh] 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.Positioning}}</p>
<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>
<p class="mt-6 max-w-2xl text-lg leading-relaxed text-white/80 md:text-xl">{{.Content.HeroSubtitle}}</p>
<div class="mt-8 flex flex-wrap gap-3" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">
<a href="{{.Content.HeroCTAURL}}" class="bg-white px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-neutral-950 hover:bg-neutral-200">{{.Content.HeroCTALabel}}</a>
<a href="{{.Content.SecondaryCTAURL}}" class="border border-white/60 px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-white hover:bg-white hover:text-neutral-950">{{.Content.SecondaryCTALabel}}</a>
</div>
</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>
<div class="max-w-2xl">
<p class="text-xl leading-relaxed text-neutral-600">{{.Content.IntroText}}</p>
<a href="/about" class="mt-6 inline-flex text-sm uppercase tracking-[0.18em] text-neutral-500 hover:text-neutral-950" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">Meet the studio</a>
</div>
<p class="max-w-2xl text-xl leading-relaxed text-neutral-600">{{.Content.IntroText}}</p>
</section>
<section class="border-y border-neutral-200 bg-white px-5 py-20 md:px-8 md:py-24">
<div class="mx-auto grid max-w-7xl gap-10 md:grid-cols-[0.75fr_1.25fr]">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">Services</p>
<h2 class="text-3xl font-semibold md:text-5xl">Focused support for homes and small spaces</h2>
</div>
<div class="grid gap-6 md:grid-cols-3">
<article class="border-t border-neutral-300 pt-5">
<h3 class="mb-3 text-xl font-medium">{{.Content.ServiceOneTitle}}</h3>
<p class="leading-relaxed text-neutral-600">{{.Content.ServiceOneText}}</p>
</article>
<article class="border-t border-neutral-300 pt-5">
<h3 class="mb-3 text-xl font-medium">{{.Content.ServiceTwoTitle}}</h3>
<p class="leading-relaxed text-neutral-600">{{.Content.ServiceTwoText}}</p>
</article>
<article class="border-t border-neutral-300 pt-5">
<h3 class="mb-3 text-xl font-medium">{{.Content.ServiceThreeTitle}}</h3>
<p class="leading-relaxed text-neutral-600">{{.Content.ServiceThreeText}}</p>
</article>
</div>
</div>
</section>
<section class="px-5 py-20 md:px-8 md:py-28" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">
<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>
@ -70,8 +29,7 @@
<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}} · {{.Status}}</p>
<p class="mt-2 max-w-sm text-sm leading-relaxed text-neutral-600">{{.Summary}}</p>
<p class="text-sm text-neutral-500">{{.Location}}</p>
</div>
<p class="text-sm text-neutral-500">{{.Year}}</p>
</div>
@ -79,43 +37,8 @@
{{end}}
</div>
</section>
<section class="bg-neutral-950 px-5 py-20 text-white md:px-8 md:py-28">
<div class="mx-auto grid max-w-7xl gap-10 md:grid-cols-[0.7fr_1.3fr]">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-white/50">Process</p>
<h2 class="text-3xl font-semibold md:text-5xl">Clear decisions from first conversation to detailed direction</h2>
</div>
<div class="grid gap-6 md:grid-cols-3">
<article class="border-t border-white/25 pt-5">
<p class="mb-4 text-sm text-white/45">01</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessOneTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessOneText}}</p>
</article>
<article class="border-t border-white/25 pt-5">
<p class="mb-4 text-sm text-white/45">02</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessTwoTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessTwoText}}</p>
</article>
<article class="border-t border-white/25 pt-5">
<p class="mb-4 text-sm text-white/45">03</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessThreeTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessThreeText}}</p>
</article>
</div>
</div>
</section>
<section class="mx-auto grid max-w-7xl gap-10 px-5 py-20 md:grid-cols-[0.85fr_1.15fr] md:px-8 md:py-28 md:items-center">
<div class="aspect-[4/5] max-h-[640px] overflow-hidden bg-neutral-200">
<img src="{{.Content.AboutImage}}" alt="{{.Content.AboutName}}" class="h-full w-full object-cover">
</div>
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">{{.Content.AboutRole}}</p>
<h2 class="text-4xl font-semibold md:text-6xl">{{.Content.AboutName}}</h2>
<p class="mt-6 max-w-2xl text-xl leading-relaxed text-neutral-600">{{.Content.AboutBio}}</p>
<a href="/about" class="mt-8 inline-flex bg-neutral-950 px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-white hover:bg-neutral-700" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">Studio profile</a>
</div>
</section>
</main>
{{end}}
{{template "footer" .}}
<div id="overlay-root"></div>
</body>
</html>

View File

@ -1,37 +1,17 @@
{{define "project.html"}}
{{template "public_shell_start" .}}
{{template "project_content" .}}
{{template "public_shell_end" .}}
{{end}}
{{define "project_partial.html"}}
{{template "public_nav_oob" .}}
{{template "project_content" .}}
{{end}}
{{define "project_content"}}
<main id="main-content" class="pt-28 md:pt-36">
{{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}} · {{.Project.Status}}</p>
<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-2 gap-4 border-t border-neutral-200 pt-4 text-sm">
<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><p class="text-neutral-500">Status</p><p>{{.Project.Status}}</p></div>
</div>
</div>
<div class="mt-10 grid gap-8 md:grid-cols-[0.8fr_1.2fr]">
<div class="border-t border-neutral-200 pt-4">
<p class="mb-2 text-sm text-neutral-500">Scope</p>
<p class="leading-relaxed">{{.Project.Scope}}</p>
</div>
<div>
<p class="mb-4 text-2xl leading-snug text-neutral-800">{{.Project.Summary}}</p>
<p class="max-w-3xl text-xl leading-relaxed text-neutral-600">{{.Project.Description}}</p>
</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">
@ -45,4 +25,7 @@
</div>
</section>
</main>
{{end}}
{{template "footer" .}}
<div id="overlay-root"></div>
</body>
</html>

View File

@ -1,16 +1,6 @@
{{define "projects.html"}}
{{template "public_shell_start" .}}
{{template "projects_content" .}}
{{template "public_shell_end" .}}
{{end}}
{{define "projects_partial.html"}}
{{template "public_nav_oob" .}}
{{template "projects_content" .}}
{{end}}
{{define "projects_content"}}
<main id="main-content" class="mx-auto max-w-7xl px-5 pb-24 pt-32 md:px-8 md:pt-40">
{{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>
@ -18,22 +8,21 @@
</div>
<p class="max-w-md text-neutral-600">A visual index of architectural and interior design work.</p>
</div>
<div class="grid gap-x-5 gap-y-10 sm:grid-cols-2 lg:grid-cols-3" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">
<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-[4/3] overflow-hidden bg-neutral-200">
<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-4">
<div class="mb-2 flex items-start justify-between gap-4">
<h2 class="text-xl font-medium">{{.Title}}</h2>
<p class="text-sm text-neutral-500">{{.Year}}</p>
</div>
<p class="mb-3 text-sm text-neutral-500">{{.Location}} · {{.Category}} · {{.Status}}</p>
<p class="leading-relaxed text-neutral-600">{{.Summary}}</p>
<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>
{{end}}
{{template "footer" .}}
<div id="overlay-root"></div>
</body>
</html>

View File

@ -1,82 +0,0 @@
{{define "services.html"}}
{{template "public_shell_start" .}}
{{template "services_content" .}}
{{template "public_shell_end" .}}
{{end}}
{{define "services_partial.html"}}
{{template "public_nav_oob" .}}
{{template "services_content" .}}
{{end}}
{{define "services_content"}}
<main id="main-content" class="mx-auto max-w-7xl px-5 pb-24 pt-32 md:px-8 md:pt-40">
<section class="grid gap-10 border-b border-neutral-200 pb-14 md:grid-cols-[0.82fr_1.18fr] md:items-end">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">{{.Content.ServiceArea}}</p>
<h1 class="text-5xl font-semibold md:text-7xl">Services</h1>
</div>
<p class="max-w-2xl text-xl leading-relaxed text-neutral-600">Focused architectural and interior design support for residential clients, renovations, compact interiors, and early-stage project decisions.</p>
</section>
<section class="grid gap-6 py-16 md:grid-cols-2">
{{range .Services}}
<article class="border-t border-neutral-300 pt-6">
<div class="mb-4 flex items-start justify-between gap-4">
<h2 class="text-2xl font-semibold">{{.Title}}</h2>
<p class="text-sm text-neutral-400">{{.Position}}</p>
</div>
<p class="mb-5 text-lg leading-relaxed text-neutral-600">{{.Summary}}</p>
<p class="leading-relaxed text-neutral-500">{{.Details}}</p>
</article>
{{end}}
</section>
<section class="grid gap-10 bg-neutral-950 px-5 py-12 text-white md:grid-cols-[0.75fr_1.25fr] md:px-8">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-white/50">How projects work</p>
<h2 class="text-3xl font-semibold md:text-5xl">A clear process before a larger commitment</h2>
</div>
<div class="grid gap-6 md:grid-cols-3">
<article class="border-t border-white/25 pt-5">
<p class="mb-4 text-sm text-white/45">01</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessOneTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessOneText}}</p>
</article>
<article class="border-t border-white/25 pt-5">
<p class="mb-4 text-sm text-white/45">02</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessTwoTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessTwoText}}</p>
</article>
<article class="border-t border-white/25 pt-5">
<p class="mb-4 text-sm text-white/45">03</p>
<h3 class="mb-3 text-xl font-medium">{{.Content.ProcessThreeTitle}}</h3>
<p class="leading-relaxed text-white/65">{{.Content.ProcessThreeText}}</p>
</article>
</div>
</section>
<section class="grid gap-10 py-16 md:grid-cols-[0.75fr_1.25fr]">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">Project fit</p>
<h2 class="text-3xl font-semibold md:text-5xl">FAQs</h2>
</div>
<div class="grid gap-6">
{{range .FAQs}}
<article class="border-t border-neutral-300 pt-5">
<h3 class="mb-3 text-xl font-medium">{{.Question}}</h3>
<p class="leading-relaxed text-neutral-600">{{.Answer}}</p>
</article>
{{end}}
</div>
</section>
<section class="grid gap-6 border-t border-neutral-200 pt-10 md:grid-cols-[0.8fr_1.2fr] md:items-center">
<h2 class="text-3xl font-semibold">Ready to discuss a project?</h2>
<div>
<p class="max-w-2xl text-lg leading-relaxed text-neutral-600">Use the enquiry form to share the project type, site location, budget range, and timeline.</p>
<a href="/contact" class="mt-6 inline-flex bg-neutral-950 px-5 py-3 text-sm font-medium uppercase tracking-[0.18em] text-white hover:bg-neutral-700" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">Start an enquiry</a>
</div>
</section>
</main>
{{end}}