package store import ( "context" "database/sql" "errors" "fmt" "os" "path/filepath" "time" _ "github.com/mattn/go-sqlite3" "golang.org/x/crypto/bcrypt" ) type Store struct { db *sql.DB } type SiteContent struct { HeroTitle string HeroSubtitle string IntroTitle string IntroText string AboutName string AboutRole string AboutBio string Email string Phone string Location string HeroImage string AboutImage string } type Project struct { ID int64 Slug string Title string Location string Year string Category string Description string CoverImage string Featured bool CreatedAt time.Time Images []ProjectImage } type ProjectImage struct { ID int64 ProjectID int64 Path string Caption string Position int } type ContactRequest struct { ID int64 Name string Email string Message string CreatedAt time.Time } type AdminUser struct { ID int64 Username string PasswordHash []byte } func Open(path string) (*Store, error) { if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return nil, err } db, err := sql.Open("sqlite3", path+"?_foreign_keys=on") if err != nil { return nil, err } db.SetMaxOpenConns(1) return &Store{db: db}, db.Ping() } func (s *Store) Close() error { return s.db.Close() } func (s *Store) Migrate(adminUsername, adminPassword string) error { stmts := []string{ `create table if not exists site_content ( id integer primary key check (id = 1), hero_title text not null, hero_subtitle text not null, intro_title text not null, intro_text text not null, about_name text not null, about_role text not null, about_bio text not null, email text not null, phone text not null, location text not null, hero_image text not null, about_image text not null )`, `create table if not exists projects ( id integer primary key autoincrement, slug text not null unique, title text not null, location text not null, year text not null, category text not null, description text not null, cover_image text not null, featured integer not null default 0, created_at datetime not null default current_timestamp )`, `create table if not exists project_images ( id integer primary key autoincrement, project_id integer not null references projects(id) on delete cascade, path text not null, caption text not null, position integer not null default 0 )`, `create table if not exists contact_requests ( id integer primary key autoincrement, name text not null, email text not null, message text not null, created_at datetime not null default current_timestamp )`, `create table if not exists admin_users ( id integer primary key autoincrement, username text not null unique, password_hash blob not null )`, `create table if not exists sessions ( token_hash text primary key, user_id integer not null references admin_users(id) on delete cascade, expires_at datetime not null )`, } for _, stmt := range stmts { if _, err := s.db.Exec(stmt); err != nil { return err } } return s.seed(adminUsername, adminPassword) } func (s *Store) seed(adminUsername, adminPassword string) error { var count int if err := s.db.QueryRow(`select count(*) from site_content`).Scan(&count); err != nil { return err } if count == 0 { _, err := s.db.Exec(`insert into site_content ( id, hero_title, hero_subtitle, intro_title, intro_text, about_name, about_role, about_bio, email, phone, location, hero_image, about_image ) values (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, "Archi Folio", "Spatial design, architecture, and interiors shaped through quiet detail.", "Selected residential and cultural spaces", "A compact portfolio for showing image-led architectural work, interior concepts, and project narratives.", "Alex Morgan", "Architect & Interior Designer", "I design calm, functional spaces with attention to proportion, material, light, and the daily rituals of the people who use them.", "studio@example.com", "+44 20 0000 0000", "London, United Kingdom", "/static/placeholders/hero.svg", "/static/placeholders/about.svg", ) if err != nil { return err } } if err := s.db.QueryRow(`select count(*) from projects`).Scan(&count); err != nil { return err } if count == 0 { projects := []Project{ {Slug: "courtyard-house", Title: "Courtyard House", Location: "Bath, UK", Year: "2025", Category: "Residential", Description: "A private house organized around a quiet internal garden, using warm timber, stone, and filtered daylight.", CoverImage: "/static/placeholders/project-1.svg", Featured: true}, {Slug: "atelier-apartment", Title: "Atelier Apartment", Location: "London, UK", Year: "2024", Category: "Interior", Description: "A compact apartment refit with integrated storage, gallery-like surfaces, and a flexible work area.", CoverImage: "/static/placeholders/project-2.svg", Featured: true}, {Slug: "gallery-room", Title: "Gallery Room", Location: "Amsterdam, NL", Year: "2024", Category: "Cultural", Description: "A small exhibition environment designed for shifting light levels, sculpture, and intimate events.", CoverImage: "/static/placeholders/project-3.svg", Featured: true}, } for _, p := range projects { id, err := s.CreateProject(context.Background(), p) if err != nil { return err } for i := 0; i < 3; i++ { if _, err := s.db.Exec(`insert into project_images (project_id, path, caption, position) values (?, ?, ?, ?)`, id, p.CoverImage, p.Title, i); err != nil { return err } } } } hash, err := bcrypt.GenerateFromPassword([]byte(adminPassword), bcrypt.DefaultCost) if err != nil { return err } var user AdminUser err = s.db.QueryRow(`select id, username, password_hash from admin_users order by id asc limit 1`).Scan(&user.ID, &user.Username, &user.PasswordHash) if errors.Is(err, sql.ErrNoRows) { _, err = s.db.Exec(`insert into admin_users (username, password_hash) values (?, ?)`, adminUsername, hash) return err } if err != nil { return err } if user.Username == adminUsername && bcrypt.CompareHashAndPassword(user.PasswordHash, []byte(adminPassword)) == nil { return nil } _, err = s.db.Exec(`update admin_users set username = ?, password_hash = ? where id = ?`, adminUsername, hash, user.ID) if err != nil { return err } _, err = s.db.Exec(`delete from sessions`) return err } func (s *Store) SiteContent(ctx context.Context) (SiteContent, error) { var c SiteContent err := s.db.QueryRowContext(ctx, `select hero_title, hero_subtitle, intro_title, intro_text, about_name, about_role, about_bio, email, phone, location, hero_image, about_image from site_content where id = 1`). Scan(&c.HeroTitle, &c.HeroSubtitle, &c.IntroTitle, &c.IntroText, &c.AboutName, &c.AboutRole, &c.AboutBio, &c.Email, &c.Phone, &c.Location, &c.HeroImage, &c.AboutImage) return c, err } func (s *Store) UpdateSiteContent(ctx context.Context, c SiteContent) error { _, err := s.db.ExecContext(ctx, `update site_content set hero_title=?, hero_subtitle=?, intro_title=?, intro_text=?, about_name=?, about_role=?, about_bio=?, email=?, phone=?, location=?, hero_image=?, about_image=? where id=1`, c.HeroTitle, c.HeroSubtitle, c.IntroTitle, c.IntroText, c.AboutName, c.AboutRole, c.AboutBio, c.Email, c.Phone, c.Location, c.HeroImage, c.AboutImage) return err } func (s *Store) Projects(ctx context.Context, featuredOnly bool) ([]Project, error) { query := `select id, slug, title, location, year, category, description, cover_image, featured, created_at from projects` if featuredOnly { query += ` where featured = 1` } query += ` order by created_at desc, id desc` rows, err := s.db.QueryContext(ctx, query) if err != nil { return nil, err } defer rows.Close() var projects []Project for rows.Next() { var p Project var featured int if err := rows.Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Description, &p.CoverImage, &featured, &p.CreatedAt); err != nil { return nil, err } p.Featured = featured == 1 projects = append(projects, p) } return projects, rows.Err() } func (s *Store) ProjectBySlug(ctx context.Context, slug string) (Project, error) { var p Project var featured int err := s.db.QueryRowContext(ctx, `select id, slug, title, location, year, category, description, cover_image, featured, created_at from projects where slug = ?`, slug). Scan(&p.ID, &p.Slug, &p.Title, &p.Location, &p.Year, &p.Category, &p.Description, &p.CoverImage, &featured, &p.CreatedAt) if err != nil { return p, err } p.Featured = featured == 1 p.Images, err = s.ProjectImages(ctx, p.ID) return p, err } func (s *Store) ProjectImages(ctx context.Context, projectID int64) ([]ProjectImage, error) { rows, err := s.db.QueryContext(ctx, `select id, project_id, path, caption, position from project_images where project_id = ? order by position asc, id asc`, projectID) if err != nil { return nil, err } defer rows.Close() var images []ProjectImage for rows.Next() { var img ProjectImage if err := rows.Scan(&img.ID, &img.ProjectID, &img.Path, &img.Caption, &img.Position); err != nil { return nil, err } images = append(images, img) } return images, rows.Err() } func (s *Store) ProjectImagesByProject(ctx context.Context) (map[int64][]ProjectImage, error) { rows, err := s.db.QueryContext(ctx, `select id, project_id, path, caption, position from project_images order by project_id asc, position asc, id asc`) if err != nil { return nil, err } defer rows.Close() images := make(map[int64][]ProjectImage) for rows.Next() { var img ProjectImage if err := rows.Scan(&img.ID, &img.ProjectID, &img.Path, &img.Caption, &img.Position); err != nil { return nil, err } images[img.ProjectID] = append(images[img.ProjectID], img) } return images, rows.Err() } func (s *Store) ProjectImageForSlug(ctx context.Context, slug string, imageID int64) (Project, ProjectImage, error) { p, err := s.ProjectBySlug(ctx, slug) if err != nil { return Project{}, ProjectImage{}, err } for _, img := range p.Images { if img.ID == imageID { return p, img, nil } } return Project{}, ProjectImage{}, sql.ErrNoRows } func (s *Store) CreateProject(ctx context.Context, p Project) (int64, error) { res, err := s.db.ExecContext(ctx, `insert into projects (slug, title, location, year, category, description, cover_image, featured) values (?, ?, ?, ?, ?, ?, ?, ?)`, p.Slug, p.Title, p.Location, p.Year, p.Category, p.Description, p.CoverImage, boolInt(p.Featured)) if err != nil { return 0, err } return res.LastInsertId() } func (s *Store) UpdateProject(ctx context.Context, p Project) error { _, err := s.db.ExecContext(ctx, `update projects set slug=?, title=?, location=?, year=?, category=?, description=?, cover_image=?, featured=? where id=?`, p.Slug, p.Title, p.Location, p.Year, p.Category, p.Description, p.CoverImage, boolInt(p.Featured), p.ID) return err } func (s *Store) DeleteProject(ctx context.Context, id int64) error { _, err := s.db.ExecContext(ctx, `delete from projects where id = ?`, id) return err } func (s *Store) AddProjectImage(ctx context.Context, projectID int64, path, caption string) error { var pos int _ = s.db.QueryRowContext(ctx, `select coalesce(max(position), -1) + 1 from project_images where project_id = ?`, projectID).Scan(&pos) _, err := s.db.ExecContext(ctx, `insert into project_images (project_id, path, caption, position) values (?, ?, ?, ?)`, projectID, path, caption, pos) return err } func (s *Store) DeleteProjectImage(ctx context.Context, id int64) error { _, err := s.db.ExecContext(ctx, `delete from project_images where id = ?`, id) return err } func (s *Store) SaveContact(ctx context.Context, name, email, message string) error { _, err := s.db.ExecContext(ctx, `insert into contact_requests (name, email, message) values (?, ?, ?)`, name, email, message) return err } func (s *Store) ContactRequests(ctx context.Context) ([]ContactRequest, error) { rows, err := s.db.QueryContext(ctx, `select id, name, email, message, created_at from contact_requests order by created_at desc, id desc`) if err != nil { return nil, err } defer rows.Close() var requests []ContactRequest for rows.Next() { var r ContactRequest if err := rows.Scan(&r.ID, &r.Name, &r.Email, &r.Message, &r.CreatedAt); err != nil { return nil, err } requests = append(requests, r) } return requests, rows.Err() } func (s *Store) AdminByUsername(ctx context.Context, username string) (AdminUser, error) { var u AdminUser err := s.db.QueryRowContext(ctx, `select id, username, password_hash from admin_users where username = ?`, username).Scan(&u.ID, &u.Username, &u.PasswordHash) return u, err } func (s *Store) CreateSession(ctx context.Context, tokenHash string, userID int64, expiresAt time.Time) error { _, err := s.db.ExecContext(ctx, `insert into sessions (token_hash, user_id, expires_at) values (?, ?, ?)`, tokenHash, userID, expiresAt) return err } func (s *Store) SessionUser(ctx context.Context, tokenHash string) (AdminUser, error) { var u AdminUser err := s.db.QueryRowContext(ctx, `select u.id, u.username, u.password_hash from sessions s join admin_users u on u.id = s.user_id where s.token_hash = ? and s.expires_at > ?`, tokenHash, time.Now()). Scan(&u.ID, &u.Username, &u.PasswordHash) return u, err } func (s *Store) DeleteSession(ctx context.Context, tokenHash string) error { _, err := s.db.ExecContext(ctx, `delete from sessions where token_hash = ?`, tokenHash) return err } func IsNotFound(err error) bool { return errors.Is(err, sql.ErrNoRows) } func boolInt(v bool) int { if v { return 1 } return 0 } func SlugFromTitle(title string) string { out := make([]rune, 0, len(title)) lastDash := false for _, r := range title { if r >= 'A' && r <= 'Z' { r += 'a' - 'A' } if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { out = append(out, r) lastDash = false continue } if !lastDash && len(out) > 0 { out = append(out, '-') lastDash = true } } for len(out) > 0 && out[len(out)-1] == '-' { out = out[:len(out)-1] } if len(out) == 0 { return fmt.Sprintf("project-%d", time.Now().Unix()) } return string(out) }