Pictest
-Share photographs with a clean, fast gallery.
-Register to upload photos, browse the newest images first, inspect metadata, and leave comments.
-Login
- {{if .Error}}No account yet? Create one
-diff --git a/README.md b/README.md deleted file mode 100644 index 0f486f1..0000000 --- a/README.md +++ /dev/null @@ -1,26 +0,0 @@ -# Pictest - -Pictest is a Go + SQLite photo sharing site that uses server-rendered HTML, htmx, and plain JavaScript. It supports registration, login, authenticated uploads, a recent-first photo feed, photo detail modals, EXIF metadata capture, and comments. - -## Stack - -- Go 1.23+ -- SQLite via `modernc.org/sqlite` -- Server-rendered HTML templates -- htmx for partial page updates -- Plain JavaScript for modal behavior - -## Run - -1. Install Go 1.23 or newer. -2. Run `go mod download`. -3. Start the app with `go run .`. -4. Open `http://localhost:8080`. - -The app stores data in `./data/pictest.db` and uploaded photos in `./data/uploads`. - -## Notes - -- The first registered user can immediately start uploading photos. -- Photo comments are editable by their author. -- Photos can be deleted only by the uploader. diff --git a/cmd/pictest/main.go b/cmd/pictest/main.go deleted file mode 100644 index f222531..0000000 --- a/cmd/pictest/main.go +++ /dev/null @@ -1,764 +0,0 @@ -package pictest - -import ( - "bytes" - "crypto/rand" - "database/sql" - "embed" - "encoding/hex" - "encoding/json" - "errors" - "fmt" - "image" - _ "image/gif" - _ "image/jpeg" - _ "image/png" - "html/template" - "io" - "io/fs" - "mime" - "mime/multipart" - "net/http" - "os" - "path/filepath" - "sort" - "strconv" - "strings" - "time" - - _ "modernc.org/sqlite" - - exif "github.com/rwcarlsen/goexif/exif" - "github.com/rwcarlsen/goexif/tiff" - "golang.org/x/crypto/bcrypt" - - "pictest/internal/models" -) - -//go:embed templates/*.gohtml -var templatesFS embed.FS - -//go:embed static/* -var staticFS embed.FS - -const sessionDuration = 30 * 24 * time.Hour - -type app struct { - db *sql.DB - templates map[string]*template.Template - uploadDir string - databaseNow func() time.Time -} - -type pageData struct { - Title string - CurrentUser *models.User - Photos []models.Photo - Photo *models.Photo - ExifFields []exifField - Comments []commentView - CanDelete bool - Error string - Flash string -} - -type commentView struct { - models.Comment - CanEdit bool -} - -type exifField struct { - Name string - Value string -} - -func Run() error { - if err := run(); err != nil { - fmt.Fprintln(os.Stderr, err) - return err - } - return nil -} - -func run() error { - if err := os.MkdirAll("data/uploads", 0o755); err != nil { - return err - } - - db, err := sql.Open("sqlite", "file:data/pictest.db?_pragma=foreign_keys(1)") - if err != nil { - return err - } - defer db.Close() - - if err := initializeDatabase(db); err != nil { - return err - } - - tmpl, err := loadTemplates() - if err != nil { - return err - } - - app := &app{ - db: db, - templates: tmpl, - uploadDir: "data/uploads", - databaseNow: time.Now, - } - - mux := http.NewServeMux() - mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(mustSub(staticFS, "static"))))) - mux.Handle("/uploads/", http.StripPrefix("/uploads/", http.FileServer(http.Dir(app.uploadDir)))) - mux.HandleFunc("/", app.handleRoot) - mux.HandleFunc("/register", app.handleRegister) - mux.HandleFunc("/login", app.handleLogin) - mux.HandleFunc("/logout", app.handleLogout) - mux.HandleFunc("/feed", app.requireAuth(app.handleFeed)) - mux.HandleFunc("/photos", app.requireAuth(app.handlePhotos)) - mux.HandleFunc("/photos/", app.requireAuth(app.handlePhotoActions)) - mux.HandleFunc("/comments/", app.requireAuth(app.handleCommentActions)) - - server := &http.Server{ - Addr: ":8080", - Handler: loggingMiddleware(mux), - ReadHeaderTimeout: 5 * time.Second, - } - - fmt.Println("listening on http://localhost:8080") - return server.ListenAndServe() -} - -func initializeDatabase(db *sql.DB) error { - schema, err := os.ReadFile("migrations/0001_init.sql") - if err != nil { - return err - } - _, err = db.Exec(string(schema)) - return err -} - -func loadTemplates() (map[string]*template.Template, error) { - funcMap := template.FuncMap{ - "toJSON": func(value any) template.JS { - data, _ := json.Marshal(value) - return template.JS(data) - }, - } - - templates := map[string]*template.Template{} - pageFiles := []string{"login.gohtml", "register.gohtml", "feed.gohtml"} - for _, page := range pageFiles { - tmpl, err := template.New("base.gohtml").Funcs(funcMap).ParseFS(templatesFS, "templates/base.gohtml", "templates/"+page) - if err != nil { - return nil, err - } - templates[page] = tmpl - } - - partial, err := template.New("photo_detail.gohtml").Funcs(funcMap).ParseFS(templatesFS, "templates/photo_detail.gohtml") - if err != nil { - return nil, err - } - templates["photo_detail.gohtml"] = partial - - return templates, nil -} - -func mustSub(fsys embed.FS, dir string) fs.FS { - sub, err := fs.Sub(fsys, dir) - if err != nil { - panic(err) - } - return sub -} - -func loggingMiddleware(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - next.ServeHTTP(w, r) - fmt.Printf("%s %s %s\n", r.Method, r.URL.Path, time.Since(start).Round(time.Millisecond)) - }) -} - -func (a *app) requireAuth(next func(http.ResponseWriter, *http.Request, *models.User)) http.HandlerFunc { - return func(w http.ResponseWriter, r *http.Request) { - user, err := a.currentUser(r) - if err != nil { - http.Redirect(w, r, "/login", http.StatusSeeOther) - return - } - next(w, r, user) - } -} - -func (a *app) handleRoot(w http.ResponseWriter, r *http.Request) { - if _, err := a.currentUser(r); err != nil { - http.Redirect(w, r, "/login", http.StatusSeeOther) - return - } - http.Redirect(w, r, "/feed", http.StatusSeeOther) -} - -func (a *app) handleRegister(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - if _, err := a.currentUser(r); err == nil { - http.Redirect(w, r, "/feed", http.StatusSeeOther) - return - } - a.render(w, "register.gohtml", pageData{}) - return - } - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - username := strings.TrimSpace(r.FormValue("username")) - password := r.FormValue("password") - if err := validateCredentials(username, password); err != nil { - a.render(w, "register.gohtml", pageData{Error: err.Error()}) - return - } - - hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) - if err != nil { - http.Error(w, "could not secure password", http.StatusInternalServerError) - return - } - - _, err = a.db.Exec(`INSERT INTO users(username, password_hash) VALUES(?, ?)`, username, hash) - if err != nil { - if strings.Contains(strings.ToLower(err.Error()), "unique") { - a.render(w, "register.gohtml", pageData{Error: "username already exists"}) - return - } - http.Error(w, "could not create user", http.StatusInternalServerError) - return - } - - if err := a.createSessionAndRedirect(w, r, username); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } -} - -func (a *app) handleLogin(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodGet { - if _, err := a.currentUser(r); err == nil { - http.Redirect(w, r, "/feed", http.StatusSeeOther) - return - } - a.render(w, "login.gohtml", pageData{}) - return - } - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - username := strings.TrimSpace(r.FormValue("username")) - password := r.FormValue("password") - - user, err := a.userByUsername(username) - if err != nil { - a.render(w, "login.gohtml", pageData{Error: "invalid username or password"}) - return - } - if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil { - a.render(w, "login.gohtml", pageData{Error: "invalid username or password"}) - return - } - - if err := a.createSession(w, user.ID); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - http.Redirect(w, r, "/feed", http.StatusSeeOther) -} - -func (a *app) handleLogout(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - if cookie, err := r.Cookie("session_id"); err == nil { - _, _ = a.db.Exec(`DELETE FROM sessions WHERE id = ?`, cookie.Value) - } - http.SetCookie(w, &http.Cookie{Name: "session_id", Value: "", Path: "/", Expires: time.Unix(0, 0), MaxAge: -1, HttpOnly: true, SameSite: http.SameSiteLaxMode}) - http.Redirect(w, r, "/login", http.StatusSeeOther) -} - -func (a *app) handleFeed(w http.ResponseWriter, r *http.Request, user *models.User) { - photos, err := a.photos() - if err != nil { - http.Error(w, "could not load photos", http.StatusInternalServerError) - return - } - a.render(w, "feed.gohtml", pageData{CurrentUser: user, Photos: photos}) -} - -func (a *app) handlePhotos(w http.ResponseWriter, r *http.Request, user *models.User) { - if r.Method != http.MethodPost { - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) - return - } - - if err := r.ParseMultipartForm(32 << 20); err != nil { - http.Error(w, "invalid upload", http.StatusBadRequest) - return - } - file, header, err := r.FormFile("photo") - if err != nil { - http.Error(w, "photo file is required", http.StatusBadRequest) - return - } - defer file.Close() - - photo, err := a.savePhoto(user.ID, file, header) - if err != nil { - http.Error(w, err.Error(), http.StatusBadRequest) - return - } - - _ = photo - http.Redirect(w, r, "/feed", http.StatusSeeOther) -} - -func (a *app) handlePhotoActions(w http.ResponseWriter, r *http.Request, user *models.User) { - trimmed := strings.TrimPrefix(r.URL.Path, "/photos/") - parts := strings.Split(strings.Trim(trimmed, "/"), "/") - if len(parts) == 0 || parts[0] == "" { - http.NotFound(w, r) - return - } - - photoID, err := strconv.ParseInt(parts[0], 10, 64) - if err != nil { - http.NotFound(w, r) - return - } - - if len(parts) == 1 && r.Method == http.MethodGet { - detail, err := a.photoDetail(photoID, user.ID) - if err != nil { - http.Error(w, "photo not found", http.StatusNotFound) - return - } - a.render(w, "photo_detail.gohtml", detail) - return - } - - if len(parts) == 2 && parts[1] == "delete" && r.Method == http.MethodPost { - if err := a.deletePhoto(photoID, user.ID); err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - http.Redirect(w, r, "/feed", http.StatusSeeOther) - return - } - - if len(parts) == 2 && parts[1] == "comments" && r.Method == http.MethodPost { - body := strings.TrimSpace(r.FormValue("body")) - if body == "" { - http.Error(w, "comment cannot be empty", http.StatusBadRequest) - return - } - if err := a.addComment(photoID, user.ID, body); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - detail, err := a.photoDetail(photoID, user.ID) - if err != nil { - http.Error(w, "photo not found", http.StatusNotFound) - return - } - a.render(w, "photo_detail.gohtml", detail) - return - } - - http.NotFound(w, r) -} - -func (a *app) handleCommentActions(w http.ResponseWriter, r *http.Request, user *models.User) { - trimmed := strings.TrimPrefix(r.URL.Path, "/comments/") - parts := strings.Split(strings.Trim(trimmed, "/"), "/") - if len(parts) == 0 || parts[0] == "" { - http.NotFound(w, r) - return - } - - commentID, err := strconv.ParseInt(parts[0], 10, 64) - if err != nil { - http.NotFound(w, r) - return - } - - if r.Method == http.MethodPost && len(parts) == 1 { - body := strings.TrimSpace(r.FormValue("body")) - if body == "" { - http.Error(w, "comment cannot be empty", http.StatusBadRequest) - return - } - photoID, err := a.updateComment(commentID, user.ID, body) - if err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return - } - detail, err := a.photoDetail(photoID, user.ID) - if err != nil { - http.Error(w, "photo not found", http.StatusNotFound) - return - } - a.render(w, "photo_detail.gohtml", detail) - return - } - - http.NotFound(w, r) -} - -func (a *app) render(w http.ResponseWriter, name string, data any) { - w.Header().Set("Content-Type", "text/html; charset=utf-8") - tmpl, ok := a.templates[name] - if !ok { - http.Error(w, "template not found", http.StatusInternalServerError) - return - } - templateName := "base" - if name == "photo_detail.gohtml" { - templateName = "photo_detail" - } - if err := tmpl.ExecuteTemplate(w, templateName, data); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - } -} - -func (a *app) currentUser(r *http.Request) (*models.User, error) { - cookie, err := r.Cookie("session_id") - if err != nil || cookie.Value == "" { - return nil, errors.New("no session") - } - - var user models.User - var expiresAt time.Time - err = a.db.QueryRow(` - SELECT u.id, u.username, u.password_hash, u.created_at, s.expires_at - FROM sessions s - JOIN users u ON u.id = s.user_id - WHERE s.id = ? - `, cookie.Value).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.CreatedAt, &expiresAt) - if err != nil { - return nil, err - } - if time.Now().After(expiresAt) { - _, _ = a.db.Exec(`DELETE FROM sessions WHERE id = ?`, cookie.Value) - return nil, errors.New("session expired") - } - return &user, nil -} - -func (a *app) userByUsername(username string) (*models.User, error) { - var user models.User - return &user, a.db.QueryRow(`SELECT id, username, password_hash, created_at FROM users WHERE username = ?`, username).Scan(&user.ID, &user.Username, &user.PasswordHash, &user.CreatedAt) -} - -func validateCredentials(username, password string) error { - if len(username) < 3 || len(username) > 32 { - return errors.New("username must be between 3 and 32 characters") - } - if len(password) < 8 { - return errors.New("password must be at least 8 characters") - } - return nil -} - -func (a *app) createSessionAndRedirect(w http.ResponseWriter, r *http.Request, username string) error { - user, err := a.userByUsername(username) - if err != nil { - return err - } - if err := a.createSession(w, user.ID); err != nil { - return err - } - http.Redirect(w, r, "/feed", http.StatusSeeOther) - return nil -} - -func (a *app) createSession(w http.ResponseWriter, userID int64) error { - sessionID, err := randomToken(32) - if err != nil { - return err - } - expiresAt := a.databaseNow().Add(sessionDuration) - _, err = a.db.Exec(`INSERT INTO sessions(id, user_id, expires_at) VALUES(?, ?, ?)`, sessionID, userID, expiresAt) - if err != nil { - return err - } - http.SetCookie(w, &http.Cookie{ - Name: "session_id", - Value: sessionID, - Path: "/", - Expires: expiresAt, - HttpOnly: true, - SameSite: http.SameSiteLaxMode, - }) - return nil -} - -func randomToken(size int) (string, error) { - buffer := make([]byte, size) - if _, err := rand.Read(buffer); err != nil { - return "", err - } - return hex.EncodeToString(buffer), nil -} - -func (a *app) photos() ([]models.Photo, error) { - rows, err := a.db.Query(` - SELECT p.id, p.user_id, u.username, p.original_filename, p.stored_filename, p.mime_type, p.width, p.height, p.exif_json, p.created_at - FROM photos p - JOIN users u ON u.id = p.user_id - ORDER BY p.created_at DESC, p.id DESC - `) - if err != nil { - return nil, err - } - defer rows.Close() - - var photos []models.Photo - for rows.Next() { - var photo models.Photo - if err := rows.Scan(&photo.ID, &photo.UserID, &photo.Username, &photo.OriginalFilename, &photo.StoredFilename, &photo.MimeType, &photo.Width, &photo.Height, &photo.ExifJSON, &photo.CreatedAt); err != nil { - return nil, err - } - photos = append(photos, photo) - } - return photos, rows.Err() -} - -func (a *app) photoDetail(photoID int64, currentUserID int64) (*pageData, error) { - var photo models.Photo - err := a.db.QueryRow(` - SELECT p.id, p.user_id, u.username, p.original_filename, p.stored_filename, p.mime_type, p.width, p.height, p.exif_json, p.created_at - FROM photos p - JOIN users u ON u.id = p.user_id - WHERE p.id = ? - `, photoID).Scan(&photo.ID, &photo.UserID, &photo.Username, &photo.OriginalFilename, &photo.StoredFilename, &photo.MimeType, &photo.Width, &photo.Height, &photo.ExifJSON, &photo.CreatedAt) - if err != nil { - return nil, err - } - - rows, err := a.db.Query(` - SELECT c.id, c.photo_id, c.user_id, u.username, c.body, c.created_at, c.updated_at - FROM comments c - JOIN users u ON u.id = c.user_id - WHERE c.photo_id = ? - ORDER BY c.created_at ASC, c.id ASC - `, photoID) - if err != nil { - return nil, err - } - defer rows.Close() - - var comments []commentView - for rows.Next() { - var comment models.Comment - if err := rows.Scan(&comment.ID, &comment.PhotoID, &comment.UserID, &comment.Username, &comment.Body, &comment.CreatedAt, &comment.UpdatedAt); err != nil { - return nil, err - } - comments = append(comments, commentView{Comment: comment, CanEdit: comment.UserID == currentUserID}) - } - - canDelete := photo.UserID == currentUserID - return &pageData{ - Photo: &photo, - ExifFields: parseExifFields(photo.ExifJSON), - Comments: comments, - CanDelete: canDelete, - CurrentUser: &models.User{ID: currentUserID}, - }, nil -} - -func (a *app) savePhoto(userID int64, file multipart.File, header *multipart.FileHeader) (*models.Photo, error) { - content, err := io.ReadAll(io.LimitReader(file, 20<<20)) - if err != nil { - return nil, err - } - if len(content) == 0 { - return nil, errors.New("uploaded file is empty") - } - - mimeType := http.DetectContentType(content) - if !strings.HasPrefix(mimeType, "image/") { - return nil, errors.New("only image uploads are supported") - } - - config, _, err := imageConfig(content) - if err != nil { - return nil, errors.New("uploaded file is not a valid image") - } - - exifJSON, _ := extractExif(content) - storedName, err := randomToken(16) - if err != nil { - return nil, err - } - ext := filepath.Ext(filepath.Base(header.Filename)) - if ext == "" { - extensions, lookupErr := mime.ExtensionsByType(mimeType) - if lookupErr == nil && len(extensions) > 0 { - ext = extensions[0] - } - } - if ext == "" { - ext = ".img" - } - storedName = storedName + ext - - path := filepath.Join(a.uploadDir, storedName) - if err := os.WriteFile(path, content, 0o644); err != nil { - return nil, err - } - - result, err := a.db.Exec(` - INSERT INTO photos(user_id, original_filename, stored_filename, mime_type, width, height, exif_json) - VALUES(?, ?, ?, ?, ?, ?, ?) - `, userID, header.Filename, storedName, mimeType, config.Width, config.Height, exifJSON) - if err != nil { - return nil, err - } - photoID, _ := result.LastInsertId() - - return &models.Photo{ - ID: photoID, - UserID: userID, - OriginalFilename: header.Filename, - StoredFilename: storedName, - MimeType: mimeType, - Width: config.Width, - Height: config.Height, - ExifJSON: exifJSON, - CreatedAt: time.Now(), - }, nil -} - -func imageConfig(data []byte) (imageConfigResult, string, error) { - config, format, err := image.DecodeConfig(bytes.NewReader(data)) - return imageConfigResult{Width: config.Width, Height: config.Height}, format, err -} - -type imageConfigResult struct { - Width int - Height int -} - -func extractExif(data []byte) (string, error) { - x, err := exif.Decode(bytes.NewReader(data)) - if err != nil { - return "{}", nil - } - collector := exifMapCollector{values: map[string]any{}} - _ = x.Walk(&collector) - encoded, err := json.Marshal(collector.values) - if err != nil { - return "{}", nil - } - return string(encoded), nil -} - -type exifMapCollector struct { - values map[string]any -} - -func (c *exifMapCollector) Walk(name exif.FieldName, tag *tiff.Tag) error { - if tag == nil { - return nil - } - val, err := tag.StringVal() - if err != nil { - val = tag.String() - } - c.values[string(name)] = val - return nil -} - -func parseExifFields(raw string) []exifField { - if strings.TrimSpace(raw) == "" || raw == "{}" { - return nil - } - - var values map[string]any - if err := json.Unmarshal([]byte(raw), &values); err != nil { - return nil - } - - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - sort.Strings(keys) - - fields := make([]exifField, 0, len(keys)) - for _, key := range keys { - fields = append(fields, exifField{Name: key, Value: exifValueString(values[key])}) - } - return fields -} - -func exifValueString(value any) string { - switch typed := value.(type) { - case string: - return typed - case float64: - if typed == float64(int64(typed)) { - return strconv.FormatInt(int64(typed), 10) - } - return strconv.FormatFloat(typed, 'f', -1, 64) - case bool: - return strconv.FormatBool(typed) - case nil: - return "" - default: - encoded, err := json.Marshal(typed) - if err != nil { - return fmt.Sprint(typed) - } - return string(encoded) - } -} - -func (a *app) deletePhoto(photoID, userID int64) error { - var ownerID int64 - var storedFilename string - err := a.db.QueryRow(`SELECT user_id, stored_filename FROM photos WHERE id = ?`, photoID).Scan(&ownerID, &storedFilename) - if err != nil { - return errors.New("photo not found") - } - if ownerID != userID { - return errors.New("you can only delete your own photos") - } - if _, err := a.db.Exec(`DELETE FROM photos WHERE id = ?`, photoID); err != nil { - return err - } - _ = os.Remove(filepath.Join(a.uploadDir, storedFilename)) - return nil -} - -func (a *app) addComment(photoID, userID int64, body string) error { - _, err := a.db.Exec(`INSERT INTO comments(photo_id, user_id, body) VALUES(?, ?, ?)`, photoID, userID, body) - return err -} - -func (a *app) updateComment(commentID, userID int64, body string) (int64, error) { - var photoID int64 - var ownerID int64 - err := a.db.QueryRow(`SELECT photo_id, user_id FROM comments WHERE id = ?`, commentID).Scan(&photoID, &ownerID) - if err != nil { - return 0, errors.New("comment not found") - } - if ownerID != userID { - return 0, errors.New("you can only edit your own comments") - } - _, err = a.db.Exec(`UPDATE comments SET body = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?`, body, commentID) - return photoID, err -} diff --git a/cmd/pictest/static/app.js b/cmd/pictest/static/app.js deleted file mode 100644 index 711e37f..0000000 --- a/cmd/pictest/static/app.js +++ /dev/null @@ -1,41 +0,0 @@ -document.addEventListener("DOMContentLoaded", () => { - const dialog = document.getElementById("photo-modal"); - - function openDialog() { - if (dialog && !dialog.open) { - dialog.showModal(); - } - } - - function closeDialog() { - if (dialog && dialog.open) { - dialog.close(); - } - } - - document.body.addEventListener("click", (event) => { - const trigger = event.target.closest("[data-modal-trigger]"); - const closer = event.target.closest("[data-modal-close]"); - if (closer) { - event.preventDefault(); - closeDialog(); - } - if (trigger) { - event.preventDefault(); - } - }); - - document.body.addEventListener("htmx:afterSwap", (event) => { - if (event.target && event.target.id === "photo-modal-content") { - openDialog(); - } - }); - - if (dialog) { - dialog.addEventListener("click", (event) => { - if (event.target === dialog) { - closeDialog(); - } - }); - } -}); diff --git a/cmd/pictest/static/styles.css b/cmd/pictest/static/styles.css deleted file mode 100644 index efd53c6..0000000 --- a/cmd/pictest/static/styles.css +++ /dev/null @@ -1,379 +0,0 @@ -:root { - color-scheme: light; - --bg: #f5efe6; - --bg-accent: #e7dbc9; - --panel: rgba(255, 255, 255, 0.72); - --panel-strong: #ffffff; - --text: #1f1b16; - --muted: #6d6258; - --accent: #9d4f24; - --accent-strong: #6f3314; - --danger: #a72d22; - --border: rgba(67, 46, 30, 0.14); - --shadow: 0 18px 60px rgba(43, 26, 11, 0.12); -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - color: var(--text); - background: - radial-gradient(circle at top left, rgba(157, 79, 36, 0.18), transparent 28%), - radial-gradient(circle at top right, rgba(81, 118, 129, 0.18), transparent 28%), - linear-gradient(180deg, var(--bg), #fff8f0 48%, #f7f2eb); - min-height: 100vh; -} - -.page-shell { - width: min(1200px, calc(100% - 32px)); - margin: 0 auto; - padding: 28px 0 40px; -} - -.auth-layout { - display: grid; - grid-template-columns: 1.1fr 0.9fr; - gap: 24px; - min-height: calc(100vh - 80px); - align-items: center; -} - -.hero-panel, -.form-panel, -.upload-card, -.photo-card, -.modal-card, -.notice, -.empty-state { - background: var(--panel); - backdrop-filter: blur(18px); - border: 1px solid var(--border); - box-shadow: var(--shadow); -} - -.hero-panel, -.form-panel, -.upload-card, -.modal-card { - border-radius: 28px; - padding: 28px; -} - -.hero-panel h1, -.topbar h1 { - font-family: Georgia, "Times New Roman", serif; - font-size: clamp(2.4rem, 6vw, 4.6rem); - line-height: 0.95; - margin: 8px 0 16px; -} - -.hero-panel p { - max-width: 52ch; - font-size: 1.05rem; - color: var(--muted); -} - -.eyebrow { - text-transform: uppercase; - letter-spacing: 0.18em; - font-size: 0.76rem; - color: var(--accent-strong); - margin: 0; - font-weight: 700; -} - -.stack-form, -.upload-form, -.comment-form, -.inline-edit-form { - display: grid; - gap: 14px; -} - -label { - display: grid; - gap: 8px; - font-weight: 600; -} - -input, -textarea, -button { - font: inherit; -} - -input, -textarea { - width: 100%; - border: 1px solid var(--border); - border-radius: 16px; - padding: 14px 15px; - background: rgba(255, 255, 255, 0.88); - color: var(--text); -} - -textarea { - min-height: 120px; - resize: vertical; -} - -button { - border: 0; - border-radius: 999px; - padding: 12px 18px; - background: var(--accent); - color: white; - font-weight: 700; - cursor: pointer; - transition: transform 0.18s ease, background 0.18s ease; -} - -button:hover { - transform: translateY(-1px); - background: var(--accent-strong); -} - -button.secondary { - background: rgba(31, 27, 22, 0.08); - color: var(--text); -} - -button.secondary:hover { - background: rgba(31, 27, 22, 0.14); -} - -button.danger { - background: var(--danger); -} - -.form-footnote, -.notice, -.card-meta, -.comment-footer, -.metadata dd, -.metadata dt, -.empty-state { - color: var(--muted); -} - -.notice { - border-radius: 16px; - padding: 12px 14px; - margin-bottom: 16px; -} - -.notice.error { - border-color: rgba(167, 45, 34, 0.35); - color: #7f1f19; - background: rgba(167, 45, 34, 0.08); -} - -.topbar { - display: flex; - justify-content: space-between; - align-items: center; - gap: 16px; - margin-bottom: 22px; -} - -.upload-card { - margin-bottom: 24px; -} - -.gallery-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); - gap: 18px; -} - -.photo-card { - overflow: hidden; - border-radius: 24px; -} - -.photo-link { - display: block; -} - -.photo-card img, -.modal-image { - width: 100%; - display: block; - object-fit: cover; -} - -.photo-card img { - aspect-ratio: 4 / 3; -} - -.card-meta { - display: grid; - gap: 4px; - padding: 14px 16px 18px; -} - -.photo-modal { - border: 0; - padding: 0; - width: min(1080px, calc(100vw - 20px)); - background: transparent; -} - -.photo-modal::backdrop { - background: rgba(17, 13, 9, 0.72); -} - -.modal-card { - position: relative; - max-height: 92vh; - overflow: auto; - background: #fffaf4; -} - -.modal-image-wrap { - margin-bottom: 18px; - border-radius: 22px; - overflow: hidden; - background: #f6efe7; -} - -.modal-image { - max-height: 58vh; - object-fit: contain; -} - -.close-button { - position: sticky; - top: 0; - float: right; - z-index: 1; -} - -.detail-header { - display: flex; - justify-content: space-between; - gap: 16px; - align-items: start; - margin-bottom: 18px; -} - -.metadata { - display: grid; - gap: 12px; - margin: 0 0 24px; -} - -.metadata div { - padding: 14px 16px; - border-radius: 18px; - background: rgba(31, 27, 22, 0.04); -} - -.metadata dt { - font-size: 0.82rem; - text-transform: uppercase; - letter-spacing: 0.08em; -} - -.metadata dd, -.metadata pre { - margin: 6px 0 0; - white-space: pre-wrap; - word-break: break-word; -} - -.section-heading { - margin-bottom: 12px; -} - -.section-heading h3 { - margin: 0 0 4px; -} - -.section-heading p { - margin: 0; - color: var(--muted); -} - -.exif-card { - margin-bottom: 24px; -} - -.exif-list { - display: grid; - gap: 10px; - margin: 0; -} - -.exif-list div { - padding: 12px 14px; - border-radius: 16px; - background: rgba(31, 27, 22, 0.04); -} - -.exif-list dt { - font-size: 0.82rem; - text-transform: uppercase; - letter-spacing: 0.08em; - color: var(--accent-strong); -} - -.exif-list dd { - margin: 6px 0 0; - word-break: break-word; -} - -.comments-section { - display: grid; - gap: 16px; -} - -.comments-list { - display: grid; - gap: 12px; -} - -.comment-card { - border-radius: 18px; - padding: 14px 16px; - background: rgba(31, 27, 22, 0.04); -} - -.comment-card p { - margin: 0 0 8px; -} - -.comment-footer { - display: grid; - gap: 10px; - font-size: 0.9rem; -} - -.inline-edit-form textarea { - min-height: 84px; -} - -.empty-state { - border-radius: 22px; - padding: 20px; -} - -@media (max-width: 860px) { - .auth-layout { - grid-template-columns: 1fr; - } - - .topbar, - .detail-header { - flex-direction: column; - align-items: stretch; - } - - .page-shell { - width: min(100%, calc(100% - 20px)); - } -} diff --git a/cmd/pictest/templates/base.gohtml b/cmd/pictest/templates/base.gohtml deleted file mode 100644 index c85bbb5..0000000 --- a/cmd/pictest/templates/base.gohtml +++ /dev/null @@ -1,18 +0,0 @@ -{{define "base"}} - - -
- - -Signed in as {{.CurrentUser.Username}}
-No photos yet. Upload the first one.
- {{end}} -Pictest
-Register to upload photos, browse the newest images first, inspect metadata, and leave comments.
-No account yet? Create one
-Uploaded by {{.Photo.Username}} on {{.Photo.CreatedAt.Format "02 Jan 2006 15:04"}}
-Extracted from the image file when available.
-No EXIF metadata was embedded in this photo.
- {{end}} -Pictest
-Your profile becomes the uploader tag on each photo you add.
-Already registered? Log in
-
Comments
- -{{.Body}}
-No comments yet.
- {{end}} -