Compare commits

..

No commits in common. "7079e32a5fdfb1f7bda0b15034709fb93a7b33fb" and "fac53d7b85525653370c06e0359971513084c7f4" have entirely different histories.

35 changed files with 135 additions and 2532 deletions

View File

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

View File

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

View File

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

View File

@ -28,15 +28,6 @@ func TestAdminMutationsRedirectToOwningTabs(t *testing.T) {
form: url.Values{
"hero_title": {"Hero"}, "hero_subtitle": {"Subtitle"}, "intro_title": {"Intro"}, "intro_text": {"Text"},
"about_name": {"Name"}, "about_role": {"Role"}, "about_bio": {"Bio"},
"positioning": {"Residential architect in London"}, "hero_cta_label": {"Enquire"}, "hero_cta_url": {"/contact"},
"secondary_cta_label": {"Projects"}, "secondary_cta_url": {"/projects"},
"service_one_title": {"Homes"}, "service_one_text": {"Home text"},
"service_two_title": {"Interiors"}, "service_two_text": {"Interior text"},
"service_three_title": {"Consulting"}, "service_three_text": {"Consulting text"},
"process_one_title": {"Listen"}, "process_one_text": {"Listen text"},
"process_two_title": {"Shape"}, "process_two_text": {"Shape text"},
"process_three_title": {"Refine"}, "process_three_text": {"Refine text"},
"studio_philosophy": {"Philosophy"}, "studio_approach": {"Approach"}, "studio_credentials": {"Credentials"}, "service_area": {"London"},
"hero_image_current": {"/static/placeholders/hero.svg"}, "about_image_current": {"/static/placeholders/about.svg"},
},
want: "/admin/main?ok=content+saved",
@ -57,7 +48,7 @@ func TestAdminProjectValidation(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
form := url.Values{"title": {" "}, "location": {"London"}, "year": {"2026"}, "category": {"Residential"}, "summary": {"Summary"}, "scope": {"Scope"}, "status": {"Completed"}, "description": {"Text"}}
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)
@ -72,87 +63,3 @@ func TestAdminProjectValidation(t *testing.T) {
t.Fatalf("expected validation error redirect, got %q", location)
}
}
func TestAdminProjectCreatePersistsDepthFields(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
form := url.Values{
"title": {"Garden Studio"},
"location": {"London"},
"year": {"2026"},
"category": {"Residential"},
"summary": {"A compact studio in a rear garden."},
"scope": {"Architecture, interiors"},
"status": {"In progress"},
"position": {"12"},
"description": {"A small project with careful storage and daylight."},
"featured": {"on"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/projects", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/projects?ok=project+created" {
t.Fatalf("expected create redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
}
project, err := srv.store.ProjectBySlug(t.Context(), "garden-studio")
if err != nil {
t.Fatal(err)
}
if project.Summary != "A compact studio in a rear garden." || project.Scope != "Architecture, interiors" || project.Status != "In progress" || project.Position != 12 {
t.Fatalf("unexpected project depth fields: %+v", project)
}
}
func TestAdminServiceAndFAQMutations(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
cookie := loginCookie(t, handler)
serviceForm := url.Values{
"title": {"Planning advice"},
"summary": {"Early advice for planning routes."},
"details": {"Detailed planning route guidance."},
"position": {"8"},
"active": {"on"},
}
req := httptest.NewRequest(http.MethodPost, "/admin/services", strings.NewReader(serviceForm.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/services?ok=service+created" {
t.Fatalf("expected service redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
}
faqForm := url.Values{
"question": {"Can you help before purchase?"},
"answer": {"Yes, early consultation can clarify feasibility."},
"position": {"9"},
"active": {"on"},
}
req = httptest.NewRequest(http.MethodPost, "/admin/faqs", strings.NewReader(faqForm.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rec = httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/admin/services?ok=FAQ+created" {
t.Fatalf("expected FAQ redirect, got %d %q", rec.Code, rec.Header().Get("Location"))
}
services, err := srv.store.Services(t.Context(), false)
if err != nil {
t.Fatal(err)
}
faqs, err := srv.store.FAQs(t.Context(), false)
if err != nil {
t.Fatal(err)
}
if len(services) < 5 || len(faqs) < 4 {
t.Fatalf("expected created service and FAQ, got services=%d faqs=%d", len(services), len(faqs))
}
}

View File

@ -24,15 +24,6 @@ func (s *Server) adminProjects(w http.ResponseWriter, r *http.Request) {
s.renderAdmin(w, r, "admin_projects.html", "admin_projects_partial.html", data)
}
func (s *Server) adminServices(w http.ResponseWriter, r *http.Request) {
data, err := s.adminData(r, "services")
if err != nil {
s.error(w, err)
return
}
s.renderAdmin(w, r, "admin_services.html", "admin_services_partial.html", data)
}
func (s *Server) adminContactDetails(w http.ResponseWriter, r *http.Request) {
data, err := s.adminData(r, "contact-details")
if err != nil {
@ -62,18 +53,6 @@ func (s *Server) adminData(r *http.Request, tab string) (pageData, error) {
}
data.Projects = projects
}
if tab == "services" {
services, err := s.store.Services(r.Context(), false)
if err != nil {
return pageData{}, err
}
faqs, err := s.store.FAQs(r.Context(), false)
if err != nil {
return pageData{}, err
}
data.Services = services
data.FAQs = faqs
}
if tab == "contact-details" {
contacts, err := s.store.ContactRequests(r.Context())
if err != nil {

View File

@ -27,7 +27,6 @@ func TestAdminTabs(t *testing.T) {
}{
{"/admin/main", "Main Content"},
{"/admin/projects", "Add Project"},
{"/admin/services", "Add service"},
{"/admin/contact-details", "Contact Requests"},
} {
req := httptest.NewRequest(http.MethodGet, test.path, nil)
@ -49,7 +48,7 @@ func TestAdminHTMXTabRequestReturnsPartial(t *testing.T) {
handler := srv.Routes()
cookie := loginCookie(t, handler)
req := httptest.NewRequest(http.MethodGet, "/admin/services", nil)
req := httptest.NewRequest(http.MethodGet, "/admin/projects", nil)
req.Header.Set("HX-Request", "true")
req.AddCookie(cookie)
rec := httptest.NewRecorder()
@ -63,7 +62,7 @@ func TestAdminHTMXTabRequestReturnsPartial(t *testing.T) {
if strings.Contains(text, "<!doctype html>") {
t.Fatalf("expected partial response, got full document: %s", text)
}
if !strings.Contains(text, `hx-swap-oob="true"`) || !strings.Contains(text, "Add service") {
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)
}
}

View File

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

View File

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

View File

@ -19,7 +19,7 @@ func (s *Server) home(w http.ResponseWriter, r *http.Request) {
s.error(w, err)
return
}
s.renderPublic(w, r, "home.html", "home_partial.html", pageData{Title: content.HeroTitle, Active: "home", Content: content, Projects: projects, CurrentPath: r.URL.Path})
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) {
@ -33,7 +33,7 @@ func (s *Server) projects(w http.ResponseWriter, r *http.Request) {
s.error(w, err)
return
}
s.renderPublic(w, r, "projects.html", "projects_partial.html", pageData{Title: "Projects", Active: "projects", Content: content, Projects: projects, CurrentPath: r.URL.Path})
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) {
@ -51,7 +51,7 @@ func (s *Server) projectDetail(w http.ResponseWriter, r *http.Request) {
s.error(w, err)
return
}
s.renderPublic(w, r, "project.html", "project_partial.html", pageData{Title: project.Title, Active: "projects", Content: content, Project: project, CurrentPath: r.URL.Path})
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) {
@ -78,35 +78,7 @@ func (s *Server) about(w http.ResponseWriter, r *http.Request) {
s.error(w, err)
return
}
s.renderPublic(w, r, "about.html", "about_partial.html", pageData{Title: "Studio", Active: "about", Content: content, CurrentPath: r.URL.Path})
}
func (s *Server) services(w http.ResponseWriter, r *http.Request) {
content, err := s.store.SiteContent(r.Context())
if err != nil {
s.error(w, err)
return
}
services, err := s.store.Services(r.Context(), true)
if err != nil {
s.error(w, err)
return
}
faqs, err := s.store.FAQs(r.Context(), true)
if err != nil {
s.error(w, err)
return
}
s.renderPublic(w, r, "services.html", "services_partial.html", pageData{Title: "Services", Active: "services", Content: content, Services: services, FAQs: faqs, CurrentPath: r.URL.Path})
}
func (s *Server) contactPage(w http.ResponseWriter, r *http.Request) {
content, err := s.store.SiteContent(r.Context())
if err != nil {
s.error(w, err)
return
}
s.renderPublic(w, r, "contact.html", "contact_partial.html", pageData{Title: "Contact", Active: "contact", Content: content, CurrentPath: r.URL.Path})
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) {
@ -114,22 +86,14 @@ func (s *Server) contact(w http.ResponseWriter, r *http.Request) {
s.render(w, "contact_result.html", pageData{Error: "Please check the form and try again."})
return
}
request := store.ContactRequest{
Name: strings.TrimSpace(r.FormValue("name")),
Email: strings.TrimSpace(r.FormValue("email")),
Phone: strings.TrimSpace(r.FormValue("phone")),
ProjectType: strings.TrimSpace(r.FormValue("project_type")),
ProjectLocation: strings.TrimSpace(r.FormValue("project_location")),
BudgetRange: strings.TrimSpace(r.FormValue("budget_range")),
Timeline: strings.TrimSpace(r.FormValue("timeline")),
Message: strings.TrimSpace(r.FormValue("message")),
Status: "new",
}
if err := validateContactRequest(request); err != nil {
s.render(w, "contact_result.html", pageData{Error: err.Error()})
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(), request); err != nil {
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
}

View File

@ -14,7 +14,7 @@ func TestPublicRoutes(t *testing.T) {
srv := newTestServer(t)
handler := srv.Routes()
for _, path := range []string{"/", "/projects", "/about", "/services", "/contact", "/projects/courtyard-house"} {
for _, path := range []string{"/", "/projects", "/about", "/projects/courtyard-house"} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@ -24,117 +24,9 @@ func TestPublicRoutes(t *testing.T) {
}
}
func TestPublicRoutesRenderFullShellOnDirectLoad(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/services", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
for _, want := range []string{"<!doctype html>", `class="drawer drawer-end"`, `id="main-content"`, `id="site-drawer"`} {
if !strings.Contains(text, want) {
t.Fatalf("direct public route missing %q: %s", want, text)
}
}
}
func TestPublicHTMXRoutesReturnMainContentPartial(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/services", nil)
req.Header.Set("HX-Request", "true")
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
if strings.Contains(text, "<!doctype html>") || strings.Contains(text, `class="drawer drawer-end"`) {
t.Fatalf("expected partial response, got full document: %s", text)
}
for _, want := range []string{`id="main-content"`, `id="site-header"`, `id="site-drawer-nav"`, `hx-swap-oob="true"`} {
if !strings.Contains(text, want) {
t.Fatalf("HTMX public route missing %q: %s", want, text)
}
}
}
func TestServicesRouteRendersServicesAndFAQs(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/services", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
for _, want := range []string{"Residential architecture", "How projects work", "FAQs", "Start an enquiry"} {
if !strings.Contains(text, want) {
t.Fatalf("services page missing %q: %s", want, text)
}
}
}
func TestHomeRendersPhaseTwoSections(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
for _, want := range []string{"Start an enquiry", "Focused support", "Process", "Studio profile"} {
if !strings.Contains(text, want) {
t.Fatalf("home missing %q: %s", want, text)
}
}
}
func TestStudioRendersExpandedContent(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/about", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
for _, want := range []string{"Philosophy", "Approach", "Experience", "Service area"} {
if !strings.Contains(text, want) {
t.Fatalf("studio missing %q: %s", want, text)
}
}
}
func TestContactSubmissionPersists(t *testing.T) {
srv := newTestServer(t)
form := url.Values{
"name": {"Jane"},
"email": {"jane@example.com"},
"phone": {"123"},
"project_type": {"Renovation or extension"},
"project_location": {"London"},
"budget_range": {"GBP 250k-500k"},
"timeline": {"3-6 months"},
"message": {"New project"},
}
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()
@ -152,29 +44,11 @@ func TestContactSubmissionPersists(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if len(requests) != 1 || requests[0].Email != "jane@example.com" || requests[0].ProjectType != "Renovation or extension" || requests[0].Status != "new" {
if len(requests) != 1 || requests[0].Email != "jane@example.com" {
t.Fatalf("unexpected contact requests: %+v", requests)
}
}
func TestContactSubmissionRequiresQualificationFields(t *testing.T) {
srv := newTestServer(t)
form := url.Values{"name": {"Jane"}, "email": {"jane@example.com"}, "message": {"New project"}}
req := httptest.NewRequest(http.MethodPost, "/contact", strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
if !strings.Contains(string(body), "project type is required") {
t.Fatalf("expected qualification validation message, got %s", body)
}
}
func TestProjectImageOverlay(t *testing.T) {
srv := newTestServer(t)
project, err := srv.store.ProjectBySlug(t.Context(), "courtyard-house")
@ -198,22 +72,3 @@ func TestProjectImageOverlay(t *testing.T) {
t.Fatalf("overlay fragment missing expected content: %s", body)
}
}
func TestProjectDetailRendersDepthFields(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest(http.MethodGet, "/projects/courtyard-house", nil)
rec := httptest.NewRecorder()
srv.Routes().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("expected ok, got %d", rec.Code)
}
body, _ := io.ReadAll(rec.Result().Body)
text := string(body)
for _, want := range []string{"Scope", "Completed", "Architecture, interiors, material strategy", "A private house arranged around a quiet internal garden."} {
if !strings.Contains(text, want) {
t.Fatalf("project detail missing %q: %s", want, text)
}
}
}

View File

@ -25,14 +25,6 @@ func (s *Server) renderAdmin(w http.ResponseWriter, r *http.Request, fullTemplat
s.render(w, fullTemplate, data)
}
func (s *Server) renderPublic(w http.ResponseWriter, r *http.Request, fullTemplate, partialTemplate string, data pageData) {
if r.Header.Get("HX-Request") == "true" {
s.render(w, partialTemplate, data)
return
}
s.render(w, fullTemplate, data)
}
func (s *Server) error(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusInternalServerError)
}

View File

@ -10,16 +10,11 @@ func (s *Server) Routes() http.Handler {
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServer(http.Dir(filepath.Join(assetRoot(), "static")))))
mux.Handle("GET /uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(s.cfg.UploadDir))))
mux.HandleFunc("GET /healthz", s.healthz)
mux.HandleFunc("GET /readyz", s.readyz)
mux.HandleFunc("GET /", s.home)
mux.HandleFunc("GET /projects", s.projects)
mux.HandleFunc("GET /projects/{slug}", s.projectDetail)
mux.HandleFunc("GET /projects/{slug}/images/{imageID}/overlay", s.projectImageOverlay)
mux.HandleFunc("GET /about", s.about)
mux.HandleFunc("GET /services", s.services)
mux.HandleFunc("GET /contact", s.contactPage)
mux.HandleFunc("POST /contact", s.contact)
mux.HandleFunc("GET /admin/login", s.adminLogin)
@ -29,16 +24,9 @@ func (s *Server) Routes() http.Handler {
mux.Handle("GET /admin", s.requireAdmin(http.HandlerFunc(s.adminRedirect)))
mux.Handle("GET /admin/main", s.requireAdmin(http.HandlerFunc(s.adminMain)))
mux.Handle("GET /admin/projects", s.requireAdmin(http.HandlerFunc(s.adminProjects)))
mux.Handle("GET /admin/services", s.requireAdmin(http.HandlerFunc(s.adminServices)))
mux.Handle("GET /admin/contact-details", s.requireAdmin(http.HandlerFunc(s.adminContactDetails)))
mux.Handle("POST /admin/content", s.requireAdmin(http.HandlerFunc(s.adminUpdateContent)))
mux.Handle("POST /admin/contact-details", s.requireAdmin(http.HandlerFunc(s.adminUpdateContactDetails)))
mux.Handle("POST /admin/services", s.requireAdmin(http.HandlerFunc(s.adminCreateService)))
mux.Handle("POST /admin/services/{id}", s.requireAdmin(http.HandlerFunc(s.adminUpdateService)))
mux.Handle("POST /admin/services/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteService)))
mux.Handle("POST /admin/faqs", s.requireAdmin(http.HandlerFunc(s.adminCreateFAQ)))
mux.Handle("POST /admin/faqs/{id}", s.requireAdmin(http.HandlerFunc(s.adminUpdateFAQ)))
mux.Handle("POST /admin/faqs/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteFAQ)))
mux.Handle("POST /admin/projects", s.requireAdmin(http.HandlerFunc(s.adminCreateProject)))
mux.Handle("POST /admin/projects/{id}", s.requireAdmin(http.HandlerFunc(s.adminUpdateProject)))
mux.Handle("POST /admin/projects/{id}/delete", s.requireAdmin(http.HandlerFunc(s.adminDeleteProject)))

View File

@ -24,8 +24,6 @@ type pageData struct {
Projects []store.Project
Project store.Project
Image store.ProjectImage
Services []store.Service
FAQs []store.FAQ
Contacts []store.ContactRequest
Admin bool
AdminTab string

View File

@ -13,34 +13,16 @@ func validateContent(c store.SiteContent) error {
return errors.New("hero title is required")
case c.HeroSubtitle == "":
return errors.New("hero subtitle is required")
case c.Positioning == "":
return errors.New("positioning is required")
case c.HeroCTALabel == "" || c.HeroCTAURL == "":
return errors.New("primary hero CTA is required")
case c.SecondaryCTALabel == "" || c.SecondaryCTAURL == "":
return errors.New("secondary hero CTA is required")
case c.IntroTitle == "":
return errors.New("intro title is required")
case c.IntroText == "":
return errors.New("intro text is required")
case c.ServiceOneTitle == "" || c.ServiceOneText == "" || c.ServiceTwoTitle == "" || c.ServiceTwoText == "" || c.ServiceThreeTitle == "" || c.ServiceThreeText == "":
return errors.New("three service preview items are required")
case c.ProcessOneTitle == "" || c.ProcessOneText == "" || c.ProcessTwoTitle == "" || c.ProcessTwoText == "" || c.ProcessThreeTitle == "" || c.ProcessThreeText == "":
return errors.New("three process steps are required")
case c.AboutName == "":
return errors.New("about name is required")
case c.AboutRole == "":
return errors.New("about role is required")
case c.AboutBio == "":
return errors.New("about bio is required")
case c.StudioPhilosophy == "":
return errors.New("studio philosophy is required")
case c.StudioApproach == "":
return errors.New("studio approach is required")
case c.StudioCredentials == "":
return errors.New("studio credentials are required")
case c.ServiceArea == "":
return errors.New("service area is required")
case c.HeroImage == "":
return errors.New("hero image is required")
case c.AboutImage == "":
@ -63,43 +45,6 @@ func validateContactDetails(c store.SiteContent) error {
}
}
func validateContactRequest(r store.ContactRequest) error {
switch {
case r.Name == "":
return errors.New("name is required")
case r.Email == "" || !strings.Contains(r.Email, "@"):
return errors.New("valid email is required")
case r.ProjectType == "":
return errors.New("project type is required")
case r.ProjectLocation == "":
return errors.New("project location is required")
case r.BudgetRange == "":
return errors.New("budget range is required")
case r.Timeline == "":
return errors.New("timeline is required")
case r.Message == "":
return errors.New("project message is required")
case len(r.Name) > 120:
return errors.New("name is too long")
case len(r.Email) > 180:
return errors.New("email is too long")
case len(r.Phone) > 80:
return errors.New("phone is too long")
case len(r.ProjectType) > 120:
return errors.New("project type is too long")
case len(r.ProjectLocation) > 180:
return errors.New("project location is too long")
case len(r.BudgetRange) > 120:
return errors.New("budget range is too long")
case len(r.Timeline) > 120:
return errors.New("timeline is too long")
case len(r.Message) > 3000:
return errors.New("project message is too long")
default:
return nil
}
}
func validateProject(p store.Project) error {
switch {
case p.Slug == "":
@ -112,56 +57,10 @@ func validateProject(p store.Project) error {
return errors.New("project year is required")
case p.Category == "":
return errors.New("project category is required")
case p.Summary == "":
return errors.New("project summary is required")
case p.Scope == "":
return errors.New("project scope is required")
case p.Status == "":
return errors.New("project status is required")
case p.Description == "":
return errors.New("project description is required")
case p.CoverImage == "":
return errors.New("project cover image is required")
case len(p.Summary) > 500:
return errors.New("project summary is too long")
case len(p.Scope) > 240:
return errors.New("project scope is too long")
case len(p.Status) > 80:
return errors.New("project status is too long")
default:
return nil
}
}
func validateService(service store.Service) error {
switch {
case service.Title == "":
return errors.New("service title is required")
case service.Summary == "":
return errors.New("service summary is required")
case service.Details == "":
return errors.New("service details are required")
case len(service.Title) > 160:
return errors.New("service title is too long")
case len(service.Summary) > 500:
return errors.New("service summary is too long")
case len(service.Details) > 2000:
return errors.New("service details are too long")
default:
return nil
}
}
func validateFAQ(faq store.FAQ) error {
switch {
case faq.Question == "":
return errors.New("FAQ question is required")
case faq.Answer == "":
return errors.New("FAQ answer is required")
case len(faq.Question) > 240:
return errors.New("FAQ question is too long")
case len(faq.Answer) > 2000:
return errors.New("FAQ answer is too long")
default:
return nil
}

View File

@ -2,26 +2,13 @@ package store
import "context"
func (s *Store) SaveContact(ctx context.Context, request ContactRequest) error {
_, err := s.db.ExecContext(ctx, `insert into contact_requests (
name, email, phone, project_type, project_location, budget_range, timeline, message, status, notes
) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
request.Name,
request.Email,
request.Phone,
request.ProjectType,
request.ProjectLocation,
request.BudgetRange,
request.Timeline,
request.Message,
coalesceString(request.Status, "new"),
request.Notes,
)
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, phone, project_type, project_location, budget_range, timeline, message, status, notes, created_at from contact_requests order by created_at desc, id desc`)
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
}
@ -29,17 +16,10 @@ func (s *Store) ContactRequests(ctx context.Context) ([]ContactRequest, error) {
var requests []ContactRequest
for rows.Next() {
var r ContactRequest
if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Phone, &r.ProjectType, &r.ProjectLocation, &r.BudgetRange, &r.Timeline, &r.Message, &r.Status, &r.Notes, &r.CreatedAt); err != nil {
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 coalesceString(value, fallback string) string {
if value == "" {
return fallback
}
return value
}

View File

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

View File

@ -31,96 +31,3 @@ func TestMigrateUpdatesAdminCredentials(t *testing.T) {
t.Fatalf("expected old username to be removed, got %v", err)
}
}
func TestContactRequestsSupportQualificationFields(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate("admin", "password"); err != nil {
t.Fatal(err)
}
err = st.SaveContact(t.Context(), ContactRequest{
Name: "Jane",
Email: "jane@example.com",
Phone: "123",
ProjectType: "Renovation",
ProjectLocation: "London",
BudgetRange: "GBP 250k-500k",
Timeline: "3-6 months",
Message: "Project notes",
})
if err != nil {
t.Fatal(err)
}
requests, err := st.ContactRequests(t.Context())
if err != nil {
t.Fatal(err)
}
if len(requests) != 1 || requests[0].ProjectLocation != "London" || requests[0].Status != "new" {
t.Fatalf("unexpected request fields: %+v", requests)
}
}
func TestSiteContentIncludesPhaseTwoFields(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate("admin", "password"); err != nil {
t.Fatal(err)
}
content, err := st.SiteContent(t.Context())
if err != nil {
t.Fatal(err)
}
if content.Positioning == "" || content.HeroCTALabel == "" || content.ServiceOneTitle == "" || content.StudioPhilosophy == "" {
t.Fatalf("expected seeded phase two content, got %+v", content)
}
}
func TestServicesAndFAQsAreSeeded(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate("admin", "password"); err != nil {
t.Fatal(err)
}
services, err := st.Services(t.Context(), true)
if err != nil {
t.Fatal(err)
}
faqs, err := st.FAQs(t.Context(), true)
if err != nil {
t.Fatal(err)
}
if len(services) < 4 || len(faqs) < 3 {
t.Fatalf("expected seeded services and FAQs, got services=%d faqs=%d", len(services), len(faqs))
}
}
func TestSeededProjectsIncludeDepthFields(t *testing.T) {
st, err := Open(filepath.Join(t.TempDir(), "app.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate("admin", "password"); err != nil {
t.Fatal(err)
}
project, err := st.ProjectBySlug(t.Context(), "courtyard-house")
if err != nil {
t.Fatal(err)
}
if project.Summary == "" || project.Scope == "" || project.Status == "" || project.Position == 0 {
t.Fatalf("expected project depth fields, got %+v", project)
}
}

View File

@ -6,11 +6,11 @@ import (
)
func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, error) {
query := `select id, slug, title, location, year, category, summary, scope, status, position, description, cover_image, featured, created_at from projects`
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 position asc, created_at desc, id desc`
query += ` order by created_at desc, id desc`
rows, err := s.db.QueryContext(ctx, query)
if err != nil {
return nil, err
@ -20,7 +20,7 @@ func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, err
for rows.Next() {
var p Project
var featured int
if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Summary, &p.Scope, &p.Status, &p.Position, &p.Description, &p.CoverImage, &featured, &p.CreatedAt); err != nil {
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
@ -32,8 +32,8 @@ func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, err
func (s *Store) ProjectBySlug(ctx context.Context, slug string) (Project, error) {
var p Project
var featured int
err := s.db.QueryRowContext(ctx, `select id, slug, title, location, year, category, summary, scope, status, position, description, cover_image, featured, created_at from projects where slug = ?`, slug).
Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Summary, &p.Scope, &p.Status, &p.Position, &p.Description, &p.CoverImage, &featured, &p.CreatedAt)
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
}
@ -90,8 +90,8 @@ func (s *Store) ProjectImageForSlug(ctx context.Context, slug string, imageID in
}
func (s *Store) CreateProject(ctx context.Context, p Project) (int64, error) {
res, err := s.db.ExecContext(ctx, `insert into projects (slug, title, location, year, category, summary, scope, status, position, description, cover_image, featured) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
p.Slug, p.Title, p.Location, p.Year, p.Category, p.Summary, p.Scope, p.Status, p.Position, p.Description, p.CoverImage, boolInt(p.Featured))
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
}
@ -99,8 +99,8 @@ func (s *Store) CreateProject(ctx context.Context, p Project) (int64, error) {
}
func (s *Store) UpdateProject(ctx context.Context, p Project) error {
_, err := s.db.ExecContext(ctx, `update projects set slug=?, title=?, location=?, year=?, category=?, summary=?, scope=?, status=?, position=?, description=?, cover_image=?, featured=? where id=?`,
p.Slug, p.Title, p.Location, p.Year, p.Category, p.Summary, p.Scope, p.Status, p.Position, p.Description, p.CoverImage, boolInt(p.Featured), p.ID)
_, 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
}

View File

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

View File

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

View File

@ -1,7 +1,6 @@
package store
import (
"context"
"database/sql"
"os"
"path/filepath"
@ -17,32 +16,11 @@ type Store struct {
type SiteContent struct {
HeroTitle string
HeroSubtitle string
Positioning string
HeroCTALabel string
HeroCTAURL string
SecondaryCTALabel string
SecondaryCTAURL string
IntroTitle string
IntroText string
ServiceOneTitle string
ServiceOneText string
ServiceTwoTitle string
ServiceTwoText string
ServiceThreeTitle string
ServiceThreeText string
ProcessOneTitle string
ProcessOneText string
ProcessTwoTitle string
ProcessTwoText string
ProcessThreeTitle string
ProcessThreeText string
AboutName string
AboutRole string
AboutBio string
StudioPhilosophy string
StudioApproach string
StudioCredentials string
ServiceArea string
Email string
Phone string
Location string
@ -57,10 +35,6 @@ type Project struct {
Location string
Year string
Category string
Summary string
Scope string
Status string
Position int
Description string
CoverImage string
Featured bool
@ -76,37 +50,11 @@ type ProjectImage struct {
Position int
}
type Service struct {
ID int64
Title string
Summary string
Details string
Position int
Active bool
CreatedAt time.Time
}
type FAQ struct {
ID int64
Question string
Answer string
Position int
Active bool
CreatedAt time.Time
}
type ContactRequest struct {
ID int64
Name string
Email string
Phone string
ProjectType string
ProjectLocation string
BudgetRange string
Timeline string
Message string
Status string
Notes string
CreatedAt time.Time
}
@ -131,7 +79,3 @@ func Open(path string) (*Store, error) {
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) Ping(ctx context.Context) error {
return s.db.PingContext(ctx)
}

View File

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

View File

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

View File

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

View File

@ -33,7 +33,6 @@
<nav class="mx-auto flex max-w-7xl gap-2 overflow-x-auto px-5 py-3 text-sm md:px-8" aria-label="Admin sections">
<a href="/admin/main" hx-get="/admin/main" hx-target="#admin-panel" hx-push-url="true" hx-swap="innerHTML transition:true" class="whitespace-nowrap px-3 py-2 {{if eq .AdminTab "main"}}bg-neutral-950 text-white{{else}}text-neutral-500 hover:bg-neutral-100 hover:text-neutral-950{{end}}">Main</a>
<a href="/admin/projects" hx-get="/admin/projects" hx-target="#admin-panel" hx-push-url="true" hx-swap="innerHTML transition:true" class="whitespace-nowrap px-3 py-2 {{if eq .AdminTab "projects"}}bg-neutral-950 text-white{{else}}text-neutral-500 hover:bg-neutral-100 hover:text-neutral-950{{end}}">Projects</a>
<a href="/admin/services" hx-get="/admin/services" hx-target="#admin-panel" hx-push-url="true" hx-swap="innerHTML transition:true" class="whitespace-nowrap px-3 py-2 {{if eq .AdminTab "services"}}bg-neutral-950 text-white{{else}}text-neutral-500 hover:bg-neutral-100 hover:text-neutral-950{{end}}">Services</a>
<a href="/admin/contact-details" hx-get="/admin/contact-details" hx-target="#admin-panel" hx-push-url="true" hx-swap="innerHTML transition:true" class="whitespace-nowrap px-3 py-2 {{if eq .AdminTab "contact-details"}}bg-neutral-950 text-white{{else}}text-neutral-500 hover:bg-neutral-100 hover:text-neutral-950{{end}}">Contact Details</a>
</nav>
{{end}}

View File

@ -28,15 +28,9 @@
{{range .Contacts}}
<article class="border border-neutral-200 p-4">
<div class="mb-2 flex flex-col gap-1 text-sm md:flex-row md:items-center md:justify-between">
<p class="font-medium">{{.Name}} · {{.Email}}{{if .Phone}} · {{.Phone}}{{end}}</p>
<p class="text-neutral-500">{{.Status}} · {{.CreatedAt.Format "2006-01-02 15:04"}}</p>
<p class="font-medium">{{.Name}} · {{.Email}}</p>
<p class="text-neutral-500">{{.CreatedAt.Format "2006-01-02 15:04"}}</p>
</div>
<dl class="mb-3 grid gap-2 text-sm text-neutral-600 md:grid-cols-4">
<div><dt class="text-neutral-400">Type</dt><dd>{{.ProjectType}}</dd></div>
<div><dt class="text-neutral-400">Location</dt><dd>{{.ProjectLocation}}</dd></div>
<div><dt class="text-neutral-400">Budget</dt><dd>{{.BudgetRange}}</dd></div>
<div><dt class="text-neutral-400">Timeline</dt><dd>{{.Timeline}}</dd></div>
</dl>
<p class="text-neutral-700">{{.Message}}</p>
</article>
{{else}}

View File

@ -16,53 +16,15 @@
<form method="post" action="/admin/content" enctype="multipart/form-data" class="grid gap-5">
<input type="hidden" name="hero_image_current" value="{{.Content.HeroImage}}">
<input type="hidden" name="about_image_current" value="{{.Content.AboutImage}}">
<h2 class="text-lg font-semibold">Home Hero</h2>
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Hero title</span><input name="hero_title" value="{{.Content.HeroTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Hero subtitle</span><input name="hero_subtitle" value="{{.Content.HeroSubtitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Positioning</span><input name="positioning" value="{{.Content.Positioning}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Primary CTA label</span><input name="hero_cta_label" value="{{.Content.HeroCTALabel}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Primary CTA URL</span><input name="hero_cta_url" value="{{.Content.HeroCTAURL}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Secondary CTA label</span><input name="secondary_cta_label" value="{{.Content.SecondaryCTALabel}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Secondary CTA URL</span><input name="secondary_cta_url" value="{{.Content.SecondaryCTAURL}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Intro title</span><input name="intro_title" value="{{.Content.IntroTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">About name</span><input name="about_name" value="{{.Content.AboutName}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">About role</span><input name="about_role" value="{{.Content.AboutRole}}" class="w-full border px-3 py-2"></label>
</div>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Intro text</span><textarea name="intro_text" rows="3" class="w-full border px-3 py-2">{{.Content.IntroText}}</textarea></label>
<h2 class="border-t border-neutral-200 pt-5 text-lg font-semibold">Service Preview</h2>
<div class="grid gap-4 md:grid-cols-3">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 1 title</span><input name="service_one_title" value="{{.Content.ServiceOneTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 2 title</span><input name="service_two_title" value="{{.Content.ServiceTwoTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 3 title</span><input name="service_three_title" value="{{.Content.ServiceThreeTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 1 text</span><textarea name="service_one_text" rows="4" class="w-full border px-3 py-2">{{.Content.ServiceOneText}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 2 text</span><textarea name="service_two_text" rows="4" class="w-full border px-3 py-2">{{.Content.ServiceTwoText}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service 3 text</span><textarea name="service_three_text" rows="4" class="w-full border px-3 py-2">{{.Content.ServiceThreeText}}</textarea></label>
</div>
<h2 class="border-t border-neutral-200 pt-5 text-lg font-semibold">Process Preview</h2>
<div class="grid gap-4 md:grid-cols-3">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 1 title</span><input name="process_one_title" value="{{.Content.ProcessOneTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 2 title</span><input name="process_two_title" value="{{.Content.ProcessTwoTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 3 title</span><input name="process_three_title" value="{{.Content.ProcessThreeTitle}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 1 text</span><textarea name="process_one_text" rows="4" class="w-full border px-3 py-2">{{.Content.ProcessOneText}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 2 text</span><textarea name="process_two_text" rows="4" class="w-full border px-3 py-2">{{.Content.ProcessTwoText}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Step 3 text</span><textarea name="process_three_text" rows="4" class="w-full border px-3 py-2">{{.Content.ProcessThreeText}}</textarea></label>
</div>
<h2 class="border-t border-neutral-200 pt-5 text-lg font-semibold">Studio</h2>
<div class="grid gap-4 md:grid-cols-3">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Studio name</span><input name="about_name" value="{{.Content.AboutName}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Role</span><input name="about_role" value="{{.Content.AboutRole}}" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Service area</span><input name="service_area" value="{{.Content.ServiceArea}}" class="w-full border px-3 py-2"></label>
</div>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">About bio</span><textarea name="about_bio" rows="4" class="w-full border px-3 py-2">{{.Content.AboutBio}}</textarea></label>
<div class="grid gap-4 md:grid-cols-3">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Studio philosophy</span><textarea name="studio_philosophy" rows="5" class="w-full border px-3 py-2">{{.Content.StudioPhilosophy}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Studio approach</span><textarea name="studio_approach" rows="5" class="w-full border px-3 py-2">{{.Content.StudioApproach}}</textarea></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Credentials / experience</span><textarea name="studio_credentials" rows="5" class="w-full border px-3 py-2">{{.Content.StudioCredentials}}</textarea></label>
</div>
<h2 class="border-t border-neutral-200 pt-5 text-lg font-semibold">Images</h2>
<div class="grid gap-4 md:grid-cols-2">
<label class="block text-sm"><span class="mb-2 block text-neutral-500">Hero image</span><input name="hero_image" type="file" accept="image/png,image/jpeg,image/webp,image/gif" class="w-full border px-3 py-2"></label>
<label class="block text-sm"><span class="mb-2 block text-neutral-500">About image</span><input name="about_image" type="file" accept="image/png,image/jpeg,image/webp,image/gif" class="w-full border px-3 py-2"></label>

View File

@ -20,11 +20,7 @@
<input name="location" required placeholder="Location" class="border px-3 py-2">
<input name="year" required placeholder="Year" class="border px-3 py-2">
<input name="category" required placeholder="Category" class="border px-3 py-2">
<input name="scope" required placeholder="Scope" class="border px-3 py-2">
<input name="status" required placeholder="Status" value="Completed" class="border px-3 py-2">
<input name="position" type="number" value="0" class="border px-3 py-2">
<label class="flex items-center gap-2 border px-3 py-2 text-sm"><input name="featured" type="checkbox"> Featured</label>
<textarea name="summary" required rows="3" placeholder="Short card summary" class="border px-3 py-2 md:col-span-2"></textarea>
<textarea name="description" required rows="4" placeholder="Description" class="border px-3 py-2 md:col-span-2"></textarea>
<input name="cover_image" type="file" accept="image/png,image/jpeg,image/webp,image/gif" class="border px-3 py-2 md:col-span-2">
<button class="w-fit bg-neutral-950 px-5 py-3 text-sm uppercase tracking-[0.18em] text-white md:col-span-2">Create project</button>
@ -42,11 +38,7 @@
<input name="location" value="{{.Location}}" class="border px-3 py-2">
<input name="year" value="{{.Year}}" class="border px-3 py-2">
<input name="category" value="{{.Category}}" class="border px-3 py-2">
<input name="scope" value="{{.Scope}}" class="border px-3 py-2">
<input name="status" value="{{.Status}}" class="border px-3 py-2">
<input name="position" type="number" value="{{.Position}}" class="border px-3 py-2">
<label class="flex items-center gap-2 border px-3 py-2 text-sm"><input name="featured" type="checkbox" {{if .Featured}}checked{{end}}> Featured</label>
<textarea name="summary" rows="3" class="border px-3 py-2 md:col-span-2">{{.Summary}}</textarea>
<textarea name="description" rows="4" class="border px-3 py-2 md:col-span-2">{{.Description}}</textarea>
<div class="grid gap-3 md:col-span-2 md:grid-cols-[120px_1fr] md:items-center">
<img src="{{.CoverImage}}" alt="" class="h-24 w-24 object-cover">

View File

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

View File

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

View File

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

View File

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

View File

@ -1,37 +1,17 @@
{{define "project.html"}}
{{template "public_shell_start" .}}
{{template "project_content" .}}
{{template "public_shell_end" .}}
{{end}}
{{define "project_partial.html"}}
{{template "public_nav_oob" .}}
{{template "project_content" .}}
{{end}}
{{define "project_content"}}
<main id="main-content" class="pt-28 md:pt-36">
{{template "head" .}}
{{template "site_header" .}}
<main class="pt-28 md:pt-36">
<section class="mx-auto max-w-7xl px-5 md:px-8">
<p class="mb-4 text-sm uppercase tracking-[0.2em] text-neutral-500">{{.Project.Category}} · {{.Project.Status}}</p>
<p class="mb-4 text-sm uppercase tracking-[0.2em] text-neutral-500">{{.Project.Category}}</p>
<div class="grid gap-8 md:grid-cols-[1.2fr_0.8fr] md:items-end">
<h1 class="text-5xl font-semibold leading-none md:text-8xl">{{.Project.Title}}</h1>
<div class="grid grid-cols-2 gap-4 border-t border-neutral-200 pt-4 text-sm">
<div class="grid grid-cols-3 gap-4 border-t border-neutral-200 pt-4 text-sm">
<div><p class="text-neutral-500">Location</p><p>{{.Project.Location}}</p></div>
<div><p class="text-neutral-500">Year</p><p>{{.Project.Year}}</p></div>
<div><p class="text-neutral-500">Type</p><p>{{.Project.Category}}</p></div>
<div><p class="text-neutral-500">Status</p><p>{{.Project.Status}}</p></div>
</div>
</div>
<div class="mt-10 grid gap-8 md:grid-cols-[0.8fr_1.2fr]">
<div class="border-t border-neutral-200 pt-4">
<p class="mb-2 text-sm text-neutral-500">Scope</p>
<p class="leading-relaxed">{{.Project.Scope}}</p>
</div>
<div>
<p class="mb-4 text-2xl leading-snug text-neutral-800">{{.Project.Summary}}</p>
<p class="max-w-3xl text-xl leading-relaxed text-neutral-600">{{.Project.Description}}</p>
</div>
</div>
<p class="mt-10 max-w-3xl text-xl leading-relaxed text-neutral-600">{{.Project.Description}}</p>
</section>
<section class="mx-auto mt-14 max-w-7xl px-5 pb-24 md:px-8">
@ -45,4 +25,7 @@
</div>
</section>
</main>
{{end}}
{{template "footer" .}}
<div id="overlay-root"></div>
</body>
</html>

View File

@ -1,16 +1,6 @@
{{define "projects.html"}}
{{template "public_shell_start" .}}
{{template "projects_content" .}}
{{template "public_shell_end" .}}
{{end}}
{{define "projects_partial.html"}}
{{template "public_nav_oob" .}}
{{template "projects_content" .}}
{{end}}
{{define "projects_content"}}
<main id="main-content" class="mx-auto max-w-7xl px-5 pb-24 pt-32 md:px-8 md:pt-40">
{{template "head" .}}
{{template "site_header" .}}
<main class="mx-auto max-w-7xl px-5 pb-24 pt-32 md:px-8 md:pt-40">
<div class="mb-10 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p class="mb-3 text-sm uppercase tracking-[0.2em] text-neutral-500">Selected work</p>
@ -18,22 +8,21 @@
</div>
<p class="max-w-md text-neutral-600">A visual index of architectural and interior design work.</p>
</div>
<div class="grid gap-x-5 gap-y-10 sm:grid-cols-2 lg:grid-cols-3" hx-boost="true" hx-target="#main-content" hx-swap="outerHTML transition:true" hx-push-url="true">
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4" hx-boost="true" hx-target="body" hx-swap="outerHTML transition:true">
{{range .Projects}}
<a href="/projects/{{.Slug}}" class="group block">
<div class="aspect-[4/3] overflow-hidden bg-neutral-200">
<div class="aspect-square overflow-hidden bg-neutral-200">
<img src="{{.CoverImage}}" alt="{{.Title}}" class="h-full w-full object-cover transition duration-500 group-hover:scale-105">
</div>
<div class="mt-4">
<div class="mb-2 flex items-start justify-between gap-4">
<h2 class="text-xl font-medium">{{.Title}}</h2>
<p class="text-sm text-neutral-500">{{.Year}}</p>
</div>
<p class="mb-3 text-sm text-neutral-500">{{.Location}} · {{.Category}} · {{.Status}}</p>
<p class="leading-relaxed text-neutral-600">{{.Summary}}</p>
<div class="mt-3">
<h2 class="text-lg font-medium">{{.Title}}</h2>
<p class="text-sm text-neutral-500">{{.Location}} · {{.Year}}</p>
</div>
</a>
{{end}}
</div>
</main>
{{end}}
{{template "footer" .}}
<div id="overlay-root"></div>
</body>
</html>

View File

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