feat: rename service to galo, add halo branding and release build

- rename Go module, binary, systemd unit, PG_DATABASE default, frontend package and docs from shared-album to galo
- add theme-aware halo emblem and favicon (new ui-branding capability)
- add make build-release (versioned, stripped, static binary) and run the binary from the repo root
This commit is contained in:
iu
2026-10-11 11:36:56 +03:00
parent 29c2594a9b
commit 50dff63ece
89 changed files with 696 additions and 254 deletions
+1 -1
View File
@@ -3,7 +3,7 @@ PG_HOST=pg.localnet
PG_PORT=5432 PG_PORT=5432
PG_USER=album PG_USER=album
PG_PASSWORD=change_me PG_PASSWORD=change_me
PG_DATABASE=shared_album PG_DATABASE=galo
PG_SSLMODE=disable PG_SSLMODE=disable
# Metadata backend: sqlite (default, embedded single instance) or postgres # Metadata backend: sqlite (default, embedded single instance) or postgres
+21 -3
View File
@@ -1,4 +1,7 @@
.PHONY: build frontend backend run migrate reconcile reconcile-delete tidy test clean .PHONY: build build-release frontend backend run migrate reconcile reconcile-delete tidy test clean
# Release version stamped into the binary (-X main.version). Override with VERSION=...
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
frontend: frontend:
cd frontend && npm ci && npm run build cd frontend && npm ci && npm run build
@@ -10,12 +13,27 @@ backend:
# -tags nodynamic forces gen2brain/webp to use its pure-Go transpiled path # -tags nodynamic forces gen2brain/webp to use its pure-Go transpiled path
# (no purego/dlopen), keeping the binary fully static. # (no purego/dlopen), keeping the binary fully static.
cd backend && CGO_ENABLED=0 go build -tags nodynamic -trimpath -ldflags="-s -w" \ cd backend && CGO_ENABLED=0 go build -tags nodynamic -trimpath -ldflags="-s -w" \
-o ../bin/shared-album ./cmd/server -o ../bin/galo ./cmd/server
build: frontend backend build: frontend backend
# Release build: full static, stripped and (if available) UPX-compressed binary
# with the version embedded. Produces bin/galo and a copy at bin/galo-$(VERSION).
build-release: frontend
mkdir -p bin
cd backend && CGO_ENABLED=0 go build -tags nodynamic -trimpath \
-ldflags="-s -w -X main.version=$(VERSION)" -o ../bin/galo ./cmd/server
strip --strip-all bin/galo
@if command -v upx >/dev/null 2>&1; then \
upx --best --quiet bin/galo && echo "upx: bin/galo compressed"; \
else \
echo "upx not found - skipping compression (install upx-ucl to enable)"; \
fi
cp bin/galo bin/galo-$(VERSION)
@echo "release: bin/galo-$(VERSION)"
run: backend run: backend
cd backend && ../bin/shared-album ./bin/galo
migrate: migrate:
cd backend && go run ./cmd/migrate cd backend && go run ./cmd/migrate
+13 -13
View File
@@ -47,14 +47,14 @@ cp .env.example .env
## Сборка ## Сборка
```bash ```bash
make build # frontend -> backend/static -> go:embed -> bin/shared-album make build # frontend -> backend/static -> go:embed -> bin/galo
``` ```
Отдельные шаги: Отдельные шаги:
```bash ```bash
make frontend # npm ci && npm run build, копирует статику в backend/static make frontend # npm ci && npm run build, копирует статику в backend/static
make backend # CGO_ENABLED=0 go build -tags nodynamic -o bin/shared-album make backend # CGO_ENABLED=0 go build -tags nodynamic -o bin/galo
``` ```
> `-tags nodynamic` обязателен: он заставляет `gen2brain/webp` использовать > `-tags nodynamic` обязателен: он заставляет `gen2brain/webp` использовать
@@ -64,7 +64,7 @@ make backend # CGO_ENABLED=0 go build -tags nodynamic -o bin/shared-album
Проверка: Проверка:
```bash ```bash
file bin/shared-album # => statically linked file bin/galo # => statically linked
``` ```
## Миграции ## Миграции
@@ -79,9 +79,9 @@ make migrate # go run ./cmd/migrate — применяет up-миграци
## Локальный запуск ## Локальный запуск
```bash ```bash
make run # соберёт backend и запустит ./bin/shared-album make run # соберёт backend и запустит ./bin/galo
# или напрямую: # или напрямую:
./bin/shared-album ./bin/galo
``` ```
Сервер слушает `HTTP_ADDR` (по умолчанию `:8080`). Health-check: Сервер слушает `HTTP_ADDR` (по умолчанию `:8080`). Health-check:
@@ -126,20 +126,20 @@ go run ./cmd/reconcile --delete # удалить сироты старше --m
2. Создайте пользователя и каталог: 2. Создайте пользователя и каталог:
```bash ```bash
sudo useradd --system --home /opt/shared-album album sudo useradd --system --home /opt/galo album
sudo mkdir -p /opt/shared-album sudo mkdir -p /opt/galo
sudo cp bin/shared-album /opt/shared-album/ sudo cp bin/galo /opt/galo/
sudo cp .env /opt/shared-album/.env sudo cp .env /opt/galo/.env
sudo chown -R album:album /opt/shared-album sudo chown -R album:album /opt/galo
``` ```
3. Установите unit: 3. Установите unit:
```bash ```bash
sudo cp deploy/shared-album.service /etc/systemd/system/ sudo cp deploy/galo.service /etc/systemd/system/
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now shared-album sudo systemctl enable --now galo
sudo journalctl -u shared-album -f sudo journalctl -u galo -f
``` ```
## nginx (опционально) ## nginx (опционально)
+5 -5
View File
@@ -7,11 +7,11 @@ import (
"os" "os"
"strings" "strings"
"shared-album/internal/config" "galo/internal/config"
"shared-album/internal/db" "galo/internal/db"
"shared-album/internal/settings" "galo/internal/settings"
"shared-album/internal/storage" "galo/internal/storage"
"shared-album/internal/store" "galo/internal/store"
) )
func main() { func main() {
+3 -3
View File
@@ -5,9 +5,9 @@ import (
"log/slog" "log/slog"
"os" "os"
assets "shared-album" assets "galo"
"shared-album/internal/config" "galo/internal/config"
"shared-album/internal/db" "galo/internal/db"
) )
func main() { func main() {
+7 -7
View File
@@ -14,13 +14,13 @@ import (
"strings" "strings"
"time" "time"
assets "shared-album" assets "galo"
"shared-album/internal/config" "galo/internal/config"
"shared-album/internal/db" "galo/internal/db"
"shared-album/internal/reconcile" "galo/internal/reconcile"
"shared-album/internal/settings" "galo/internal/settings"
"shared-album/internal/storage" "galo/internal/storage"
"shared-album/internal/store" "galo/internal/store"
) )
const objectPrefix = "objects/" const objectPrefix = "objects/"
+13 -9
View File
@@ -13,23 +13,27 @@ import (
"syscall" "syscall"
"time" "time"
assets "shared-album" assets "galo"
"shared-album/internal/app" "galo/internal/app"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/config" "galo/internal/config"
"shared-album/internal/db" "galo/internal/db"
"shared-album/internal/settings" "galo/internal/settings"
"shared-album/internal/sse" "galo/internal/sse"
"shared-album/internal/storage" "galo/internal/storage"
"shared-album/internal/store" "galo/internal/store"
) )
// version is set at build time via -ldflags "-X main.version=..."
var version = "dev"
func main() { func main() {
loadDotEnv(".env") loadDotEnv(".env")
loadDotEnv("../.env") loadDotEnv("../.env")
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
slog.SetDefault(logger) slog.SetDefault(logger)
slog.Info("starting galo", "version", version)
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
+1 -1
View File
@@ -1,4 +1,4 @@
module shared-album module galo
go 1.26.1 go 1.26.1
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/httpx" "galo/internal/httpx"
) )
type Handler struct { type Handler struct {
+1 -1
View File
@@ -3,7 +3,7 @@ package albums
import ( import (
"context" "context"
"shared-album/internal/models" "galo/internal/models"
) )
// Repository is the persistence contract for the albums domain. // Repository is the persistence contract for the albums domain.
+6 -6
View File
@@ -5,12 +5,12 @@ import (
"errors" "errors"
"strings" "strings"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/httpx" "galo/internal/httpx"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/roles" "galo/internal/roles"
"shared-album/internal/sse" "galo/internal/sse"
"shared-album/internal/storage" "galo/internal/storage"
) )
type MemberLookup interface { type MemberLookup interface {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"errors" "errors"
"time" "time"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/sqliteutil" "galo/internal/sqliteutil"
) )
// SQLiteStore is the SQLite implementation of Repository. // SQLiteStore is the SQLite implementation of Repository.
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/httpx" "galo/internal/httpx"
"shared-album/internal/models" "galo/internal/models"
) )
var ErrNotFound = errors.New("album not found") var ErrNotFound = errors.New("album not found")
+13 -13
View File
@@ -10,19 +10,19 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
assets "shared-album" assets "galo"
"shared-album/internal/albums" "galo/internal/albums"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/config" "galo/internal/config"
"shared-album/internal/httpx" "galo/internal/httpx"
"shared-album/internal/members" "galo/internal/members"
"shared-album/internal/notifications" "galo/internal/notifications"
"shared-album/internal/photos" "galo/internal/photos"
"shared-album/internal/sharelinks" "galo/internal/sharelinks"
"shared-album/internal/social" "galo/internal/social"
"shared-album/internal/sse" "galo/internal/sse"
"shared-album/internal/storage" "galo/internal/storage"
"shared-album/internal/store" "galo/internal/store"
) )
type Server struct { type Server struct {
+2 -2
View File
@@ -10,8 +10,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"shared-album/internal/config" "galo/internal/config"
"shared-album/internal/storage" "galo/internal/storage"
) )
func mustSigner(t *testing.T) *storage.Signer { func mustSigner(t *testing.T) *storage.Signer {
+5 -5
View File
@@ -12,11 +12,11 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/config" "galo/internal/config"
"shared-album/internal/sse" "galo/internal/sse"
"shared-album/internal/storage" "galo/internal/storage"
"shared-album/internal/store" "galo/internal/store"
) )
func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) { func newTestServerWithFS(t *testing.T) (*storage.FS, *gin.Engine) {
+10 -10
View File
@@ -18,16 +18,16 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/albums" "galo/internal/albums"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/config" "galo/internal/config"
"shared-album/internal/members" "galo/internal/members"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/photos" "galo/internal/photos"
"shared-album/internal/sharelinks" "galo/internal/sharelinks"
"shared-album/internal/sse" "galo/internal/sse"
"shared-album/internal/storage" "galo/internal/storage"
"shared-album/internal/store" "galo/internal/store"
) )
func integrationPool(t *testing.T) *pgxpool.Pool { func integrationPool(t *testing.T) *pgxpool.Pool {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"shared-album/internal/httpx" "galo/internal/httpx"
) )
type Handler struct { type Handler struct {
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"shared-album/internal/httpx" "galo/internal/httpx"
) )
const ( const (
+1 -1
View File
@@ -3,7 +3,7 @@ package auth
import ( import (
"context" "context"
"shared-album/internal/models" "galo/internal/models"
) )
// Repository is the persistence contract for the auth domain. // Repository is the persistence contract for the auth domain.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"strings" "strings"
"time" "time"
"shared-album/internal/httpx" "galo/internal/httpx"
"shared-album/internal/models" "galo/internal/models"
) )
type TokenPair struct { type TokenPair struct {
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"errors" "errors"
"time" "time"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/sqliteutil" "galo/internal/sqliteutil"
) )
// SQLiteStore is the SQLite implementation of Repository. // SQLiteStore is the SQLite implementation of Repository.
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/models" "galo/internal/models"
) )
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
+1 -1
View File
@@ -131,7 +131,7 @@ func Load() (*Config, error) {
Port: pgPort, Port: pgPort,
User: getEnv("PG_USER", "album"), User: getEnv("PG_USER", "album"),
Password: getEnv("PG_PASSWORD", ""), Password: getEnv("PG_PASSWORD", ""),
Database: getEnv("PG_DATABASE", "shared_album"), Database: getEnv("PG_DATABASE", "galo"),
SSLMode: getEnv("PG_SSLMODE", "disable"), SSLMode: getEnv("PG_SSLMODE", "disable"),
}, },
S3: S3{ S3: S3{
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
assets "shared-album" assets "galo"
) )
func TestMigrateSQLite(t *testing.T) { func TestMigrateSQLite(t *testing.T) {
+9 -9
View File
@@ -12,15 +12,15 @@ import (
"testing" "testing"
"time" "time"
assets "shared-album" assets "galo"
"shared-album/internal/albums" "galo/internal/albums"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/db" "galo/internal/db"
"shared-album/internal/photos" "galo/internal/photos"
"shared-album/internal/settings" "galo/internal/settings"
"shared-album/internal/sse" "galo/internal/sse"
"shared-album/internal/storage" "galo/internal/storage"
"shared-album/internal/store" "galo/internal/store"
) )
func makePNG(t *testing.T) []byte { func makePNG(t *testing.T) []byte {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/httpx" "galo/internal/httpx"
) )
type Handler struct { type Handler struct {
+1 -1
View File
@@ -3,7 +3,7 @@ package members
import ( import (
"context" "context"
"shared-album/internal/models" "galo/internal/models"
) )
// Repository is the persistence contract for the members domain. // Repository is the persistence contract for the members domain.
+5 -5
View File
@@ -5,11 +5,11 @@ import (
"errors" "errors"
"time" "time"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/httpx" "galo/internal/httpx"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/roles" "galo/internal/roles"
"shared-album/internal/sse" "galo/internal/sse"
) )
type AlbumReader interface { type AlbumReader interface {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/sqliteutil" "galo/internal/sqliteutil"
) )
// SQLiteStore is the SQLite implementation of Repository. // SQLiteStore is the SQLite implementation of Repository.
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/models" "galo/internal/models"
) )
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/httpx" "galo/internal/httpx"
) )
type Handler struct { type Handler struct {
+1 -1
View File
@@ -3,7 +3,7 @@ package notifications
import ( import (
"context" "context"
"shared-album/internal/models" "galo/internal/models"
) )
// Repository is the persistence contract for the notifications domain. // Repository is the persistence contract for the notifications domain.
+4 -4
View File
@@ -3,10 +3,10 @@ package notifications
import ( import (
"context" "context"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/httpx" "galo/internal/httpx"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/sse" "galo/internal/sse"
) )
type Service struct { type Service struct {
@@ -7,8 +7,8 @@ import (
"errors" "errors"
"time" "time"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/sqliteutil" "galo/internal/sqliteutil"
) )
// SQLiteStore is the SQLite implementation of Repository. // SQLiteStore is the SQLite implementation of Repository.
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/models" "galo/internal/models"
) )
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/httpx" "galo/internal/httpx"
) )
const maxUploadSize = 100 << 20 const maxUploadSize = 100 << 20
+6 -6
View File
@@ -12,12 +12,12 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/config" "galo/internal/config"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/roles" "galo/internal/roles"
"shared-album/internal/sse" "galo/internal/sse"
"shared-album/internal/storage" "galo/internal/storage"
) )
func testPool(t *testing.T) *pgxpool.Pool { func testPool(t *testing.T) *pgxpool.Pool {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/auth" "galo/internal/auth"
) )
func TestObjectStateTransitions(t *testing.T) { func TestObjectStateTransitions(t *testing.T) {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context" "context"
"time" "time"
"shared-album/internal/models" "galo/internal/models"
) )
// Repository is the persistence contract for the photos domain, including the // Repository is the persistence contract for the photos domain, including the
+6 -6
View File
@@ -9,12 +9,12 @@ import (
"strings" "strings"
"time" "time"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/httpx" "galo/internal/httpx"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/roles" "galo/internal/roles"
"shared-album/internal/sse" "galo/internal/sse"
"shared-album/internal/storage" "galo/internal/storage"
) )
type AlbumAccess interface { type AlbumAccess interface {
+1 -1
View File
@@ -3,7 +3,7 @@ package photos
import ( import (
"time" "time"
"shared-album/internal/sqliteutil" "galo/internal/sqliteutil"
) )
func nullIfEmpty(s string) any { func nullIfEmpty(s string) any {
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"errors" "errors"
"time" "time"
"shared-album/internal/sqliteutil" "galo/internal/sqliteutil"
) )
// CreateObject inserts a new object row in the pending state. // CreateObject inserts a new object row in the pending state.
+3 -3
View File
@@ -7,9 +7,9 @@ import (
"errors" "errors"
"strings" "strings"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/sqliteutil" "galo/internal/sqliteutil"
) )
// SQLiteStore is the SQLite implementation of Repository. // SQLiteStore is the SQLite implementation of Repository.
+2 -2
View File
@@ -11,8 +11,8 @@ import (
"github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/models" "galo/internal/models"
) )
var ErrNotFound = errors.New("photo not found") var ErrNotFound = errors.New("photo not found")
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"log/slog" "log/slog"
"time" "time"
"shared-album/internal/auth" "galo/internal/auth"
) )
const maxVariantAttempts = 5 const maxVariantAttempts = 5
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/storage" "galo/internal/storage"
) )
func TestWorkerGeneratesVariantsAndCollects(t *testing.T) { func TestWorkerGeneratesVariantsAndCollects(t *testing.T) {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"strings" "strings"
"time" "time"
"shared-album/internal/storage" "galo/internal/storage"
) )
// Result holds the two kinds of divergence between storage and the journal. // Result holds the two kinds of divergence between storage and the journal.
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing" "testing"
"time" "time"
"shared-album/internal/storage" "galo/internal/storage"
) )
func TestClassify(t *testing.T) { func TestClassify(t *testing.T) {
+9 -9
View File
@@ -9,15 +9,15 @@ import (
"testing" "testing"
"time" "time"
"shared-album/internal/albums" "galo/internal/albums"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/members" "galo/internal/members"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/notifications" "galo/internal/notifications"
"shared-album/internal/photos" "galo/internal/photos"
"shared-album/internal/settings" "galo/internal/settings"
"shared-album/internal/sharelinks" "galo/internal/sharelinks"
"shared-album/internal/social" "galo/internal/social"
) )
// Repos bundles one repository per domain plus a Reset hook that clears all // Repos bundles one repository per domain plus a Reset hook that clears all
+10 -10
View File
@@ -10,16 +10,16 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
assets "shared-album" assets "galo"
"shared-album/internal/albums" "galo/internal/albums"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/db" "galo/internal/db"
"shared-album/internal/members" "galo/internal/members"
"shared-album/internal/notifications" "galo/internal/notifications"
"shared-album/internal/photos" "galo/internal/photos"
"shared-album/internal/settings" "galo/internal/settings"
"shared-album/internal/sharelinks" "galo/internal/sharelinks"
"shared-album/internal/social" "galo/internal/social"
) )
// TestPostgresContract runs the shared suite against PostgreSQL inside its own // TestPostgresContract runs the shared suite against PostgreSQL inside its own
+10 -10
View File
@@ -6,16 +6,16 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
assets "shared-album" assets "galo"
"shared-album/internal/albums" "galo/internal/albums"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/db" "galo/internal/db"
"shared-album/internal/members" "galo/internal/members"
"shared-album/internal/notifications" "galo/internal/notifications"
"shared-album/internal/photos" "galo/internal/photos"
"shared-album/internal/settings" "galo/internal/settings"
"shared-album/internal/sharelinks" "galo/internal/sharelinks"
"shared-album/internal/social" "galo/internal/social"
) )
func TestSQLiteContract(t *testing.T) { func TestSQLiteContract(t *testing.T) {
+6 -6
View File
@@ -8,12 +8,12 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/albums" "galo/internal/albums"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/members" "galo/internal/members"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/photos" "galo/internal/photos"
"shared-album/internal/sharelinks" "galo/internal/sharelinks"
) )
func testPool(t *testing.T) *pgxpool.Pool { func testPool(t *testing.T) *pgxpool.Pool {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"fmt" "fmt"
"time" "time"
"shared-album/internal/sqliteutil" "galo/internal/sqliteutil"
) )
// SQLiteStore is the SQLite implementation of Repository. // SQLiteStore is the SQLite implementation of Repository.
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/httpx" "galo/internal/httpx"
) )
type Handler struct { type Handler struct {
+1 -1
View File
@@ -3,7 +3,7 @@ package sharelinks
import ( import (
"context" "context"
"shared-album/internal/models" "galo/internal/models"
) )
// Repository is the persistence contract for the share-links domain. // Repository is the persistence contract for the share-links domain.
+4 -4
View File
@@ -5,10 +5,10 @@ import (
"errors" "errors"
"time" "time"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/httpx" "galo/internal/httpx"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/roles" "galo/internal/roles"
) )
type AlbumAccess interface { type AlbumAccess interface {
+2 -2
View File
@@ -6,8 +6,8 @@ import (
"errors" "errors"
"time" "time"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/sqliteutil" "galo/internal/sqliteutil"
) )
// SQLiteStore is the SQLite implementation of Repository. // SQLiteStore is the SQLite implementation of Repository.
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/models" "galo/internal/models"
) )
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/httpx" "galo/internal/httpx"
) )
type Handler struct { type Handler struct {
+1 -1
View File
@@ -3,7 +3,7 @@ package social
import ( import (
"context" "context"
"shared-album/internal/models" "galo/internal/models"
) )
// Repository is the persistence contract for the social domain. // Repository is the persistence contract for the social domain.
+5 -5
View File
@@ -5,11 +5,11 @@ import (
"errors" "errors"
"strings" "strings"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/httpx" "galo/internal/httpx"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/roles" "galo/internal/roles"
"shared-album/internal/sse" "galo/internal/sse"
) )
type AlbumAccess interface { type AlbumAccess interface {
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"database/sql" "database/sql"
"errors" "errors"
"shared-album/internal/models" "galo/internal/models"
"shared-album/internal/sqliteutil" "galo/internal/sqliteutil"
) )
// SQLiteStore is the SQLite implementation of Repository. // SQLiteStore is the SQLite implementation of Repository.
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/models" "galo/internal/models"
) )
var ErrNotFound = errors.New("not found") var ErrNotFound = errors.New("not found")
+1 -1
View File
@@ -13,7 +13,7 @@ import (
"testing" "testing"
"time" "time"
"shared-album/internal/config" "galo/internal/config"
) )
func newTestS3(t *testing.T) (*S3, *Signer) { func newTestS3(t *testing.T) (*S3, *Signer) {
+1 -1
View File
@@ -17,7 +17,7 @@ import (
"github.com/aws/aws-sdk-go-v2/service/s3/types" "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go" "github.com/aws/smithy-go"
"shared-album/internal/config" "galo/internal/config"
) )
// ErrSignedURLInvalid is returned when an app-served object URL has a missing, // ErrSignedURLInvalid is returned when an app-served object URL has a missing,
+8 -8
View File
@@ -8,14 +8,14 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"shared-album/internal/albums" "galo/internal/albums"
"shared-album/internal/auth" "galo/internal/auth"
"shared-album/internal/members" "galo/internal/members"
"shared-album/internal/notifications" "galo/internal/notifications"
"shared-album/internal/photos" "galo/internal/photos"
"shared-album/internal/settings" "galo/internal/settings"
"shared-album/internal/sharelinks" "galo/internal/sharelinks"
"shared-album/internal/social" "galo/internal/social"
) )
// Repos bundles one repository per domain. // Repos bundles one repository per domain.
+2 -2
View File
@@ -9,8 +9,8 @@ import (
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
assets "shared-album" assets "galo"
"shared-album/internal/db" "galo/internal/db"
) )
func assertComplete(t *testing.T, r Repos) { func assertComplete(t *testing.T, r Repos) {
@@ -1,5 +1,5 @@
[Unit] [Unit]
Description=Shared Album backend Description=Galo backend
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
@@ -7,9 +7,9 @@ Wants=network-online.target
Type=simple Type=simple
User=album User=album
Group=album Group=album
WorkingDirectory=/opt/shared-album WorkingDirectory=/opt/galo
EnvironmentFile=/opt/shared-album/.env EnvironmentFile=/opt/galo/.env
ExecStart=/opt/shared-album/shared-album ExecStart=/opt/galo/galo
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
@@ -18,7 +18,7 @@ NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=true ProtectHome=true
ReadWritePaths=/opt/shared-album ReadWritePaths=/opt/galo
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
+4 -4
View File
@@ -1,7 +1,7 @@
# Пример nginx-конфигурации перед Go-бэкендом. # Пример nginx-конфигурации перед Go-бэкендом.
# Для SSE критично proxy_buffering off, иначе события буферизуются и не доходят. # Для SSE критично proxy_buffering off, иначе события буферизуются и не доходят.
upstream shared_album { upstream galo {
server 127.0.0.1:8080; server 127.0.0.1:8080;
} }
@@ -12,7 +12,7 @@ server {
client_max_body_size 60m; client_max_body_size 60m;
location /api/sse/ { location /api/sse/ {
proxy_pass http://shared_album; proxy_pass http://galo;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Connection ""; proxy_set_header Connection "";
proxy_buffering off; # иначе SSE буферизуется proxy_buffering off; # иначе SSE буферизуется
@@ -22,7 +22,7 @@ server {
} }
location /api/objects/ { location /api/objects/ {
proxy_pass http://shared_album; proxy_pass http://galo;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_buffering off; # стриминг объектов без буферизации proxy_buffering off; # стриминг объектов без буферизации
@@ -30,7 +30,7 @@ server {
} }
location / { location / {
proxy_pass http://shared_album; proxy_pass http://galo;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
+68 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "shared-album-frontend", "name": "galo-frontend",
"version": "0.0.1", "version": "0.0.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "shared-album-frontend", "name": "galo-frontend",
"version": "0.0.1", "version": "0.0.1",
"devDependencies": { "devDependencies": {
"@sveltejs/adapter-static": "^3.0.6", "@sveltejs/adapter-static": "^3.0.6",
@@ -1102,6 +1102,72 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.4",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"peerDependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.2",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.3.3", "version": "4.3.3",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "shared-album-frontend", "name": "galo-frontend",
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
+3 -1
View File
@@ -3,7 +3,9 @@
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="%sveltekit.assets%/favicon.png" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
<link rel="alternate icon" href="%sveltekit.assets%/favicon.png" type="image/png" />
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
<script> <script>
(function () { (function () {
try { try {
+2 -1
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { authApi } from '$lib/api'; import { authApi } from '$lib/api';
import Emblem from './Emblem.svelte';
import Icon from './Icon.svelte'; import Icon from './Icon.svelte';
import ThemeSwitcher from './ThemeSwitcher.svelte'; import ThemeSwitcher from './ThemeSwitcher.svelte';
import { auth } from '$lib/stores/auth.svelte'; import { auth } from '$lib/stores/auth.svelte';
@@ -115,7 +116,7 @@
<Icon name="menu" class="size-4" /> <Icon name="menu" class="size-4" />
</button> </button>
<a href="/" class="inline-flex items-center gap-1.5 truncate text-sm font-semibold"> <a href="/" class="inline-flex items-center gap-1.5 truncate text-sm font-semibold">
<Icon name="photo_camera" class="size-4" /> <Emblem class="text-primary size-4" />
Galo Galo
</a> </a>
</div> </div>
+28
View File
@@ -0,0 +1,28 @@
<script lang="ts">
interface Props {
label?: string;
class?: string;
}
let { label, class: className = 'size-5' }: Props = $props();
</script>
<svg
viewBox="0 0 64 64"
class={className}
role={label ? 'img' : undefined}
aria-label={label}
aria-hidden={label ? undefined : 'true'}
focusable="false"
>
<circle cx="32" cy="32" r="13" fill="currentColor" />
<circle
cx="32"
cy="32"
r="24"
fill="none"
stroke="currentColor"
stroke-width="6"
stroke-opacity="0.45"
/>
</svg>
+2 -2
View File
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte'; import ThemeSwitcher from '$lib/components/ThemeSwitcher.svelte';
import Icon from '$lib/components/Icon.svelte'; import Emblem from '$lib/components/Emblem.svelte';
let { children } = $props(); let { children } = $props();
</script> </script>
@@ -9,7 +9,7 @@
<header class="border-border bg-chrome border-b"> <header class="border-border bg-chrome border-b">
<div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3"> <div class="mx-auto flex max-w-6xl items-center justify-between px-4 py-3">
<a href="/" class="inline-flex items-center gap-2 text-lg font-semibold"> <a href="/" class="inline-flex items-center gap-2 text-lg font-semibold">
<Icon name="photo_camera" /> <Emblem class="text-primary size-6" />
Galo Galo
</a> </a>
<ThemeSwitcher /> <ThemeSwitcher />
Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 840 B

+30
View File
@@ -0,0 +1,30 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<style>
.bg {
fill: #ffffff;
}
.disc {
fill: #2563eb;
}
.ring {
fill: none;
stroke: #2563eb;
stroke-opacity: 0.45;
}
@media (prefers-color-scheme: dark) {
.bg {
fill: #171717;
}
.disc {
fill: #60a5fa;
}
.ring {
stroke: #60a5fa;
stroke-opacity: 0.6;
}
}
</style>
<rect class="bg" width="64" height="64" rx="14" />
<circle class="disc" cx="32" cy="32" r="13" />
<circle class="ring" cx="32" cy="32" r="24" stroke-width="6" />
</svg>

After

Width:  |  Height:  |  Size: 562 B

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-10-11
@@ -0,0 +1,49 @@
# Design
## Context
See proposal.md - Why. Current state that shapes the approach:
- `frontend/src/app.html:6` already points at `%sveltekit.assets%/favicon.png`, but `frontend/static/` does not exist, so the request 404s.
- The icon system (`ui-iconography`) requires self-contained inline vector icons with no external requests, and icons that inherit the theme color.
- Theme colors live as CSS custom properties on `:root` per theme (`--primary` = blue light/dark, orange neutral), defined in `frontend/src/app.css`.
- Static assets flow through `frontend/static/` -> `frontend/build/` -> `backend/static/` -> `go:embed` -> served by the static handler. No backend change is needed.
## Goals / Non-Goals
**Goals:**
- One recognizable halo mark, legible down to 16px, used consistently as UI emblem and favicon.
- Theme-awareness in both the UI emblem and the browser/touch icon.
- Zero external requests for any brand asset.
**Non-Goals:**
- Changing the wordmark, typography, or theme palette.
- Open Graph / social preview images, PWA manifest icons, or app-store assets.
- Any backend or API change.
## Decisions
### D1. Concept: pure halo (variant A)
A sun disc with a separated outer ring. Alternatives: rays/glow (rejected - detail collapses at 16px) and an aperture hybrid (rejected - two meanings compete at small sizes). Geometry keeps a clear gap between disc and ring so the halo reads as a halo.
### D2. Two-tone color with monochrome fallback
In the UI, the disc uses `--primary` and the ring uses `--primary` at reduced opacity; a single-color `currentColor` fallback keeps the mark usable where the two-tone treatment does not apply. Alternative: strictly monochrome (rejected - loses the "sun inside a halo" reading).
### D3. Inline SVG for UI, static files for favicon
The UI emblem is an inline SVG component so it inherits `currentColor`/theme and satisfies the no-external-requests rule, mirroring the existing icon approach. The favicon cannot use CSS custom properties, so it is a standalone asset that carries its own theme rules.
### D4. Favicon formats
Ship `favicon.svg` with an embedded `prefers-color-scheme` rule (dark and light variants), plus `favicon.png` and `apple-touch-icon.png` fallbacks. `app.html` references the SVG and keeps a PNG fallback. Alternative: `.ico` only (rejected - no theme awareness).
### D5. Placement
Emblem in the `AppShell` header, the public/login layout, and the share-page header, always next to the "Galo" wordmark, where it is decorative (hidden from assistive tech).
### D6. Asset pipeline
Files live in `frontend/static/`; the existing `make frontend` copy step carries them into `backend/static/` for embedding, so the backend serves them unchanged.
## Risks / Trade-offs
- [Mark illegible at 16px] -> Keep geometry minimal (thick-enough ring, clear gap) and verify a 16px render.
- [SVG favicon not supported everywhere] -> Provide the PNG fallback in `app.html`.
- [Favicon cannot use theme CSS variables] -> Embed `prefers-color-scheme` rules directly in the SVG.
- [Asset bloat] -> Keep the SVG hand-written and small; PNGs limited to the needed sizes.
@@ -0,0 +1,27 @@
# Proposal
## Why
The app references a favicon that does not exist (`frontend/src/app.html:6` points at `%sveltekit.assets%/favicon.png`, but there is no `frontend/static/`), so browsers get a 404. At the same time the product name **Galo** (= гало, the ring around the sun) has no visual mark — the brand is only text. This change gives the project a self-hosted halo emblem and a working favicon, consistent with the existing icon system that forbids external requests.
## What Changes
- Introduce a **halo emblem**: a sun disc surrounded by a separated ring (variant A — pure ring + disc, legible at 16px).
- Add an inline, theme-aware SVG emblem component used in the UI (header, login/public layout, share header); the sun disc uses `--primary`, the ring uses `--primary` at reduced opacity with a monochrome `currentColor` fallback.
- Add static brand assets under `frontend/static/`: `favicon.svg` (theme-aware via `prefers-color-scheme`), `favicon.png` fallback, and `apple-touch-icon.png`.
- Update `frontend/src/app.html` to reference `favicon.svg` with the PNG fallback.
- Keep the "Galo" wordmark next to the emblem; the emblem is decorative there.
## Capabilities
### New Capabilities
- `ui-branding`: the halo emblem and favicon identity — a self-hosted, theme-aware visual mark that appears in the UI and as the browser/tab and touch icon.
### Modified Capabilities
<!-- None: the icon system (ui-iconography) is unchanged; the emblem follows its existing no-external-requests rule. -->
## Impact
- **Frontend:** new `frontend/static/` assets (served through the existing `go:embed` pipeline after build), `frontend/src/app.html`, `frontend/src/lib/components/AppShell.svelte`, `frontend/src/routes/(public)/+layout.svelte`, share page header.
- **Backend:** none (static assets are embedded and served by the existing static handler).
- **Behavior:** the missing favicon 404 is replaced by a served, theme-aware icon; no API changes.
@@ -0,0 +1,62 @@
# Spec Delta
## Purpose
Единая визуальная айдентика приложения: гало-эмблема и фавикон, отдаваемые самим приложением, наследующие тему и читаемые в мелком размере.
## ADDED Requirements
### Requirement: Гало-эмблема
Интерфейс SHALL отображать эмблему «гало» — диск солнца, окружённый отделённым от него кольцом — как встроенную векторную графику, наследующую цвет текущей темы.
#### Scenario: Отображение в шапке
- **WHEN** открыт основной интерфейс
- **THEN** в шапке рядом с надписью «Galo» показана гало-эмблема
#### Scenario: Смена темы
- **WHEN** пользователь переключает тему (light/dark/neutral)
- **THEN** цвет диска и кольца эмблемы меняется вместе с темой
#### Scenario: Мелкий размер
- **WHEN** эмблема отрисована в размере порядка 16-24 px
- **THEN** диск и отделённое кольцо остаются различимыми
### Requirement: Фавикон от приложения
Приложение SHALL отдавать фавикон бренда со своего origin, а разметка страницы SHALL ссылаться на существующий файл.
#### Scenario: Запрос фавикона
- **WHEN** браузер запрашивает фавикон
- **THEN** приложение отвечает изображением SVG с собственным `Content-Type`
#### Scenario: Запасной формат
- **WHEN** клиент не поддерживает SVG-фавикон
- **THEN** разметка предоставляет PNG-запасной вариант
### Requirement: Фавикон под тему
Фавикон SHALL учитывать предпочтение схемы оформления и в тёмной схеме SHALL использовать светлый вариант эмблемы на тёмном фоне.
#### Scenario: Тёмная схема
- **WHEN** система сообщает о предпочтении тёмной схемы
- **THEN** фавикон отображается в варианте для тёмной схемы
#### Scenario: Светлая схема
- **WHEN** система сообщает о предпочтении светлой схемы
- **THEN** фавикон отображается в варианте для светлой схемы
### Requirement: Доступность эмблемы
Эмблема SHALL быть декоративной рядом с текстовым вордмарком и SHALL иметь доступное имя, когда используется как самостоятельный смысловой элемент.
#### Scenario: Эмблема рядом с вордмарком
- **WHEN** эмблема сопровождает видимый текст «Galo»
- **THEN** она скрыта от вспомогательных технологий
#### Scenario: Самостоятельная эмблема
- **WHEN** эмблема является единственным содержимым управляющего элемента
- **THEN** элемент имеет доступное имя
### Requirement: Автономность ассетов бренда
Эмблема и фавикон SHALL загружаться с самого приложения и SHALL NOT требовать запросов к внешним источникам.
#### Scenario: Загрузка без внешней сети
- **WHEN** страница загружается без доступа к внешней сети, кроме своего сервера
- **THEN** эмблема и фавикон отображаются
@@ -0,0 +1,26 @@
# Tasks
## 1. Brand assets
- [x] 1.1 Create `frontend/static/favicon.svg` with the halo mark (sun disc + separated ring) and embedded `prefers-color-scheme` dark/light variants, and verify it opens as valid SVG and renders legibly at 16px
- [x] 1.2 Create `frontend/static/favicon.png` (fallback) and `frontend/static/apple-touch-icon.png` rendered from the same emblem, and verify both files exist at their intended pixel sizes (32x32 and 180x180)
- [x] 1.3 Update `frontend/src/app.html` to reference `favicon.svg` with the PNG as fallback, and verify `make frontend` copies both into `frontend/build/`
## 2. UI emblem
- [x] 2.1 Add an inline SVG emblem component (disc in `--primary`, ring in `--primary` at reduced opacity, monochrome `currentColor` fallback), and verify it renders at 16-24px with the ring and disc still distinct
- [x] 2.2 Verify the emblem changes color with the theme by switching light/dark/neutral and confirming the disc and ring follow the active theme
## 3. Placement
- [x] 3.1 Show the emblem in the `AppShell` header next to the "Galo" wordmark and verify it appears in the main interface
- [x] 3.2 Show the emblem in the public/login layout and on the share page header and verify it appears on both
## 4. Accessibility
- [x] 4.1 Verify every decorative emblem (next to the wordmark) is hidden from assistive technologies and any standalone emblem element has an accessible name
## 5. Integration verification
- [x] 5.1 Run `make build` and verify the served favicon responds as an SVG image (`curl -I` shows the SVG content type) and that no brand asset request returns 404
- [x] 5.2 Validate the change and verify `openspec validate add-halo-branding` passes
@@ -0,0 +1,3 @@
schema: spec-driven
created: 2026-10-11
skip_specs: true
@@ -0,0 +1,56 @@
# Design
## Context
See proposal.md - Why. Current state that shapes the approach:
- The Go module is `shared-album` (`backend/go.mod:1`), imported as `shared-album/internal/*` by ~63 files and as `assets "shared-album"` by 9 of them (module-root `backend` package holding `go:embed`).
- The name also appears in the binary target (`Makefile`), the systemd unit and its `/opt/shared-album` paths, the nginx upstream, the frontend package name, and the docs.
- The real deployment `.env` already uses `PG_DATABASE=galo` and `S3_BUCKET=galo-data`; only the code/sample defaults are stale.
- Generated artifacts (`bin/`, `frontend/build/`, `frontend/.svelte-kit/`) are gitignored and pick up the new name from the build.
- No user-facing string literal carries the old name: the UI already renders "Galo".
## Goals / Non-Goals
**Goals:**
- One consistent name `galo` across Go module, build target, deploy unit, config defaults, frontend package, and docs.
- Preserve behavior except the intended DB-name default.
- Keep the change verifiable by the existing build/test/validate commands.
**Non-Goals:**
- Renaming the immutable archived change `openspec/changes/archive/2026-10-10-shared-album`.
- Migrating existing database contents (the default is config-driven; live `.env` is already `galo`).
- Any UI/branding change (already "Galo").
## Decisions
### D1. Rename the Go module to `galo`
Flat path, matching the current style (`module shared-album`, no domain). Alternatives: keep the module name (rejected - stays inconsistent) or adopt a namespaced path like `github.com/.../galo` (rejected - no hosting/import need, larger churn).
Impact: replace `shared-album` in `backend/go.mod` and every import/alias across the backend, then rely on the compiler (`go build ./...`) to prove completeness.
### D2. Change the metadata DB-name default to `galo`
Update `config.go` default and `.env.example`. Rationale: aligns the code with the live `.env` (`PG_DATABASE=galo`). Alternative: leave the default (rejected - keeps the stale name). Trade-off: installs that omit `PG_DATABASE` will now target `galo`; deployments should set it explicitly.
### D3. Rename the deploy unit and install path
`deploy/shared-album.service` -> `galo.service`, `/opt/shared-album` -> `/opt/galo`, and the nginx upstream `shared_album` -> `galo`. This is an operational change requiring re-install on the server; it stays a single unit with the same flags and `EnvironmentFile`.
### D4. Leave history intact
The archived OpenSpec change keeps its original name - it records what happened, not what the project is called now.
### D5. Frontend package rename
`shared-album-frontend` -> `galo-frontend` in `package.json`; regenerate `package-lock.json` via install so the lock name matches.
## Risks / Trade-offs
- [Missed or mistyped import path] -> Compiler-enforced; do the module/import replacement first, then `go build ./... && go test ./...`.
- [Deploy breaks on path/unit rename] -> Treat as a documented migration step: stage `/opt/galo`, re-install the unit, disable the old one; keep `.env` authoritative.
- [DB-name default surprises a fresh install] -> `.env.example` and docs state it; recommend keeping `PG_DATABASE` explicit in every `.env`.
- [Lockfile drift after package rename] -> Regenerate the lock rather than hand-editing only its `name` fields.
## Migration Plan
1. Land the rename in one commit: backend module + imports, Makefile, `config.go`/`.env.example`, `deploy/*`, frontend package, docs.
2. Verify: `cd backend && go build ./... && go test ./...`, `make build`, `openspec validate rename-shared-album-to-galo`.
3. On the server: create `/opt/galo`, place `galo` binary and `.env`, install `galo.service`, `systemctl disable --now shared-album`, then `enable --now galo`.
4. Rollback: revert the commit and re-enable the old unit; no data migration is involved.
@@ -0,0 +1,33 @@
# Proposal
## Why
The product is named **galo** — the UI already shows "Galo" and the frontend persists state under `galo.*` keys — yet internal identifiers, build artifacts, the deploy unit, and the docs still carry the old working name `shared-album`. This mismatch confuses operators and readers, and the stale metadata default `shared_album` in `config.go` diverges from the actual deployment, whose `.env` already sets `PG_DATABASE=galo`.
## What Changes
- Rename the Go module `shared-album` -> `galo` and update every internal import path (`shared-album/internal/...`, `assets "shared-album"`) across the backend (~63 files).
- Rename the built binary `bin/shared-album` -> `bin/galo` in the Makefile.
- Rename the systemd unit `deploy/shared-album.service` -> `deploy/galo.service` and its install path `/opt/shared-album` -> `/opt/galo`.
- Change the metadata-layer DB name default `shared_album` -> `galo` in `config.go` and `.env.example`, aligning the default with the real deployment.
- Rename the nginx upstream `shared_album` -> `galo`.
- Rename the frontend package `shared-album-frontend` -> `galo-frontend` (package.json + regenerated lock).
- **BREAKING** (operational): the deploy unit name and install path change, so an existing server needs the unit re-installed and the path re-created.
- Update docs (`project.md`, including the §18 prompt log, and `README.md`) to the new name.
- Leave immutable history untouched: the archived change `openspec/changes/archive/2026-10-10-shared-album` keeps its name.
## Capabilities
### New Capabilities
<!-- None: this is a pure identifier/refactor rename with no new behavior. -->
### Modified Capabilities
<!-- None: no spec-level behavior changes. The change's .openspec.yaml must set skip_specs: true. -->
## Impact
- **Backend:** `backend/go.mod` module path + all `shared-album/internal/...` imports (~63 Go files); `internal/config/config.go` DB default.
- **Build/deploy:** `Makefile`, `deploy/shared-album.service` (renamed), `deploy/nginx-sse.conf`, `.env.example`.
- **Frontend:** `frontend/package.json` + `package-lock.json` (name only).
- **Docs:** `project.md`, `README.md`.
- **Behavior:** none at the API level; the only runtime-visible change is the default `PG_DATABASE` (affects fresh installs that omit the variable — the current `.env` already uses `galo`). Operational change: systemd unit/path rename.
@@ -0,0 +1,35 @@
# Tasks
## 1. Go module and imports
- [x] 1.1 Rename the module in `backend/go.mod` from `shared-album` to `galo` and verify `head -1 backend/go.mod` prints `module galo`
- [x] 1.2 Replace every `shared-album/internal/...` import and the `assets "shared-album"` alias across the backend (~63 files) and verify `grep -rn '"shared-album' backend --include='*.go'` returns nothing
- [x] 1.3 Build and test the backend after the rename and verify `cd backend && go build ./... && go test ./...` passes
## 2. Build target
- [x] 2.1 Rename the binary output in the Makefile (`bin/shared-album` -> `bin/galo`) in both the `backend` and `run` targets and verify `make backend && file bin/galo` reports a statically linked `galo` binary
## 3. Config defaults
- [x] 3.1 Change the `PG_DATABASE` default in `backend/internal/config/config.go` and in `.env.example` from `shared_album` to `galo` and verify `grep -rn shared_album backend/internal/config .env.example` returns nothing while `cd backend && go test ./internal/config/` passes
## 4. Deploy artifacts
- [x] 4.1 Rename `deploy/shared-album.service` to `deploy/galo.service` and update its `WorkingDirectory`, `EnvironmentFile`, `ExecStart`, and `ReadWritePaths` paths from `/opt/shared-album` to `/opt/galo`, then verify `grep -rn shared-album deploy/` returns nothing
- [x] 4.2 Rename the nginx upstream `shared_album` to `galo` in `deploy/nginx-sse.conf` (upstream block and all `proxy_pass`) and verify `grep -rn 'shared_album\|shared-album' deploy/` returns nothing
## 5. Frontend package
- [x] 5.1 Rename the package to `galo-frontend` in `frontend/package.json` and regenerate `frontend/package-lock.json`, then verify `grep -n '"name"' frontend/package.json frontend/package-lock.json` shows `galo-frontend` everywhere
## 6. Documentation
- [x] 6.1 Update `README.md` and `project.md` (including the §18 step-by-step prompt log) to use `galo` / `/opt/galo` / `galo.service`, and verify `grep -rn shared-album README.md project.md` returns nothing
- [x] 6.2 Confirm the archived change is left untouched and verify `git status --porcelain openspec/changes/archive/` prints nothing
## 7. Integration verification
- [x] 7.1 Run a repo-wide audit and verify `grep -rn 'shared-album\|shared_album\|shared-album-frontend' --exclude-dir=.git --exclude-dir=bin --exclude-dir=node_modules --exclude-dir=build --exclude-dir=.svelte-kit .` returns hits only under `openspec/changes/archive/2026-10-10-shared-album/` and this change's own directory `openspec/changes/rename-shared-album-to-galo/`
- [x] 7.2 Run the full pipeline and verify `make build && make test` succeeds on the renamed codebase
- [x] 7.3 Validate the change and verify `openspec validate rename-shared-album-to-galo` passes
+17 -17
View File
@@ -66,7 +66,7 @@
### Сборка backend ### Сборка backend
```bash ```bash
cd backend cd backend
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ../bin/shared-album ./cmd/server CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o ../bin/galo ./cmd/server
``` ```
### Сборка frontend ### Сборка frontend
@@ -84,10 +84,10 @@ cp -r build/* ../backend/static/
3. Собрать backend (`go build`) — статика вшивается в бинарник 3. Собрать backend (`go build`) — статика вшивается в бинарник
4. Положить бинарник на сервер и запустить через systemd 4. Положить бинарник на сервер и запустить через systemd
### systemd unit `/etc/systemd/system/shared-album.service` ### systemd unit `/etc/systemd/system/galo.service`
```ini ```ini
[Unit] [Unit]
Description=Shared Album backend Description=Galo backend
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
@@ -95,9 +95,9 @@ Wants=network-online.target
Type=simple Type=simple
User=album User=album
Group=album Group=album
WorkingDirectory=/opt/shared-album WorkingDirectory=/opt/galo
EnvironmentFile=/opt/shared-album/.env EnvironmentFile=/opt/galo/.env
ExecStart=/opt/shared-album/shared-album ExecStart=/opt/galo/galo
Restart=on-failure Restart=on-failure
RestartSec=5s RestartSec=5s
@@ -106,7 +106,7 @@ NoNewPrivileges=true
PrivateTmp=true PrivateTmp=true
ProtectSystem=strict ProtectSystem=strict
ProtectHome=true ProtectHome=true
ReadWritePaths=/opt/shared-album ReadWritePaths=/opt/galo
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
@@ -115,8 +115,8 @@ WantedBy=multi-user.target
Активация: Активация:
```bash ```bash
sudo systemctl daemon-reload sudo systemctl daemon-reload
sudo systemctl enable --now shared-album sudo systemctl enable --now galo
sudo journalctl -u shared-album -f sudo journalctl -u galo -f
``` ```
### Прокси (опционально) ### Прокси (опционально)
@@ -141,7 +141,7 @@ PG_HOST=pg.localnet
PG_PORT=5432 PG_PORT=5432
PG_USER=album PG_USER=album
PG_PASSWORD=change_me PG_PASSWORD=change_me
PG_DATABASE=shared_album PG_DATABASE=galo
PG_SSLMODE=disable PG_SSLMODE=disable
# Metadata backend # Metadata backend
@@ -480,7 +480,7 @@ X-Accel-Buffering: no
## 13. Структура проекта ## 13. Структура проекта
``` ```
shared-album/ galo/
├── backend/ ├── backend/
│ ├── cmd/server/main.go │ ├── cmd/server/main.go
│ ├── internal/ │ ├── internal/
@@ -527,7 +527,7 @@ shared-album/
│ ├── vite.config.ts │ ├── vite.config.ts
│ └── package.json │ └── package.json
├── deploy/ ├── deploy/
│ ├── shared-album.service # systemd unit │ ├── galo.service # systemd unit
│ └── nginx-sse.conf # пример прокси для SSE │ └── nginx-sse.conf # пример прокси для SSE
├── tailwind.config.ts ├── tailwind.config.ts
├── .env.example ├── .env.example
@@ -547,12 +547,12 @@ frontend:
backend: backend:
cd backend && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \ cd backend && CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" \
-o ../bin/shared-album ./cmd/server -o ../bin/galo ./cmd/server
build: frontend backend build: frontend backend
run: backend run: backend
./bin/shared-album ./bin/galo
migrate: migrate:
cd backend && migrate -path migrations -database \ cd backend && migrate -path migrations -database \
@@ -605,7 +605,7 @@ migrate:
## 18. Пошаговые промпты для итеративной генерации ## 18. Пошаговые промпты для итеративной генерации
### Шаг 1 — Скелет и подключения ### Шаг 1 — Скелет и подключения
Создай структуру проекта shared-album: backend (Go + Gin) и frontend Создай структуру проекта galo: backend (Go + Gin) и frontend
(SvelteKit 2 + Svelte 5 + adapter-static + Tailwind). (SvelteKit 2 + Svelte 5 + adapter-static + Tailwind).
Без Docker — нативная сборка Go-бинарника, статика вшивается через go:embed. Без Docker — нативная сборка Go-бинарника, статика вшивается через go:embed.
Используй существующие pg.localnet и Garage на s3.localnet. Используй существующие pg.localnet и Garage на s3.localnet.
@@ -614,7 +614,7 @@ config-пакет на Go, подключение к pg.localnet через pgx
клиент S3 к Garage (AWS SDK v2, region=garage, UsePathStyle=true), клиент S3 к Garage (AWS SDK v2, region=garage, UsePathStyle=true),
health-check /api/health (проверяет БД и S3). health-check /api/health (проверяет БД и S3).
Добавь Makefile: build (frontend → static → backend), run, migrate. Добавь Makefile: build (frontend → static → backend), run, migrate.
Добавь systemd unit deploy/shared-album.service. Добавь systemd unit deploy/galo.service.
### Шаг 2 — Миграции ### Шаг 2 — Миграции
Добавь golang-migrate и миграции для таблиц: users, albums, album_members, Добавь golang-migrate и миграции для таблиц: users, albums, album_members,
@@ -666,7 +666,7 @@ ThemeSwitcher.svelte — 4 кнопки с активным состоянием
### Шаг 8 — Сборка и деплой без Docker ### Шаг 8 — Сборка и деплой без Docker
Настрой Makefile: frontend собирается в статику, копируется в backend/static, Настрой Makefile: frontend собирается в статику, копируется в backend/static,
backend собирается с go:embed. Финальный артефакт — один бинарник. backend собирается с go:embed. Финальный артефакт — один бинарник.
Добавь systemd unit в deploy/shared-album.service с EnvironmentFile=.env Добавь systemd unit в deploy/galo.service с EnvironmentFile=.env
и базовым харденингом (NoNewPrivileges, ProtectSystem, PrivateTmp). и базовым харденингом (NoNewPrivileges, ProtectSystem, PrivateTmp).
Добавь пример nginx-конфига с proxy_buffering off для SSE. Добавь пример nginx-конфига с proxy_buffering off для SSE.
Напиши README: как собрать, как положить бинарник, как запустить через systemd. Напиши README: как собрать, как положить бинарник, как запустить через systemd.