diff --git a/VERSION b/VERSION
index 6e8bf73..0ea3a94 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.1.0
+0.2.0
diff --git a/internal/app/app.go b/internal/app/app.go
deleted file mode 100644
index c88d8df..0000000
--- a/internal/app/app.go
+++ /dev/null
@@ -1,699 +0,0 @@
-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)
- })
-}
diff --git a/internal/app/app_test.go b/internal/app/app_test.go
deleted file mode 100644
index 41a4aab..0000000
--- a/internal/app/app_test.go
+++ /dev/null
@@ -1,313 +0,0 @@
-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), "") || !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, "") {
- t.Fatalf("expected partial response, got full document: %s", text)
- }
- if !strings.Contains(text, `hx-swap-oob="true"`) || !strings.Contains(text, "Add Project") {
- t.Fatalf("expected partial panel and out-of-band tab update: %s", text)
- }
-}
-
-func TestAdminMutationsRedirectToOwningTabs(t *testing.T) {
- srv := newTestServer(t)
- handler := srv.Routes()
- cookie := loginCookie(t, handler)
-
- for _, test := range []struct {
- path string
- form url.Values
- want string
- }{
- {
- path: "/admin/contact-details",
- form: url.Values{"email": {"studio@example.com"}, "phone": {"123"}, "location": {"London"}},
- want: "/admin/contact-details?ok=contact+details+saved",
- },
- {
- path: "/admin/content",
- form: url.Values{
- "hero_title": {"Hero"}, "hero_subtitle": {"Subtitle"}, "intro_title": {"Intro"}, "intro_text": {"Text"},
- "about_name": {"Name"}, "about_role": {"Role"}, "about_bio": {"Bio"},
- "hero_image_current": {"/static/placeholders/hero.svg"}, "about_image_current": {"/static/placeholders/about.svg"},
- },
- want: "/admin/main?ok=content+saved",
- },
- } {
- req := httptest.NewRequest(http.MethodPost, test.path, strings.NewReader(test.form.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.AddCookie(cookie)
- rec := httptest.NewRecorder()
- handler.ServeHTTP(rec, req)
- if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != test.want {
- t.Fatalf("%s expected redirect %q, got %d %q", test.path, test.want, rec.Code, rec.Header().Get("Location"))
- }
- }
-}
-
-func TestAdminProjectValidation(t *testing.T) {
- srv := newTestServer(t)
- handler := srv.Routes()
- cookie := loginCookie(t, handler)
- form := url.Values{"title": {" "}, "location": {"London"}, "year": {"2026"}, "category": {"Residential"}, "description": {"Text"}}
- req := httptest.NewRequest(http.MethodPost, "/admin/projects", strings.NewReader(form.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- req.AddCookie(cookie)
- rec := httptest.NewRecorder()
-
- handler.ServeHTTP(rec, req)
-
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("expected redirect, got %d", rec.Code)
- }
- if location := rec.Header().Get("Location"); !strings.Contains(location, "/admin/projects?err=") || !strings.Contains(location, "project+title+is+required") {
- t.Fatalf("expected validation error redirect, got %q", location)
- }
-}
-
-func TestAdminRejectsSVGUpload(t *testing.T) {
- srv := newTestServer(t)
- handler := srv.Routes()
- cookie := loginCookie(t, handler)
- var body bytes.Buffer
- writer := multipart.NewWriter(&body)
- fields := map[string]string{
- "title": "Upload Test",
- "location": "London",
- "year": "2026",
- "category": "Residential",
- "description": "A project",
- }
- for key, value := range fields {
- if err := writer.WriteField(key, value); err != nil {
- t.Fatal(err)
- }
- }
- file, err := writer.CreateFormFile("cover_image", "bad.svg")
- if err != nil {
- t.Fatal(err)
- }
- if _, err := file.Write([]byte(``)); err != nil {
- t.Fatal(err)
- }
- if err := writer.Close(); err != nil {
- t.Fatal(err)
- }
- req := httptest.NewRequest(http.MethodPost, "/admin/projects", &body)
- req.Header.Set("Content-Type", writer.FormDataContentType())
- req.AddCookie(cookie)
- rec := httptest.NewRecorder()
-
- handler.ServeHTTP(rec, req)
-
- if rec.Code != http.StatusSeeOther {
- t.Fatalf("expected redirect, got %d", rec.Code)
- }
- if location := rec.Header().Get("Location"); !strings.Contains(location, "/admin/projects?err=") || !strings.Contains(location, "unsupported+image+type") {
- t.Fatalf("expected unsupported image redirect, got %q", location)
- }
-}
-
-func TestContactSubmissionPersists(t *testing.T) {
- srv := newTestServer(t)
- form := url.Values{"name": {"Jane"}, "email": {"jane@example.com"}, "message": {"New project"}}
- req := httptest.NewRequest(http.MethodPost, "/contact", strings.NewReader(form.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- rec := httptest.NewRecorder()
-
- srv.Routes().ServeHTTP(rec, req)
-
- if rec.Code != http.StatusOK {
- t.Fatalf("expected ok, got %d", rec.Code)
- }
- body, _ := io.ReadAll(rec.Result().Body)
- if !strings.Contains(string(body), "saved") {
- t.Fatalf("expected success message, got %s", body)
- }
- requests, err := srv.store.ContactRequests(req.Context())
- if err != nil {
- t.Fatal(err)
- }
- if len(requests) != 1 || requests[0].Email != "jane@example.com" {
- t.Fatalf("unexpected contact requests: %+v", requests)
- }
-}
-
-func TestProjectImageOverlay(t *testing.T) {
- srv := newTestServer(t)
- project, err := srv.store.ProjectBySlug(t.Context(), "courtyard-house")
- if err != nil {
- t.Fatal(err)
- }
- if len(project.Images) == 0 {
- t.Fatal("expected seeded project images")
- }
- path := "/projects/" + project.Slug + "/images/" + strconv.FormatInt(project.Images[0].ID, 10) + "/overlay"
- req := httptest.NewRequest(http.MethodGet, path, nil)
- rec := httptest.NewRecorder()
-
- srv.Routes().ServeHTTP(rec, req)
-
- if rec.Code != http.StatusOK {
- t.Fatalf("expected ok, got %d", rec.Code)
- }
- body, _ := io.ReadAll(rec.Result().Body)
- if !strings.Contains(string(body), "data-overlay") || !strings.Contains(string(body), project.Images[0].Path) {
- t.Fatalf("overlay fragment missing expected content: %s", body)
- }
-}
-
-func loginCookie(t *testing.T, handler http.Handler) *http.Cookie {
- t.Helper()
- form := url.Values{"username": {"admin"}, "password": {"changeme"}}
- req := httptest.NewRequest(http.MethodPost, "/admin/login", strings.NewReader(form.Encode()))
- req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
- rec := httptest.NewRecorder()
- handler.ServeHTTP(rec, req)
- cookies := rec.Result().Cookies()
- if len(cookies) == 0 {
- t.Fatal("expected login cookie")
- }
- return cookies[0]
-}
diff --git a/internal/app/assets.go b/internal/app/assets.go
new file mode 100644
index 0000000..555d56d
--- /dev/null
+++ b/internal/app/assets.go
@@ -0,0 +1,22 @@
+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")
+}
diff --git a/internal/app/auth.go b/internal/app/auth.go
new file mode 100644
index 0000000..7a5e3fc
--- /dev/null
+++ b/internal/app/auth.go
@@ -0,0 +1,81 @@
+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[:])
+}
diff --git a/internal/app/auth_test.go b/internal/app/auth_test.go
new file mode 100644
index 0000000..0c7b67f
--- /dev/null
+++ b/internal/app/auth_test.go
@@ -0,0 +1,46 @@
+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")
+ }
+}
diff --git a/internal/app/handlers_admin_mutations.go b/internal/app/handlers_admin_mutations.go
new file mode 100644
index 0000000..c0d3556
--- /dev/null
+++ b/internal/app/handlers_admin_mutations.go
@@ -0,0 +1,217 @@
+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: 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")
+}
diff --git a/internal/app/handlers_admin_mutations_test.go b/internal/app/handlers_admin_mutations_test.go
new file mode 100644
index 0000000..afcd89d
--- /dev/null
+++ b/internal/app/handlers_admin_mutations_test.go
@@ -0,0 +1,65 @@
+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"},
+ "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)
+ }
+}
diff --git a/internal/app/handlers_admin_pages.go b/internal/app/handlers_admin_pages.go
new file mode 100644
index 0000000..7fa767d
--- /dev/null
+++ b/internal/app/handlers_admin_pages.go
@@ -0,0 +1,64 @@
+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) 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 == "contact-details" {
+ contacts, err := s.store.ContactRequests(r.Context())
+ if err != nil {
+ return pageData{}, err
+ }
+ data.Contacts = contacts
+ }
+ return data, nil
+}
diff --git a/internal/app/handlers_admin_pages_test.go b/internal/app/handlers_admin_pages_test.go
new file mode 100644
index 0000000..8ae9b92
--- /dev/null
+++ b/internal/app/handlers_admin_pages_test.go
@@ -0,0 +1,68 @@
+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/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), "") || !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, "") {
+ 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)
+ }
+}
diff --git a/internal/app/handlers_public.go b/internal/app/handlers_public.go
new file mode 100644
index 0000000..e1bf4cd
--- /dev/null
+++ b/internal/app/handlers_public.go
@@ -0,0 +1,101 @@
+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.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."})
+}
diff --git a/internal/app/handlers_public_test.go b/internal/app/handlers_public_test.go
new file mode 100644
index 0000000..4ad8e96
--- /dev/null
+++ b/internal/app/handlers_public_test.go
@@ -0,0 +1,74 @@
+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", "/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 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)
+ }
+}
diff --git a/internal/app/middleware.go b/internal/app/middleware.go
new file mode 100644
index 0000000..605a80d
--- /dev/null
+++ b/internal/app/middleware.go
@@ -0,0 +1,11 @@
+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)
+ })
+}
diff --git a/internal/app/render.go b/internal/app/render.go
new file mode 100644
index 0000000..b7685fa
--- /dev/null
+++ b/internal/app/render.go
@@ -0,0 +1,41 @@
+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) 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)
+}
diff --git a/internal/app/routes.go b/internal/app/routes.go
new file mode 100644
index 0000000..a98af51
--- /dev/null
+++ b/internal/app/routes.go
@@ -0,0 +1,37 @@
+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 /", 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)
+}
diff --git a/internal/app/server.go b/internal/app/server.go
new file mode 100644
index 0000000..fb493d0
--- /dev/null
+++ b/internal/app/server.go
@@ -0,0 +1,26 @@
+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
+}
diff --git a/internal/app/test_helpers_test.go b/internal/app/test_helpers_test.go
new file mode 100644
index 0000000..9aaf28a
--- /dev/null
+++ b/internal/app/test_helpers_test.go
@@ -0,0 +1,51 @@
+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]
+}
diff --git a/internal/app/types.go b/internal/app/types.go
new file mode 100644
index 0000000..a76f0cc
--- /dev/null
+++ b/internal/app/types.go
@@ -0,0 +1,34 @@
+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
+ Contacts []store.ContactRequest
+ Admin bool
+ AdminTab string
+ Error string
+ Success string
+ CurrentPath string
+ Version string
+}
diff --git a/internal/app/uploads.go b/internal/app/uploads.go
new file mode 100644
index 0000000..09fce35
--- /dev/null
+++ b/internal/app/uploads.go
@@ -0,0 +1,61 @@
+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()
+}
diff --git a/internal/app/uploads_test.go b/internal/app/uploads_test.go
new file mode 100644
index 0000000..8b57d2e
--- /dev/null
+++ b/internal/app/uploads_test.go
@@ -0,0 +1,53 @@
+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(``)); 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)
+ }
+}
diff --git a/internal/app/validation.go b/internal/app/validation.go
new file mode 100644
index 0000000..30a6780
--- /dev/null
+++ b/internal/app/validation.go
@@ -0,0 +1,67 @@
+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.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
+ }
+}
diff --git a/internal/store/auth.go b/internal/store/auth.go
new file mode 100644
index 0000000..2b832ec
--- /dev/null
+++ b/internal/store/auth.go
@@ -0,0 +1,29 @@
+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
+}
diff --git a/internal/store/contact.go b/internal/store/contact.go
new file mode 100644
index 0000000..a8be098
--- /dev/null
+++ b/internal/store/contact.go
@@ -0,0 +1,25 @@
+package store
+
+import "context"
+
+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()
+}
diff --git a/internal/store/helpers.go b/internal/store/helpers.go
new file mode 100644
index 0000000..aee429c
--- /dev/null
+++ b/internal/store/helpers.go
@@ -0,0 +1,45 @@
+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)
+}
diff --git a/internal/store/migrations.go b/internal/store/migrations.go
new file mode 100644
index 0000000..e6bd40e
--- /dev/null
+++ b/internal/store/migrations.go
@@ -0,0 +1,145 @@
+package store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+
+ "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,
+ 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
+}
diff --git a/internal/store/store_test.go b/internal/store/migrations_test.go
similarity index 100%
rename from internal/store/store_test.go
rename to internal/store/migrations_test.go
diff --git a/internal/store/projects.go b/internal/store/projects.go
new file mode 100644
index 0000000..65c7dec
--- /dev/null
+++ b/internal/store/projects.go
@@ -0,0 +1,122 @@
+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, 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
+}
diff --git a/internal/store/site.go b/internal/store/site.go
new file mode 100644
index 0000000..e587686
--- /dev/null
+++ b/internal/store/site.go
@@ -0,0 +1,16 @@
+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, 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
+}
diff --git a/internal/store/store.go b/internal/store/store.go
index fa3cfb1..1830f98 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -1,16 +1,12 @@
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 {
@@ -83,350 +79,3 @@ func Open(path string) (*Store, error) {
func (s *Store) Close() error {
return s.db.Close()
}
-
-func (s *Store) Migrate(adminUsername, adminPassword string) error {
- stmts := []string{
- `create table if not exists site_content (
- id integer primary key check (id = 1),
- hero_title text not null,
- hero_subtitle text not null,
- intro_title text not null,
- intro_text text not null,
- about_name text not null,
- about_role text not null,
- about_bio text not null,
- email text not null,
- phone text not null,
- location text not null,
- hero_image text not null,
- about_image text not null
- )`,
- `create table if not exists projects (
- id integer primary key autoincrement,
- slug text not null unique,
- title text not null,
- location text not null,
- year text not null,
- category text not null,
- description text not null,
- cover_image text not null,
- featured integer not null default 0,
- created_at datetime not null default current_timestamp
- )`,
- `create table if not exists project_images (
- id integer primary key autoincrement,
- project_id integer not null references projects(id) on delete cascade,
- path text not null,
- caption text not null,
- position integer not null default 0
- )`,
- `create table if not exists contact_requests (
- id integer primary key autoincrement,
- name text not null,
- email text not null,
- message text not null,
- created_at datetime not null default current_timestamp
- )`,
- `create table if not exists admin_users (
- id integer primary key autoincrement,
- username text not null unique,
- password_hash blob not null
- )`,
- `create table if not exists sessions (
- token_hash text primary key,
- user_id integer not null references admin_users(id) on delete cascade,
- expires_at datetime not null
- )`,
- }
- for _, stmt := range stmts {
- if _, err := s.db.Exec(stmt); err != nil {
- return err
- }
- }
- return s.seed(adminUsername, adminPassword)
-}
-
-func (s *Store) seed(adminUsername, adminPassword string) error {
- var count int
- if err := s.db.QueryRow(`select count(*) from site_content`).Scan(&count); err != nil {
- return err
- }
- if count == 0 {
- _, err := s.db.Exec(`insert into site_content (
- id, hero_title, hero_subtitle, intro_title, intro_text, about_name, about_role, about_bio,
- email, phone, location, hero_image, about_image
- ) values (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- "Archi Folio",
- "Spatial design, architecture, and interiors shaped through quiet detail.",
- "Selected residential and cultural spaces",
- "A compact portfolio for showing image-led architectural work, interior concepts, and project narratives.",
- "Alex Morgan",
- "Architect & Interior Designer",
- "I design calm, functional spaces with attention to proportion, material, light, and the daily rituals of the people who use them.",
- "studio@example.com",
- "+44 20 0000 0000",
- "London, United Kingdom",
- "/static/placeholders/hero.svg",
- "/static/placeholders/about.svg",
- )
- if err != nil {
- return err
- }
- }
-
- if err := s.db.QueryRow(`select count(*) from projects`).Scan(&count); err != nil {
- return err
- }
- if count == 0 {
- projects := []Project{
- {Slug: "courtyard-house", Title: "Courtyard House", Location: "Bath, UK", Year: "2025", Category: "Residential", Description: "A private house organized around a quiet internal garden, using warm timber, stone, and filtered daylight.", CoverImage: "/static/placeholders/project-1.svg", Featured: true},
- {Slug: "atelier-apartment", Title: "Atelier Apartment", Location: "London, UK", Year: "2024", Category: "Interior", Description: "A compact apartment refit with integrated storage, gallery-like surfaces, and a flexible work area.", CoverImage: "/static/placeholders/project-2.svg", Featured: true},
- {Slug: "gallery-room", Title: "Gallery Room", Location: "Amsterdam, NL", Year: "2024", Category: "Cultural", Description: "A small exhibition environment designed for shifting light levels, sculpture, and intimate events.", CoverImage: "/static/placeholders/project-3.svg", Featured: true},
- }
- for _, p := range projects {
- id, err := s.CreateProject(context.Background(), p)
- if err != nil {
- return err
- }
- for i := 0; i < 3; i++ {
- if _, err := s.db.Exec(`insert into project_images (project_id, path, caption, position) values (?, ?, ?, ?)`, id, p.CoverImage, p.Title, i); err != nil {
- return err
- }
- }
- }
- }
-
- hash, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost)
- if err != nil {
- return err
- }
- var user AdminUser
- err = s.db.QueryRow(`select id, username, password_hash from admin_users order by id asc limit 1`).Scan(&user.ID, &user.Username, &user.PasswordHash)
- if errors.Is(err, sql.ErrNoRows) {
- _, err = s.db.Exec(`insert into admin_users (username, password_hash) values (?, ?)`, adminUsername, hash)
- return err
- }
- if err != nil {
- return err
- }
- if user.Username == adminUsername && bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(adminPassword)) == nil {
- return nil
- }
- _, err = s.db.Exec(`update admin_users set username = ?, password_hash = ? where id = ?`, adminUsername, hash, user.ID)
- if err != nil {
- return err
- }
- _, err = s.db.Exec(`delete from sessions`)
- return err
-}
-
-func (s *Store) SiteContent(ctx context.Context) (SiteContent, error) {
- var c SiteContent
- err := s.db.QueryRowContext(ctx, `select hero_title, hero_subtitle, intro_title, intro_text, about_name, about_role, about_bio, email, phone, location, hero_image, about_image from site_content where id = 1`).
- Scan(&c.HeroTitle, &c.HeroSubtitle, &c.IntroTitle, &c.IntroText, &c.AboutName, &c.AboutRole, &c.AboutBio, &c.Email, &c.Phone, &c.Location, &c.HeroImage, &c.AboutImage)
- return c, err
-}
-
-func (s *Store) UpdateSiteContent(ctx context.Context, c SiteContent) error {
- _, err := s.db.ExecContext(ctx, `update site_content set hero_title=?, hero_subtitle=?, intro_title=?, intro_text=?, about_name=?, about_role=?, about_bio=?, email=?, phone=?, location=?, hero_image=?, about_image=? where id=1`,
- c.HeroTitle, c.HeroSubtitle, c.IntroTitle, c.IntroText, c.AboutName, c.AboutRole, c.AboutBio, c.Email, c.Phone, c.Location, c.HeroImage, c.AboutImage)
- return err
-}
-
-func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, error) {
- query := `select id, slug, title, location, year, category, description, cover_image, featured, created_at from projects`
- if featuredOnly {
- query += ` where featured = 1`
- }
- query += ` order by created_at desc, id desc`
- rows, err := s.db.QueryContext(ctx, query)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- var projects []Project
- for rows.Next() {
- var p Project
- var featured int
- if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Description, &p.CoverImage, &featured, &p.CreatedAt); err != nil {
- return nil, err
- }
- p.Featured = featured == 1
- projects = append(projects, p)
- }
- return projects, rows.Err()
-}
-
-func (s *Store) ProjectBySlug(ctx context.Context, slug string) (Project, error) {
- var p Project
- var featured int
- err := s.db.QueryRowContext(ctx, `select id, slug, title, location, year, category, description, cover_image, featured, created_at from projects where slug = ?`, slug).
- Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Description, &p.CoverImage, &featured, &p.CreatedAt)
- if err != nil {
- return p, err
- }
- p.Featured = featured == 1
- p.Images, err = s.ProjectImages(ctx, p.ID)
- return p, err
-}
-
-func (s *Store) ProjectImages(ctx context.Context, projectID int64) ([]ProjectImage, error) {
- rows, err := s.db.QueryContext(ctx, `select id, project_id, path, caption, position from project_images where project_id = ? order by position asc, id asc`, projectID)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- var images []ProjectImage
- for rows.Next() {
- var img ProjectImage
- if err := rows.Scan(&img.ID, &img.ProjectID, &img.Path, &img.Caption, &img.Position); err != nil {
- return nil, err
- }
- images = append(images, img)
- }
- return images, rows.Err()
-}
-
-func (s *Store) ProjectImagesByProject(ctx context.Context) (map[int64][]ProjectImage, error) {
- rows, err := s.db.QueryContext(ctx, `select id, project_id, path, caption, position from project_images order by project_id asc, position asc, id asc`)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- images := make(map[int64][]ProjectImage)
- for rows.Next() {
- var img ProjectImage
- if err := rows.Scan(&img.ID, &img.ProjectID, &img.Path, &img.Caption, &img.Position); err != nil {
- return nil, err
- }
- images[img.ProjectID] = append(images[img.ProjectID], img)
- }
- return images, rows.Err()
-}
-
-func (s *Store) ProjectImageForSlug(ctx context.Context, slug string, imageID int64) (Project, ProjectImage, error) {
- p, err := s.ProjectBySlug(ctx, slug)
- if err != nil {
- return Project{}, ProjectImage{}, err
- }
- for _, img := range p.Images {
- if img.ID == imageID {
- return p, img, nil
- }
- }
- return Project{}, ProjectImage{}, sql.ErrNoRows
-}
-
-func (s *Store) CreateProject(ctx context.Context, p Project) (int64, error) {
- res, err := s.db.ExecContext(ctx, `insert into projects (slug, title, location, year, category, description, cover_image, featured) values (?, ?, ?, ?, ?, ?, ?, ?)`,
- p.Slug, p.Title, p.Location, p.Year, p.Category, p.Description, p.CoverImage, boolInt(p.Featured))
- if err != nil {
- return 0, err
- }
- return res.LastInsertId()
-}
-
-func (s *Store) UpdateProject(ctx context.Context, p Project) error {
- _, err := s.db.ExecContext(ctx, `update projects set slug=?, title=?, location=?, year=?, category=?, description=?, cover_image=?, featured=? where id=?`,
- p.Slug, p.Title, p.Location, p.Year, p.Category, p.Description, p.CoverImage, boolInt(p.Featured), p.ID)
- return err
-}
-
-func (s *Store) DeleteProject(ctx context.Context, id int64) error {
- _, err := s.db.ExecContext(ctx, `delete from projects where id = ?`, id)
- return err
-}
-
-func (s *Store) AddProjectImage(ctx context.Context, projectID int64, path, caption string) error {
- var pos int
- _ = s.db.QueryRowContext(ctx, `select coalesce(max(position), -1) + 1 from project_images where project_id = ?`, projectID).Scan(&pos)
- _, err := s.db.ExecContext(ctx, `insert into project_images (project_id, path, caption, position) values (?, ?, ?, ?)`, projectID, path, caption, pos)
- return err
-}
-
-func (s *Store) DeleteProjectImage(ctx context.Context, id int64) error {
- _, err := s.db.ExecContext(ctx, `delete from project_images where id = ?`, id)
- return err
-}
-
-func (s *Store) SaveContact(ctx context.Context, name, email, message string) error {
- _, err := s.db.ExecContext(ctx, `insert into contact_requests (name, email, message) values (?, ?, ?)`, name, email, message)
- return err
-}
-
-func (s *Store) ContactRequests(ctx context.Context) ([]ContactRequest, error) {
- rows, err := s.db.QueryContext(ctx, `select id, name, email, message, created_at from contact_requests order by created_at desc, id desc`)
- if err != nil {
- return nil, err
- }
- defer rows.Close()
- var requests []ContactRequest
- for rows.Next() {
- var r ContactRequest
- if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Message, &r.CreatedAt); err != nil {
- return nil, err
- }
- requests = append(requests, r)
- }
- return requests, rows.Err()
-}
-
-func (s *Store) AdminByUsername(ctx context.Context, username string) (AdminUser, error) {
- var u AdminUser
- err := s.db.QueryRowContext(ctx, `select id, username, password_hash from admin_users where username = ?`, username).Scan(&u.ID, &u.Username, &u.PasswordHash)
- return u, err
-}
-
-func (s *Store) CreateSession(ctx context.Context, tokenHash string, userID int64, expiresAt time.Time) error {
- _, err := s.db.ExecContext(ctx, `insert into sessions (token_hash, user_id, expires_at) values (?, ?, ?)`, tokenHash, userID, expiresAt)
- return err
-}
-
-func (s *Store) SessionUser(ctx context.Context, tokenHash string) (AdminUser, error) {
- var u AdminUser
- err := s.db.QueryRowContext(ctx, `select u.id, u.username, u.password_hash from sessions s join admin_users u on u.id = s.user_id where s.token_hash = ? and s.expires_at > ?`, tokenHash, time.Now()).
- Scan(&u.ID, &u.Username, &u.PasswordHash)
- return u, err
-}
-
-func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error {
- _, err := s.db.ExecContext(ctx, `delete from sessions where token_hash = ?`, tokenHash)
- return err
-}
-
-func IsNotFound(err error) bool {
- return errors.Is(err, sql.ErrNoRows)
-}
-
-func boolInt(v bool) int {
- if v {
- return 1
- }
- return 0
-}
-
-func SlugFromTitle(title string) string {
- out := make([]rune, 0, len(title))
- lastDash := false
- for _, r := range title {
- if r >= 'A' && r <= 'Z' {
- r += 'a' - 'A'
- }
- if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
- out = append(out, r)
- lastDash = false
- continue
- }
- if !lastDash && len(out) > 0 {
- out = append(out, '-')
- lastDash = true
- }
- }
- for len(out) > 0 && out[len(out)-1] == '-' {
- out = out[:len(out)-1]
- }
- if len(out) == 0 {
- return fmt.Sprintf("project-%d", time.Now().Unix())
- }
- return string(out)
-}