Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 808f07800e | |||
| b62271eeb6 | |||
| a37e79ec16 | |||
| b5748121dd | |||
| 911439ebd8 | |||
| 5e24be03af | |||
| 00d43675ff | |||
| 418a4411f3 |
@@ -73,8 +73,9 @@ fi
|
||||
|
||||
# 6. Web checks — only when web/ files are staged.
|
||||
if [ -n "$(staged_match '^web/')" ]; then
|
||||
echo "→ web: prettier --check"
|
||||
( cd web && pnpm run format:check )
|
||||
echo "→ web: prettier --write"
|
||||
( cd web && pnpm run format )
|
||||
git add $(git diff --cached --name-only --diff-filter=ACMR | grep '^web/' | tr '\n' ' ')
|
||||
|
||||
echo "→ web: eslint"
|
||||
( cd web && pnpm run lint )
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
package application_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"marktvogt.de/backend/internal/domain/application"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// -- in-memory fakes --
|
||||
|
||||
type fakeRepo struct {
|
||||
apps map[uuid.UUID]application.Application
|
||||
log map[uuid.UUID][]application.StatusLogEntry
|
||||
}
|
||||
|
||||
func newFakeRepo() *fakeRepo {
|
||||
return &fakeRepo{
|
||||
apps: make(map[uuid.UUID]application.Application),
|
||||
log: make(map[uuid.UUID][]application.StatusLogEntry),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Create(_ context.Context, a application.Application) (application.Application, error) {
|
||||
for _, existing := range r.apps {
|
||||
if existing.GroupID == a.GroupID && existing.MarketEditionID == a.MarketEditionID {
|
||||
return application.Application{}, application.ErrDuplicateApplication
|
||||
}
|
||||
}
|
||||
a.CreatedAt = time.Now()
|
||||
a.UpdatedAt = time.Now()
|
||||
r.apps[a.ID] = a
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetByID(_ context.Context, id uuid.UUID) (application.Application, error) {
|
||||
a, ok := r.apps[id]
|
||||
if !ok {
|
||||
return application.Application{}, application.ErrApplicationNotFound
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListByGroup(_ context.Context, groupID uuid.UUID) ([]application.Application, error) {
|
||||
var out []application.Application
|
||||
for _, a := range r.apps {
|
||||
if a.GroupID == groupID {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Update(_ context.Context, id uuid.UUID, fields map[string]any) (application.Application, error) {
|
||||
a, ok := r.apps[id]
|
||||
if !ok {
|
||||
return application.Application{}, application.ErrApplicationNotFound
|
||||
}
|
||||
a.UpdatedAt = time.Now()
|
||||
r.apps[id] = a
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) SetStatus(_ context.Context, id uuid.UUID, status string, submittedBy *uuid.UUID, submittedAt *time.Time) (application.Application, error) {
|
||||
a, ok := r.apps[id]
|
||||
if !ok {
|
||||
return application.Application{}, application.ErrApplicationNotFound
|
||||
}
|
||||
a.Status = status
|
||||
a.SubmittedBy = submittedBy
|
||||
a.SubmittedAt = submittedAt
|
||||
r.apps[id] = a
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) AppendStatusLog(_ context.Context, e application.StatusLogEntry) error {
|
||||
r.log[e.ApplicationID] = append(r.log[e.ApplicationID], e)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListStatusLog(_ context.Context, applicationID uuid.UUID) ([]application.StatusLogEntry, error) {
|
||||
return r.log[applicationID], nil
|
||||
}
|
||||
|
||||
// fakeGroupChecker controls IsGroupAdmin / IsGroupMember responses per user.
|
||||
type fakeGroupChecker struct {
|
||||
admins map[uuid.UUID]bool
|
||||
members map[uuid.UUID]bool
|
||||
}
|
||||
|
||||
func newFakeGroupChecker() *fakeGroupChecker {
|
||||
return &fakeGroupChecker{
|
||||
admins: make(map[uuid.UUID]bool),
|
||||
members: make(map[uuid.UUID]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeGroupChecker) withAdmin(userID uuid.UUID) *fakeGroupChecker {
|
||||
f.admins[userID] = true
|
||||
f.members[userID] = true
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeGroupChecker) withMember(userID uuid.UUID) *fakeGroupChecker {
|
||||
f.members[userID] = true
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fakeGroupChecker) IsGroupAdmin(_ context.Context, _, userID uuid.UUID) (bool, error) {
|
||||
return f.admins[userID], nil
|
||||
}
|
||||
|
||||
func (f *fakeGroupChecker) IsGroupMember(_ context.Context, _, userID uuid.UUID) (bool, error) {
|
||||
return f.members[userID], nil
|
||||
}
|
||||
|
||||
// -- router helpers --
|
||||
|
||||
func newRouter(repo application.Repository, checker application.GroupMembershipChecker, authMiddleware gin.HandlerFunc) *gin.Engine {
|
||||
svc := application.NewService(repo, checker)
|
||||
h := application.NewHandler(svc)
|
||||
router := gin.New()
|
||||
application.RegisterRoutes(router.Group("/api/v1"), h, authMiddleware)
|
||||
return router
|
||||
}
|
||||
|
||||
func stubAuth(userID uuid.UUID) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set("user_id", userID)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func noAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) { c.AbortWithStatus(http.StatusUnauthorized) }
|
||||
}
|
||||
|
||||
func jsonBody(v any) *bytes.Reader {
|
||||
b, _ := json.Marshal(v)
|
||||
return bytes.NewReader(b)
|
||||
}
|
||||
|
||||
// PoC: all application endpoints reject unauthenticated requests (401).
|
||||
func TestApplicationEndpoints_Unauthenticated_Returns401(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newFakeRepo()
|
||||
checker := newFakeGroupChecker()
|
||||
router := newRouter(repo, checker, noAuth())
|
||||
|
||||
groupID := uuid.New().String()
|
||||
appID := uuid.New().String()
|
||||
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/groups/" + groupID + "/applications", map[string]any{"market_edition_id": uuid.New(), "num_persons": 1}},
|
||||
{http.MethodGet, "/api/v1/groups/" + groupID + "/applications", nil},
|
||||
{http.MethodGet, "/api/v1/applications/" + appID, nil},
|
||||
{http.MethodPatch, "/api/v1/applications/" + appID, map[string]string{"category": "Schmuck"}},
|
||||
{http.MethodPost, "/api/v1/applications/" + appID + "/submit", nil},
|
||||
{http.MethodGet, "/api/v1/applications/" + appID + "/history", nil},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
|
||||
var body *bytes.Reader
|
||||
if ep.body != nil {
|
||||
body = jsonBody(ep.body)
|
||||
} else {
|
||||
body = bytes.NewReader(nil)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(ep.method, ep.path, body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("want 401, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: create/update/submit return 403 for non-group-admin users.
|
||||
func TestApplicationWriteEndpoints_NonAdmin_Returns403(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminID := uuid.New()
|
||||
memberID := uuid.New()
|
||||
groupID := uuid.New()
|
||||
editionID := uuid.New()
|
||||
|
||||
checker := newFakeGroupChecker().withAdmin(adminID).withMember(memberID)
|
||||
repo := newFakeRepo()
|
||||
|
||||
// Seed a draft application.
|
||||
draftApp := application.Application{
|
||||
ID: uuid.New(),
|
||||
GroupID: groupID,
|
||||
MarketEditionID: editionID,
|
||||
Status: application.StatusDraft,
|
||||
NumPersons: 1,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
repo.apps[draftApp.ID] = draftApp
|
||||
|
||||
router := newRouter(repo, checker, stubAuth(memberID))
|
||||
appPath := "/api/v1/applications/" + draftApp.ID.String()
|
||||
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/groups/" + groupID.String() + "/applications",
|
||||
map[string]any{"market_edition_id": uuid.New(), "num_persons": 1}},
|
||||
{http.MethodPatch, appPath, map[string]string{"category": "Schmuck"}},
|
||||
{http.MethodPost, appPath + "/submit", nil},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
|
||||
var body *bytes.Reader
|
||||
if ep.body != nil {
|
||||
body = jsonBody(ep.body)
|
||||
} else {
|
||||
body = bytes.NewReader(nil)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(ep.method, ep.path, body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: get/list/history return 403 for users who are not group members.
|
||||
func TestApplicationReadEndpoints_NonMember_Returns403(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminID := uuid.New()
|
||||
outsiderID := uuid.New()
|
||||
groupID := uuid.New()
|
||||
|
||||
checker := newFakeGroupChecker().withAdmin(adminID)
|
||||
repo := newFakeRepo()
|
||||
|
||||
app := application.Application{
|
||||
ID: uuid.New(),
|
||||
GroupID: groupID,
|
||||
Status: application.StatusDraft,
|
||||
NumPersons: 1,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
repo.apps[app.ID] = app
|
||||
|
||||
router := newRouter(repo, checker, stubAuth(outsiderID))
|
||||
appPath := "/api/v1/applications/" + app.ID.String()
|
||||
|
||||
endpoints := []struct{ method, path string }{
|
||||
{http.MethodGet, "/api/v1/groups/" + groupID.String() + "/applications"},
|
||||
{http.MethodGet, appPath},
|
||||
{http.MethodGet, appPath + "/history"},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(ep.method, ep.path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: submitting a non-draft application returns 400.
|
||||
func TestSubmit_NonDraft_Returns400(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminID := uuid.New()
|
||||
groupID := uuid.New()
|
||||
|
||||
checker := newFakeGroupChecker().withAdmin(adminID)
|
||||
repo := newFakeRepo()
|
||||
|
||||
submittedApp := application.Application{
|
||||
ID: uuid.New(),
|
||||
GroupID: groupID,
|
||||
Status: application.StatusSubmitted,
|
||||
NumPersons: 1,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
repo.apps[submittedApp.ID] = submittedApp
|
||||
|
||||
router := newRouter(repo, checker, stubAuth(adminID))
|
||||
path := "/api/v1/applications/" + submittedApp.ID.String() + "/submit"
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("want 400, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: group admin can create and submit an application; status log is appended.
|
||||
func TestCreateAndSubmit_Admin_Succeeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminID := uuid.New()
|
||||
groupID := uuid.New()
|
||||
editionID := uuid.New()
|
||||
|
||||
checker := newFakeGroupChecker().withAdmin(adminID)
|
||||
repo := newFakeRepo()
|
||||
router := newRouter(repo, checker, stubAuth(adminID))
|
||||
|
||||
// Create a draft.
|
||||
createBody := jsonBody(map[string]any{
|
||||
"market_edition_id": editionID,
|
||||
"category": "Schmuck & Accessoires", //nolint:misspell
|
||||
"description": "Wir verkaufen handgefertigten Schmuck",
|
||||
"num_persons": 2,
|
||||
"num_tents": 1,
|
||||
})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/groups/"+groupID.String()+"/applications", createBody)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create: want 201, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Extract the application ID from the response.
|
||||
var resp struct {
|
||||
Data application.Application `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("unmarshal create response: %v", err)
|
||||
}
|
||||
if resp.Data.Status != application.StatusDraft {
|
||||
t.Errorf("want status draft, got %s", resp.Data.Status)
|
||||
}
|
||||
|
||||
// Submit it.
|
||||
w2 := httptest.NewRecorder()
|
||||
req2 := httptest.NewRequest(http.MethodPost, "/api/v1/applications/"+resp.Data.ID.String()+"/submit", nil)
|
||||
router.ServeHTTP(w2, req2)
|
||||
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("submit: want 200, got %d (body=%s)", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var resp2 struct {
|
||||
Data application.Application `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w2.Body.Bytes(), &resp2); err != nil {
|
||||
t.Fatalf("unmarshal submit response: %v", err)
|
||||
}
|
||||
if resp2.Data.Status != application.StatusSubmitted {
|
||||
t.Errorf("want status submitted, got %s", resp2.Data.Status)
|
||||
}
|
||||
|
||||
// Verify status log has two entries (draft creation + submission).
|
||||
if len(repo.log[resp.Data.ID]) < 2 {
|
||||
t.Errorf("expected at least 2 status log entries, got %d", len(repo.log[resp.Data.ID]))
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: duplicate application (same group + edition) returns 400.
|
||||
func TestCreate_Duplicate_Returns400(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminID := uuid.New()
|
||||
groupID := uuid.New()
|
||||
editionID := uuid.New()
|
||||
|
||||
checker := newFakeGroupChecker().withAdmin(adminID)
|
||||
repo := newFakeRepo()
|
||||
repo.apps[uuid.New()] = application.Application{
|
||||
ID: uuid.New(), GroupID: groupID, MarketEditionID: editionID,
|
||||
Status: application.StatusDraft, NumPersons: 1,
|
||||
CreatedAt: time.Now(), UpdatedAt: time.Now(),
|
||||
}
|
||||
|
||||
router := newRouter(repo, checker, stubAuth(adminID))
|
||||
body := jsonBody(map[string]any{"market_edition_id": editionID, "num_persons": 1})
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/groups/"+groupID.String()+"/applications", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("want 400, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"marktvogt.de/backend/internal/pkg/apierror"
|
||||
"marktvogt.de/backend/internal/pkg/validate"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
groupID, ok := parseGroupID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req CreateRequest
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
requesterID := getRequesterID(c)
|
||||
a, err := h.svc.Create(c.Request.Context(), requesterID, groupID, req)
|
||||
if err != nil {
|
||||
h.handleServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusCreated, gin.H{"data": a})
|
||||
}
|
||||
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
id, ok := parseApplicationID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requesterID := getRequesterID(c)
|
||||
a, err := h.svc.Get(c.Request.Context(), requesterID, id)
|
||||
if err != nil {
|
||||
h.handleServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": a})
|
||||
}
|
||||
|
||||
func (h *Handler) ListByGroup(c *gin.Context) {
|
||||
groupID, ok := parseGroupID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requesterID := getRequesterID(c)
|
||||
apps, err := h.svc.ListByGroup(c.Request.Context(), requesterID, groupID)
|
||||
if err != nil {
|
||||
h.handleServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": apps})
|
||||
}
|
||||
|
||||
func (h *Handler) Update(c *gin.Context) {
|
||||
id, ok := parseApplicationID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateRequest
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
requesterID := getRequesterID(c)
|
||||
a, err := h.svc.Update(c.Request.Context(), requesterID, id, req)
|
||||
if err != nil {
|
||||
h.handleServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": a})
|
||||
}
|
||||
|
||||
func (h *Handler) Submit(c *gin.Context) {
|
||||
id, ok := parseApplicationID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requesterID := getRequesterID(c)
|
||||
a, err := h.svc.Submit(c.Request.Context(), requesterID, id)
|
||||
if err != nil {
|
||||
h.handleServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": a})
|
||||
}
|
||||
|
||||
func (h *Handler) GetHistory(c *gin.Context) {
|
||||
id, ok := parseApplicationID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requesterID := getRequesterID(c)
|
||||
entries, err := h.svc.GetHistory(c.Request.Context(), requesterID, id)
|
||||
if err != nil {
|
||||
h.handleServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": entries})
|
||||
}
|
||||
|
||||
func (h *Handler) handleServiceError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, ErrApplicationNotFound):
|
||||
apiErr := apierror.NotFound("application")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
case errors.Is(err, ErrForbidden):
|
||||
apiErr := apierror.Forbidden("insufficient group permissions")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
case errors.Is(err, ErrNotDraft):
|
||||
apiErr := apierror.BadRequest("not_draft", "application is not in draft status")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
case errors.Is(err, ErrDuplicateApplication):
|
||||
apiErr := apierror.BadRequest("duplicate_application", "an application already exists for this group and market edition")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
default:
|
||||
apiErr := apierror.Internal("internal error")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
}
|
||||
}
|
||||
|
||||
func parseGroupID(c *gin.Context) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param("groupId"))
|
||||
if err != nil {
|
||||
apiErr := apierror.BadRequest("invalid_group_id", "invalid group id")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func parseApplicationID(c *gin.Context) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
apiErr := apierror.BadRequest("invalid_application_id", "invalid application id")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func getRequesterID(c *gin.Context) uuid.UUID {
|
||||
v, _ := c.Get("user_id")
|
||||
id, _ := v.(uuid.UUID)
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Application struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GroupID uuid.UUID `json:"group_id"`
|
||||
MarketEditionID uuid.UUID `json:"market_edition_id"`
|
||||
Status string `json:"status"`
|
||||
Category string `json:"category"`
|
||||
Description string `json:"description"`
|
||||
AreaSqm *float64 `json:"area_sqm,omitempty"`
|
||||
NeedsPower bool `json:"needs_power"`
|
||||
NeedsWater bool `json:"needs_water"`
|
||||
NumPersons int `json:"num_persons"`
|
||||
NumTents int `json:"num_tents"`
|
||||
Notes string `json:"notes"`
|
||||
SubmittedBy *uuid.UUID `json:"submitted_by,omitempty"`
|
||||
SubmittedAt *time.Time `json:"submitted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type StatusLogEntry struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
ApplicationID uuid.UUID `json:"application_id"`
|
||||
FromStatus *string `json:"from_status,omitempty"`
|
||||
ToStatus string `json:"to_status"`
|
||||
ChangedBy uuid.UUID `json:"changed_by"`
|
||||
Note string `json:"note"`
|
||||
ChangedAt time.Time `json:"changed_at"`
|
||||
}
|
||||
|
||||
const (
|
||||
StatusDraft = "draft"
|
||||
StatusSubmitted = "submitted"
|
||||
StatusReviewing = "reviewing"
|
||||
StatusAccepted = "accepted"
|
||||
StatusRejected = "rejected"
|
||||
StatusWaitlisted = "waitlisted"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrApplicationNotFound = fmt.Errorf("application not found")
|
||||
ErrNotDraft = fmt.Errorf("application is not in draft status")
|
||||
ErrDuplicateApplication = fmt.Errorf("application already exists for this group and market edition")
|
||||
)
|
||||
@@ -0,0 +1,198 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, a Application) (Application, error)
|
||||
GetByID(ctx context.Context, id uuid.UUID) (Application, error)
|
||||
ListByGroup(ctx context.Context, groupID uuid.UUID) ([]Application, error)
|
||||
Update(ctx context.Context, id uuid.UUID, fields map[string]any) (Application, error)
|
||||
SetStatus(ctx context.Context, id uuid.UUID, status string, submittedBy *uuid.UUID, submittedAt *time.Time) (Application, error)
|
||||
AppendStatusLog(ctx context.Context, entry StatusLogEntry) error
|
||||
ListStatusLog(ctx context.Context, applicationID uuid.UUID) ([]StatusLogEntry, error)
|
||||
}
|
||||
|
||||
type pgRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewRepository(db *pgxpool.Pool) Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
var scanCols = `id, group_id, market_edition_id, status, category, description,
|
||||
area_sqm, needs_power, needs_water, num_persons, num_tents, notes,
|
||||
submitted_by, submitted_at, created_at, updated_at`
|
||||
|
||||
func scanApplication(row interface{ Scan(...any) error }) (Application, error) {
|
||||
var a Application
|
||||
err := row.Scan(
|
||||
&a.ID, &a.GroupID, &a.MarketEditionID, &a.Status,
|
||||
&a.Category, &a.Description,
|
||||
&a.AreaSqm, &a.NeedsPower, &a.NeedsWater, &a.NumPersons, &a.NumTents, &a.Notes,
|
||||
&a.SubmittedBy, &a.SubmittedAt, &a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
return a, err
|
||||
}
|
||||
|
||||
func (r *pgRepository) Create(ctx context.Context, a Application) (Application, error) {
|
||||
row := r.db.QueryRow(ctx, fmt.Sprintf(`
|
||||
INSERT INTO applications
|
||||
(id, group_id, market_edition_id, category, description,
|
||||
area_sqm, needs_power, needs_water, num_persons, num_tents, notes)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING %s
|
||||
`, scanCols),
|
||||
a.ID, a.GroupID, a.MarketEditionID, a.Category, a.Description,
|
||||
a.AreaSqm, a.NeedsPower, a.NeedsWater, a.NumPersons, a.NumTents, a.Notes,
|
||||
)
|
||||
out, err := scanApplication(row)
|
||||
if err != nil {
|
||||
if isDuplicateKey(err) {
|
||||
return Application{}, ErrDuplicateApplication
|
||||
}
|
||||
return Application{}, fmt.Errorf("creating application: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetByID(ctx context.Context, id uuid.UUID) (Application, error) {
|
||||
row := r.db.QueryRow(ctx, fmt.Sprintf(`
|
||||
SELECT %s FROM applications WHERE id = $1
|
||||
`, scanCols), id)
|
||||
a, err := scanApplication(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Application{}, ErrApplicationNotFound
|
||||
}
|
||||
return Application{}, fmt.Errorf("getting application: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) ListByGroup(ctx context.Context, groupID uuid.UUID) ([]Application, error) {
|
||||
rows, err := r.db.Query(ctx, fmt.Sprintf(`
|
||||
SELECT %s FROM applications WHERE group_id = $1 ORDER BY created_at DESC
|
||||
`, scanCols), groupID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing applications: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var apps []Application
|
||||
for rows.Next() {
|
||||
a, scanErr := scanApplication(rows)
|
||||
if scanErr != nil {
|
||||
return nil, fmt.Errorf("scanning application: %w", scanErr)
|
||||
}
|
||||
apps = append(apps, a)
|
||||
}
|
||||
return apps, rows.Err()
|
||||
}
|
||||
|
||||
func (r *pgRepository) Update(ctx context.Context, id uuid.UUID, fields map[string]any) (Application, error) {
|
||||
if len(fields) == 0 {
|
||||
return r.GetByID(ctx, id)
|
||||
}
|
||||
|
||||
setClauses := ""
|
||||
args := []any{id}
|
||||
i := 2
|
||||
for k, v := range fields {
|
||||
if setClauses != "" {
|
||||
setClauses += ", "
|
||||
}
|
||||
setClauses += fmt.Sprintf("%s = $%d", k, i)
|
||||
args = append(args, v)
|
||||
i++
|
||||
}
|
||||
|
||||
row := r.db.QueryRow(ctx, fmt.Sprintf(`
|
||||
UPDATE applications SET %s, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING %s
|
||||
`, setClauses, scanCols), args...)
|
||||
a, err := scanApplication(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Application{}, ErrApplicationNotFound
|
||||
}
|
||||
return Application{}, fmt.Errorf("updating application: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) SetStatus(ctx context.Context, id uuid.UUID, status string, submittedBy *uuid.UUID, submittedAt *time.Time) (Application, error) {
|
||||
row := r.db.QueryRow(ctx, fmt.Sprintf(`
|
||||
UPDATE applications
|
||||
SET status = $2, submitted_by = $3, submitted_at = $4, updated_at = NOW()
|
||||
WHERE id = $1
|
||||
RETURNING %s
|
||||
`, scanCols), id, status, submittedBy, submittedAt)
|
||||
a, err := scanApplication(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Application{}, ErrApplicationNotFound
|
||||
}
|
||||
return Application{}, fmt.Errorf("setting application status: %w", err)
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) AppendStatusLog(ctx context.Context, entry StatusLogEntry) error {
|
||||
_, err := r.db.Exec(ctx, `
|
||||
INSERT INTO application_status_log (id, application_id, from_status, to_status, changed_by, note)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
`, entry.ID, entry.ApplicationID, entry.FromStatus, entry.ToStatus, entry.ChangedBy, entry.Note)
|
||||
if err != nil {
|
||||
return fmt.Errorf("appending status log: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) ListStatusLog(ctx context.Context, applicationID uuid.UUID) ([]StatusLogEntry, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT id, application_id, from_status, to_status, changed_by, note, changed_at
|
||||
FROM application_status_log
|
||||
WHERE application_id = $1
|
||||
ORDER BY changed_at ASC
|
||||
`, applicationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing status log: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var entries []StatusLogEntry
|
||||
for rows.Next() {
|
||||
var e StatusLogEntry
|
||||
if err := rows.Scan(&e.ID, &e.ApplicationID, &e.FromStatus, &e.ToStatus, &e.ChangedBy, &e.Note, &e.ChangedAt); err != nil {
|
||||
return nil, fmt.Errorf("scanning status log entry: %w", err)
|
||||
}
|
||||
entries = append(entries, e)
|
||||
}
|
||||
return entries, rows.Err()
|
||||
}
|
||||
|
||||
func isDuplicateKey(err error) bool {
|
||||
s := err.Error()
|
||||
for i := 0; i <= len(s)-13; i++ {
|
||||
if s[i:i+13] == "duplicate key" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for i := 0; i <= len(s)-5; i++ {
|
||||
if s[i:i+5] == "23505" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package application
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc) {
|
||||
auth := rg.Group("", requireAuth)
|
||||
{
|
||||
auth.POST("/groups/:groupId/applications", h.Create)
|
||||
auth.GET("/groups/:groupId/applications", h.ListByGroup)
|
||||
auth.GET("/applications/:id", h.Get)
|
||||
auth.PATCH("/applications/:id", h.Update)
|
||||
auth.POST("/applications/:id/submit", h.Submit)
|
||||
auth.GET("/applications/:id/history", h.GetHistory)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package application
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// GroupMembershipChecker is the subset of group.Repository the service needs.
|
||||
// Defined here to avoid an import cycle; satisfied by an adapter in server/routes.go.
|
||||
type GroupMembershipChecker interface {
|
||||
IsGroupAdmin(ctx context.Context, groupID, userID uuid.UUID) (bool, error)
|
||||
IsGroupMember(ctx context.Context, groupID, userID uuid.UUID) (bool, error)
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
groups GroupMembershipChecker
|
||||
}
|
||||
|
||||
func NewService(repo Repository, groups GroupMembershipChecker) *Service {
|
||||
return &Service{repo: repo, groups: groups}
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
MarketEditionID uuid.UUID `json:"market_edition_id" validate:"required"`
|
||||
Category string `json:"category" validate:"omitempty,max=100"`
|
||||
Description string `json:"description" validate:"omitempty,max=5000"`
|
||||
AreaSqm *float64 `json:"area_sqm" validate:"omitempty,gt=0"`
|
||||
NeedsPower bool `json:"needs_power"`
|
||||
NeedsWater bool `json:"needs_water"`
|
||||
NumPersons int `json:"num_persons" validate:"min=1"`
|
||||
NumTents int `json:"num_tents" validate:"min=0"`
|
||||
Notes string `json:"notes" validate:"omitempty,max=2000"`
|
||||
}
|
||||
|
||||
type UpdateRequest struct {
|
||||
Category *string `json:"category" validate:"omitempty,max=100"`
|
||||
Description *string `json:"description" validate:"omitempty,max=5000"`
|
||||
AreaSqm *float64 `json:"area_sqm" validate:"omitempty,gt=0"`
|
||||
NeedsPower *bool `json:"needs_power"`
|
||||
NeedsWater *bool `json:"needs_water"`
|
||||
NumPersons *int `json:"num_persons" validate:"omitempty,min=1"`
|
||||
NumTents *int `json:"num_tents" validate:"omitempty,min=0"`
|
||||
Notes *string `json:"notes" validate:"omitempty,max=2000"`
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, requesterID, groupID uuid.UUID, req CreateRequest) (Application, error) {
|
||||
if err := s.requireGroupAdmin(ctx, groupID, requesterID); err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
|
||||
numPersons := req.NumPersons
|
||||
if numPersons < 1 {
|
||||
numPersons = 1
|
||||
}
|
||||
|
||||
a := Application{
|
||||
ID: uuid.New(),
|
||||
GroupID: groupID,
|
||||
MarketEditionID: req.MarketEditionID,
|
||||
Status: StatusDraft,
|
||||
Category: req.Category,
|
||||
Description: req.Description,
|
||||
AreaSqm: req.AreaSqm,
|
||||
NeedsPower: req.NeedsPower,
|
||||
NeedsWater: req.NeedsWater,
|
||||
NumPersons: numPersons,
|
||||
NumTents: req.NumTents,
|
||||
Notes: req.Notes,
|
||||
}
|
||||
|
||||
created, err := s.repo.Create(ctx, a)
|
||||
if err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
|
||||
_ = s.repo.AppendStatusLog(ctx, StatusLogEntry{
|
||||
ID: uuid.New(),
|
||||
ApplicationID: created.ID,
|
||||
ToStatus: StatusDraft,
|
||||
ChangedBy: requesterID,
|
||||
})
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *Service) Get(ctx context.Context, requesterID, id uuid.UUID) (Application, error) {
|
||||
a, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
if err := s.requireGroupMember(ctx, a.GroupID, requesterID); err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListByGroup(ctx context.Context, requesterID, groupID uuid.UUID) ([]Application, error) {
|
||||
if err := s.requireGroupMember(ctx, groupID, requesterID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListByGroup(ctx, groupID)
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, requesterID, id uuid.UUID, req UpdateRequest) (Application, error) {
|
||||
a, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
if a.Status != StatusDraft {
|
||||
return Application{}, ErrNotDraft
|
||||
}
|
||||
if err := s.requireGroupAdmin(ctx, a.GroupID, requesterID); err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
|
||||
fields := make(map[string]any)
|
||||
if req.Category != nil {
|
||||
fields["category"] = *req.Category
|
||||
}
|
||||
if req.Description != nil {
|
||||
fields["description"] = *req.Description
|
||||
}
|
||||
if req.AreaSqm != nil {
|
||||
fields["area_sqm"] = *req.AreaSqm
|
||||
}
|
||||
if req.NeedsPower != nil {
|
||||
fields["needs_power"] = *req.NeedsPower
|
||||
}
|
||||
if req.NeedsWater != nil {
|
||||
fields["needs_water"] = *req.NeedsWater
|
||||
}
|
||||
if req.NumPersons != nil {
|
||||
fields["num_persons"] = *req.NumPersons
|
||||
}
|
||||
if req.NumTents != nil {
|
||||
fields["num_tents"] = *req.NumTents
|
||||
}
|
||||
if req.Notes != nil {
|
||||
fields["notes"] = *req.Notes
|
||||
}
|
||||
|
||||
return s.repo.Update(ctx, id, fields)
|
||||
}
|
||||
|
||||
func (s *Service) Submit(ctx context.Context, requesterID, id uuid.UUID) (Application, error) {
|
||||
a, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
if a.Status != StatusDraft {
|
||||
return Application{}, ErrNotDraft
|
||||
}
|
||||
if err := s.requireGroupAdmin(ctx, a.GroupID, requesterID); err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
|
||||
from := StatusDraft
|
||||
now := time.Now()
|
||||
updated, err := s.repo.SetStatus(ctx, id, StatusSubmitted, &requesterID, &now)
|
||||
if err != nil {
|
||||
return Application{}, err
|
||||
}
|
||||
|
||||
_ = s.repo.AppendStatusLog(ctx, StatusLogEntry{
|
||||
ID: uuid.New(),
|
||||
ApplicationID: id,
|
||||
FromStatus: &from,
|
||||
ToStatus: StatusSubmitted,
|
||||
ChangedBy: requesterID,
|
||||
})
|
||||
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetHistory(ctx context.Context, requesterID, id uuid.UUID) ([]StatusLogEntry, error) {
|
||||
a, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.requireGroupMember(ctx, a.GroupID, requesterID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListStatusLog(ctx, id)
|
||||
}
|
||||
|
||||
func (s *Service) requireGroupAdmin(ctx context.Context, groupID, userID uuid.UUID) error {
|
||||
ok, err := s.groups.IsGroupAdmin(ctx, groupID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking group admin: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) requireGroupMember(ctx context.Context, groupID, userID uuid.UUID) error {
|
||||
ok, err := s.groups.IsGroupMember(ctx, groupID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("checking group membership: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrForbidden is returned when the requester lacks the necessary group role.
|
||||
var ErrForbidden = errors.New("forbidden")
|
||||
@@ -65,6 +65,10 @@ func (s *Service) Login(ctx context.Context, req LoginRequest, ip, ua string) (A
|
||||
return AuthData{}, fmt.Errorf("invalid credentials")
|
||||
}
|
||||
|
||||
if u.Status == user.StatusSuspended {
|
||||
return AuthData{}, fmt.Errorf("account suspended")
|
||||
}
|
||||
|
||||
if password.NeedsRehash(*u.PasswordHash) {
|
||||
if newHash, hashErr := password.Hash(req.Password); hashErr == nil {
|
||||
if _, updateErr := s.userRepo.Update(ctx, u.ID, map[string]any{"password_hash": newHash}); updateErr != nil {
|
||||
|
||||
@@ -270,6 +270,17 @@ func (r *fakeUserRepo) Restore(_ context.Context, _ uuid.UUID) error { return
|
||||
func (r *fakeUserRepo) GetDeletedByID(_ context.Context, id uuid.UUID) (user.User, error) {
|
||||
return user.User{}, user.ErrUserNotFound
|
||||
}
|
||||
func (r *fakeUserRepo) ListByStatus(_ context.Context, _ string) ([]user.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (r *fakeUserRepo) SetStatus(_ context.Context, id uuid.UUID, status string, _ *time.Time) (user.User, error) {
|
||||
if u, ok := r.users[id]; ok {
|
||||
u.Status = status
|
||||
r.users[id] = u
|
||||
return u, nil
|
||||
}
|
||||
return user.User{}, user.ErrUserNotFound
|
||||
}
|
||||
|
||||
func makeService(authRepo auth.Repository, userRepo user.Repository) *auth.Service {
|
||||
return auth.NewService(authRepo, userRepo, auth.ServiceConfig{
|
||||
|
||||
@@ -0,0 +1,397 @@
|
||||
package group_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"marktvogt.de/backend/internal/domain/group"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// -- in-memory repository --
|
||||
|
||||
type fakeRepo struct {
|
||||
groups map[uuid.UUID]group.Group
|
||||
members map[uuid.UUID][]group.GroupMember // keyed by groupID
|
||||
profiles map[uuid.UUID]group.GroupProfile
|
||||
}
|
||||
|
||||
func newFakeRepo() *fakeRepo {
|
||||
return &fakeRepo{
|
||||
groups: make(map[uuid.UUID]group.Group),
|
||||
members: make(map[uuid.UUID][]group.GroupMember),
|
||||
profiles: make(map[uuid.UUID]group.GroupProfile),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *fakeRepo) Create(_ context.Context, g group.Group) (group.Group, error) {
|
||||
g.CreatedAt = time.Now()
|
||||
g.UpdatedAt = time.Now()
|
||||
r.groups[g.ID] = g
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetByID(_ context.Context, id uuid.UUID) (group.Group, error) {
|
||||
g, ok := r.groups[id]
|
||||
if !ok {
|
||||
return group.Group{}, group.ErrGroupNotFound
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetProfile(_ context.Context, groupID uuid.UUID) (group.GroupProfile, error) {
|
||||
p, ok := r.profiles[groupID]
|
||||
if !ok {
|
||||
return group.GroupProfile{GroupID: groupID, Categories: []string{}}, nil
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) UpsertProfile(_ context.Context, p group.GroupProfile) error {
|
||||
if p.Categories == nil {
|
||||
p.Categories = []string{}
|
||||
}
|
||||
r.profiles[p.GroupID] = p
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) AddMember(_ context.Context, m group.GroupMember) error {
|
||||
for _, existing := range r.members[m.GroupID] {
|
||||
if existing.UserID == m.UserID {
|
||||
return group.ErrAlreadyMember
|
||||
}
|
||||
}
|
||||
m.JoinedAt = time.Now()
|
||||
r.members[m.GroupID] = append(r.members[m.GroupID], m)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) RemoveMember(_ context.Context, groupID, userID uuid.UUID) error {
|
||||
members := r.members[groupID]
|
||||
for i, m := range members {
|
||||
if m.UserID == userID {
|
||||
r.members[groupID] = append(members[:i], members[i+1:]...)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return group.ErrMemberNotFound
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetMember(_ context.Context, groupID, userID uuid.UUID) (group.GroupMember, error) {
|
||||
for _, m := range r.members[groupID] {
|
||||
if m.UserID == userID {
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
return group.GroupMember{}, group.ErrMemberNotFound
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListMembers(_ context.Context, groupID uuid.UUID) ([]group.GroupMemberView, error) {
|
||||
src := r.members[groupID]
|
||||
views := make([]group.GroupMemberView, len(src))
|
||||
for i, m := range src {
|
||||
views[i] = group.GroupMemberView{GroupMember: m}
|
||||
}
|
||||
return views, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListByUser(_ context.Context, userID uuid.UUID) ([]group.Group, error) {
|
||||
var out []group.Group
|
||||
for gid, members := range r.members {
|
||||
for _, m := range members {
|
||||
if m.UserID == userID {
|
||||
if g, ok := r.groups[gid]; ok {
|
||||
out = append(out, g)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) CountAdmins(_ context.Context, groupID uuid.UUID) (int, error) {
|
||||
count := 0
|
||||
for _, m := range r.members[groupID] {
|
||||
if m.Role == group.MemberRoleAdmin {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// -- router helpers --
|
||||
|
||||
func newRouter(repo group.Repository, authMiddleware gin.HandlerFunc) *gin.Engine {
|
||||
svc := group.NewService(repo)
|
||||
h := group.NewHandler(svc)
|
||||
router := gin.New()
|
||||
group.RegisterRoutes(router.Group("/api/v1"), h, authMiddleware)
|
||||
return router
|
||||
}
|
||||
|
||||
func stubAuth(userID uuid.UUID) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set("user_id", userID)
|
||||
c.Set("user_role", "user")
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func noAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
func jsonBody(v any) *bytes.Reader {
|
||||
b, _ := json.Marshal(v)
|
||||
return bytes.NewReader(b)
|
||||
}
|
||||
|
||||
// PoC: authenticated-only endpoints reject unauthenticated requests (401).
|
||||
func TestGroupEndpoints_Unauthenticated_Returns401(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newFakeRepo()
|
||||
router := newRouter(repo, noAuth())
|
||||
groupID := uuid.New().String()
|
||||
userID := uuid.New().String()
|
||||
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}{
|
||||
{http.MethodPost, "/api/v1/groups", map[string]string{"name": "Thors Schmiede", "kind": "haendler"}},
|
||||
{http.MethodPatch, "/api/v1/groups/" + groupID + "/profile", map[string]string{"description": "test"}},
|
||||
{http.MethodPost, "/api/v1/groups/" + groupID + "/members", map[string]string{"user_id": userID}},
|
||||
{http.MethodDelete, "/api/v1/groups/" + groupID + "/members/" + userID, nil},
|
||||
{http.MethodGet, "/api/v1/users/me/groups", nil},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
|
||||
var body *bytes.Reader
|
||||
if ep.body != nil {
|
||||
body = jsonBody(ep.body)
|
||||
} else {
|
||||
body = bytes.NewReader(nil)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(ep.method, ep.path, body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("want 401, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: UpdateProfile and AddMember reject non-admins with 403.
|
||||
func TestGroupAdminEndpoints_NonAdmin_Returns403(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminID := uuid.New()
|
||||
nonMemberID := uuid.New()
|
||||
|
||||
repo := newFakeRepo()
|
||||
// create a group and add admin; non-member has no membership
|
||||
g := group.Group{ID: uuid.New(), Name: "Test Group", Kind: group.KindHaendler, CreatedBy: adminID}
|
||||
repo.groups[g.ID] = g
|
||||
repo.members[g.ID] = []group.GroupMember{
|
||||
{ID: uuid.New(), GroupID: g.ID, UserID: adminID, Role: group.MemberRoleAdmin, JoinedAt: time.Now()},
|
||||
}
|
||||
|
||||
router := newRouter(repo, stubAuth(nonMemberID))
|
||||
groupPath := "/api/v1/groups/" + g.ID.String()
|
||||
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
body any
|
||||
}{
|
||||
{http.MethodPatch, groupPath + "/profile", map[string]string{"description": "hijacked"}},
|
||||
{http.MethodPost, groupPath + "/members", map[string]string{"user_id": uuid.New().String()}},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(ep.method, ep.path, jsonBody(ep.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: RemoveMember by a regular member (not self) returns 403.
|
||||
func TestRemoveMember_NonAdminNonSelf_Returns403(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminID := uuid.New()
|
||||
memberID := uuid.New()
|
||||
otherMemberID := uuid.New()
|
||||
|
||||
repo := newFakeRepo()
|
||||
g := group.Group{ID: uuid.New(), Name: "Test Group", Kind: group.KindLager, CreatedBy: adminID}
|
||||
repo.groups[g.ID] = g
|
||||
repo.members[g.ID] = []group.GroupMember{
|
||||
{ID: uuid.New(), GroupID: g.ID, UserID: adminID, Role: group.MemberRoleAdmin, JoinedAt: time.Now()},
|
||||
{ID: uuid.New(), GroupID: g.ID, UserID: memberID, Role: group.MemberRoleMember, JoinedAt: time.Now()},
|
||||
{ID: uuid.New(), GroupID: g.ID, UserID: otherMemberID, Role: group.MemberRoleMember, JoinedAt: time.Now()},
|
||||
}
|
||||
|
||||
// memberID tries to remove otherMemberID (not self, not admin) → 403
|
||||
router := newRouter(repo, stubAuth(memberID))
|
||||
path := "/api/v1/groups/" + g.ID.String() + "/members/" + otherMemberID.String()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodDelete, path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: a member can remove themselves (self-remove is allowed without admin).
|
||||
func TestRemoveMember_SelfRemove_Succeeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminID := uuid.New()
|
||||
memberID := uuid.New()
|
||||
|
||||
repo := newFakeRepo()
|
||||
g := group.Group{ID: uuid.New(), Name: "Test Group", Kind: group.KindKuenstler, CreatedBy: adminID}
|
||||
repo.groups[g.ID] = g
|
||||
repo.members[g.ID] = []group.GroupMember{
|
||||
{ID: uuid.New(), GroupID: g.ID, UserID: adminID, Role: group.MemberRoleAdmin, JoinedAt: time.Now()},
|
||||
{ID: uuid.New(), GroupID: g.ID, UserID: memberID, Role: group.MemberRoleMember, JoinedAt: time.Now()},
|
||||
}
|
||||
|
||||
router := newRouter(repo, stubAuth(memberID))
|
||||
path := "/api/v1/groups/" + g.ID.String() + "/members/" + memberID.String()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodDelete, path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNoContent {
|
||||
t.Errorf("want 204, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: removing the last admin is rejected (409 / 400).
|
||||
func TestRemoveMember_LastAdmin_Rejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
adminID := uuid.New()
|
||||
|
||||
repo := newFakeRepo()
|
||||
g := group.Group{ID: uuid.New(), Name: "Solo Admin Group", Kind: group.KindHaendler, CreatedBy: adminID}
|
||||
repo.groups[g.ID] = g
|
||||
repo.members[g.ID] = []group.GroupMember{
|
||||
{ID: uuid.New(), GroupID: g.ID, UserID: adminID, Role: group.MemberRoleAdmin, JoinedAt: time.Now()},
|
||||
}
|
||||
|
||||
// Admin tries to remove themselves — must be rejected because they're the last admin.
|
||||
router := newRouter(repo, stubAuth(adminID))
|
||||
path := "/api/v1/groups/" + g.ID.String() + "/members/" + adminID.String()
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodDelete, path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code == http.StatusNoContent {
|
||||
t.Errorf("expected rejection (4xx), got 204")
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: admin can create a group and the creator is automatically admin.
|
||||
func TestCreateGroup_Admin_SucceedsAndCreatorIsAdmin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
creatorID := uuid.New()
|
||||
repo := newFakeRepo()
|
||||
router := newRouter(repo, stubAuth(creatorID))
|
||||
|
||||
body := jsonBody(map[string]string{"name": "Thors Schmiede", "kind": "haendler"})
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/groups", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("want 201, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
// Verify creator was added as admin in the repo.
|
||||
var foundAdmin bool
|
||||
for _, members := range repo.members {
|
||||
for _, m := range members {
|
||||
if m.UserID == creatorID && m.Role == group.MemberRoleAdmin {
|
||||
foundAdmin = true
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundAdmin {
|
||||
t.Error("creator was not added as admin after group creation")
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: public endpoints (GET group, GET members) are accessible without auth.
|
||||
func TestGroupPublicEndpoints_NoAuth_Succeeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
repo := newFakeRepo()
|
||||
g := group.Group{ID: uuid.New(), Name: "Open Group", Kind: group.KindLager, CreatedBy: uuid.New()}
|
||||
g.CreatedAt = time.Now()
|
||||
g.UpdatedAt = time.Now()
|
||||
repo.groups[g.ID] = g
|
||||
|
||||
// noAuth middleware aborts auth-required routes; public routes bypass it.
|
||||
router := newRouter(repo, noAuth())
|
||||
|
||||
t.Run("GET /groups/:id", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/groups/"+g.ID.String(), nil)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("GET /groups/:id/members", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/groups/"+g.ID.String()+"/members", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("want 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Verify ErrMemberNotFound sentinel satisfies errors.Is chain.
|
||||
func TestErrors_SentinelIdentity(t *testing.T) {
|
||||
if !errors.Is(group.ErrGroupNotFound, group.ErrGroupNotFound) {
|
||||
t.Error("ErrGroupNotFound sentinel broken")
|
||||
}
|
||||
if !errors.Is(group.ErrNotGroupAdmin, group.ErrNotGroupAdmin) {
|
||||
t.Error("ErrNotGroupAdmin sentinel broken")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"marktvogt.de/backend/internal/pkg/apierror"
|
||||
"marktvogt.de/backend/internal/pkg/validate"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *Handler) Create(c *gin.Context) {
|
||||
var req CreateRequest
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
creatorID := getRequesterID(c)
|
||||
view, err := h.svc.CreateGroup(c.Request.Context(), creatorID, req)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to create group")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, gin.H{"data": view})
|
||||
}
|
||||
|
||||
func (h *Handler) Get(c *gin.Context) {
|
||||
id, ok := parseGroupID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
view, err := h.svc.GetGroup(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrGroupNotFound) {
|
||||
apiErr := apierror.NotFound("group")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to get group")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": view})
|
||||
}
|
||||
|
||||
func (h *Handler) UpdateProfile(c *gin.Context) {
|
||||
id, ok := parseGroupID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req UpdateProfileRequest
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
requestorID := getRequesterID(c)
|
||||
profile, err := h.svc.UpdateProfile(c.Request.Context(), requestorID, id, req)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrGroupNotFound) {
|
||||
apiErr := apierror.NotFound("group")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrNotGroupAdmin) {
|
||||
apiErr := apierror.Forbidden("not a group admin")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to update group profile")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": profile})
|
||||
}
|
||||
|
||||
func (h *Handler) ListMembers(c *gin.Context) {
|
||||
id, ok := parseGroupID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
members, err := h.svc.ListMembers(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrGroupNotFound) {
|
||||
apiErr := apierror.NotFound("group")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to list members")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": members})
|
||||
}
|
||||
|
||||
func (h *Handler) AddMember(c *gin.Context) {
|
||||
id, ok := parseGroupID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req AddMemberRequest
|
||||
if apiErr := validate.BindJSON(c, &req); apiErr != nil {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
requestorID := getRequesterID(c)
|
||||
err := h.svc.AddMember(c.Request.Context(), requestorID, id, req.UserID)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, ErrGroupNotFound):
|
||||
apiErr := apierror.NotFound("group")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
case errors.Is(err, ErrNotGroupAdmin):
|
||||
apiErr := apierror.Forbidden("not a group admin")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
case errors.Is(err, ErrAlreadyMember):
|
||||
apiErr := apierror.BadRequest("already_member", "user is already a member")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
default:
|
||||
apiErr := apierror.Internal("failed to add member")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (h *Handler) RemoveMember(c *gin.Context) {
|
||||
groupID, ok := parseGroupID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
targetID, err := uuid.Parse(c.Param("userId"))
|
||||
if err != nil {
|
||||
apiErr := apierror.BadRequest("invalid_user_id", "invalid user id")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
requestorID := getRequesterID(c)
|
||||
if removeErr := h.svc.RemoveMember(c.Request.Context(), requestorID, groupID, targetID); removeErr != nil {
|
||||
switch {
|
||||
case errors.Is(removeErr, ErrGroupNotFound):
|
||||
apiErr := apierror.NotFound("group")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
case errors.Is(removeErr, ErrNotGroupAdmin):
|
||||
apiErr := apierror.Forbidden("not a group admin")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
case errors.Is(removeErr, ErrMemberNotFound):
|
||||
apiErr := apierror.NotFound("member")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
case errors.Is(removeErr, ErrCannotRemoveLastAdmin):
|
||||
apiErr := apierror.BadRequest("last_admin", "cannot remove the last group admin")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
default:
|
||||
apiErr := apierror.Internal("failed to remove member")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (h *Handler) ListMyGroups(c *gin.Context) {
|
||||
userID := getRequesterID(c)
|
||||
groups, err := h.svc.ListMyGroups(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to list groups")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": groups})
|
||||
}
|
||||
|
||||
func parseGroupID(c *gin.Context) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
apiErr := apierror.BadRequest("invalid_group_id", "invalid group id")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
func getRequesterID(c *gin.Context) uuid.UUID {
|
||||
v, _ := c.Get("user_id")
|
||||
id, _ := v.(uuid.UUID)
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Group struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type GroupProfile struct {
|
||||
GroupID uuid.UUID `json:"group_id"`
|
||||
Description string `json:"description"`
|
||||
Categories []string `json:"categories"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
WebsiteURL string `json:"website_url"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type GroupMember struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
GroupID uuid.UUID `json:"group_id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Role string `json:"role"`
|
||||
JoinedAt time.Time `json:"joined_at"`
|
||||
}
|
||||
|
||||
// GroupView is the aggregated response for a single group (group + profile).
|
||||
type GroupView struct {
|
||||
Group
|
||||
Profile GroupProfile `json:"profile"`
|
||||
}
|
||||
|
||||
// GroupMemberView augments GroupMember with user display fields for list responses.
|
||||
type GroupMemberView struct {
|
||||
GroupMember
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
}
|
||||
|
||||
const (
|
||||
MemberRoleAdmin = "admin"
|
||||
MemberRoleMember = "member"
|
||||
)
|
||||
|
||||
const (
|
||||
KindHaendler = "haendler"
|
||||
KindKuenstler = "kuenstler"
|
||||
KindLager = "lager"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrGroupNotFound = fmt.Errorf("group not found")
|
||||
ErrNotGroupAdmin = fmt.Errorf("not a group admin")
|
||||
ErrAlreadyMember = fmt.Errorf("already a member")
|
||||
ErrMemberNotFound = fmt.Errorf("member not found")
|
||||
ErrCannotRemoveLastAdmin = fmt.Errorf("cannot remove the last group admin")
|
||||
)
|
||||
@@ -0,0 +1,220 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
Create(ctx context.Context, g Group) (Group, error)
|
||||
GetByID(ctx context.Context, id uuid.UUID) (Group, error)
|
||||
GetProfile(ctx context.Context, groupID uuid.UUID) (GroupProfile, error)
|
||||
UpsertProfile(ctx context.Context, p GroupProfile) error
|
||||
AddMember(ctx context.Context, m GroupMember) error
|
||||
RemoveMember(ctx context.Context, groupID, userID uuid.UUID) error
|
||||
GetMember(ctx context.Context, groupID, userID uuid.UUID) (GroupMember, error)
|
||||
ListMembers(ctx context.Context, groupID uuid.UUID) ([]GroupMemberView, error)
|
||||
ListByUser(ctx context.Context, userID uuid.UUID) ([]Group, error)
|
||||
CountAdmins(ctx context.Context, groupID uuid.UUID) (int, error)
|
||||
}
|
||||
|
||||
type pgRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewRepository(db *pgxpool.Pool) Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pgRepository) Create(ctx context.Context, g Group) (Group, error) {
|
||||
var out Group
|
||||
err := r.db.QueryRow(ctx, `
|
||||
INSERT INTO groups (id, name, kind, created_by)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, name, kind, created_by, created_at, updated_at
|
||||
`, g.ID, g.Name, g.Kind, g.CreatedBy).Scan(
|
||||
&out.ID, &out.Name, &out.Kind, &out.CreatedBy, &out.CreatedAt, &out.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Group{}, fmt.Errorf("creating group: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetByID(ctx context.Context, id uuid.UUID) (Group, error) {
|
||||
var g Group
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT id, name, kind, created_by, created_at, updated_at
|
||||
FROM groups WHERE id = $1
|
||||
`, id).Scan(&g.ID, &g.Name, &g.Kind, &g.CreatedBy, &g.CreatedAt, &g.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Group{}, ErrGroupNotFound
|
||||
}
|
||||
return Group{}, fmt.Errorf("getting group: %w", err)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetProfile(ctx context.Context, groupID uuid.UUID) (GroupProfile, error) {
|
||||
var p GroupProfile
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT group_id, description, categories, avatar_url, website_url, updated_at
|
||||
FROM group_profiles WHERE group_id = $1
|
||||
`, groupID).Scan(&p.GroupID, &p.Description, &p.Categories, &p.AvatarURL, &p.WebsiteURL, &p.UpdatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return GroupProfile{GroupID: groupID, Categories: []string{}}, nil
|
||||
}
|
||||
return GroupProfile{}, fmt.Errorf("getting group profile: %w", err)
|
||||
}
|
||||
if p.Categories == nil {
|
||||
p.Categories = []string{}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) UpsertProfile(ctx context.Context, p GroupProfile) error {
|
||||
categories := p.Categories
|
||||
if categories == nil {
|
||||
categories = []string{}
|
||||
}
|
||||
_, err := r.db.Exec(ctx, `
|
||||
INSERT INTO group_profiles (group_id, description, categories, avatar_url, website_url, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||
ON CONFLICT (group_id) DO UPDATE SET
|
||||
description = EXCLUDED.description,
|
||||
categories = EXCLUDED.categories,
|
||||
avatar_url = EXCLUDED.avatar_url,
|
||||
website_url = EXCLUDED.website_url,
|
||||
updated_at = NOW()
|
||||
`, p.GroupID, p.Description, categories, p.AvatarURL, p.WebsiteURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upserting group profile: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) AddMember(ctx context.Context, m GroupMember) error {
|
||||
_, err := r.db.Exec(ctx, `
|
||||
INSERT INTO group_members (id, group_id, user_id, role)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
`, m.ID, m.GroupID, m.UserID, m.Role)
|
||||
if err != nil {
|
||||
if isDuplicateKey(err) {
|
||||
return ErrAlreadyMember
|
||||
}
|
||||
return fmt.Errorf("adding group member: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) RemoveMember(ctx context.Context, groupID, userID uuid.UUID) error {
|
||||
tag, err := r.db.Exec(ctx, `
|
||||
DELETE FROM group_members WHERE group_id = $1 AND user_id = $2
|
||||
`, groupID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("removing group member: %w", err)
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrMemberNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetMember(ctx context.Context, groupID, userID uuid.UUID) (GroupMember, error) {
|
||||
var m GroupMember
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT id, group_id, user_id, role, joined_at
|
||||
FROM group_members WHERE group_id = $1 AND user_id = $2
|
||||
`, groupID, userID).Scan(&m.ID, &m.GroupID, &m.UserID, &m.Role, &m.JoinedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return GroupMember{}, ErrMemberNotFound
|
||||
}
|
||||
return GroupMember{}, fmt.Errorf("getting group member: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) ListMembers(ctx context.Context, groupID uuid.UUID) ([]GroupMemberView, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT gm.id, gm.group_id, gm.user_id, gm.role, gm.joined_at,
|
||||
u.display_name, u.avatar_url
|
||||
FROM group_members gm
|
||||
JOIN users u ON u.id = gm.user_id
|
||||
WHERE gm.group_id = $1
|
||||
ORDER BY gm.joined_at ASC
|
||||
`, groupID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing group members: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var members []GroupMemberView
|
||||
for rows.Next() {
|
||||
var v GroupMemberView
|
||||
if err := rows.Scan(
|
||||
&v.ID, &v.GroupID, &v.UserID, &v.Role, &v.JoinedAt,
|
||||
&v.DisplayName, &v.AvatarURL,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scanning group member: %w", err)
|
||||
}
|
||||
members = append(members, v)
|
||||
}
|
||||
return members, rows.Err()
|
||||
}
|
||||
|
||||
func (r *pgRepository) ListByUser(ctx context.Context, userID uuid.UUID) ([]Group, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT g.id, g.name, g.kind, g.created_by, g.created_at, g.updated_at
|
||||
FROM groups g
|
||||
JOIN group_members gm ON gm.group_id = g.id
|
||||
WHERE gm.user_id = $1
|
||||
ORDER BY g.created_at DESC
|
||||
`, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing groups by user: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var groups []Group
|
||||
for rows.Next() {
|
||||
var g Group
|
||||
if err := rows.Scan(&g.ID, &g.Name, &g.Kind, &g.CreatedBy, &g.CreatedAt, &g.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scanning group: %w", err)
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups, rows.Err()
|
||||
}
|
||||
|
||||
func (r *pgRepository) CountAdmins(ctx context.Context, groupID uuid.UUID) (int, error) {
|
||||
var count int
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM group_members WHERE group_id = $1 AND role = 'admin'
|
||||
`, groupID).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("counting group admins: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func isDuplicateKey(err error) bool {
|
||||
s := err.Error()
|
||||
return contains(s, "duplicate key") || contains(s, "23505")
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
for i := 0; i <= len(s)-len(sub); i++ {
|
||||
if s[i:i+len(sub)] == sub {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package group
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterRoutes(rg *gin.RouterGroup, h *Handler, requireAuth gin.HandlerFunc) {
|
||||
rg.GET("/groups/:id", h.Get)
|
||||
rg.GET("/groups/:id/members", h.ListMembers)
|
||||
|
||||
auth := rg.Group("", requireAuth)
|
||||
{
|
||||
auth.POST("/groups", h.Create)
|
||||
auth.PATCH("/groups/:id/profile", h.UpdateProfile)
|
||||
auth.POST("/groups/:id/members", h.AddMember)
|
||||
auth.DELETE("/groups/:id/members/:userId", h.RemoveMember)
|
||||
auth.GET("/users/me/groups", h.ListMyGroups)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
package group
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
type CreateRequest struct {
|
||||
Name string `json:"name" validate:"required,min=1,max=100"`
|
||||
Kind string `json:"kind" validate:"required,oneof=haendler kuenstler lager"`
|
||||
}
|
||||
|
||||
type UpdateProfileRequest struct {
|
||||
Description *string `json:"description" validate:"omitempty,max=2000"`
|
||||
Categories []string `json:"categories" validate:"omitempty,max=10,dive,min=1,max=100"`
|
||||
AvatarURL *string `json:"avatar_url" validate:"omitempty,url"`
|
||||
WebsiteURL *string `json:"website_url" validate:"omitempty,url"`
|
||||
}
|
||||
|
||||
type AddMemberRequest struct {
|
||||
UserID uuid.UUID `json:"user_id" validate:"required"`
|
||||
}
|
||||
|
||||
func (s *Service) CreateGroup(ctx context.Context, creatorID uuid.UUID, req CreateRequest) (GroupView, error) {
|
||||
g := Group{
|
||||
ID: uuid.New(),
|
||||
Name: req.Name,
|
||||
Kind: req.Kind,
|
||||
CreatedBy: creatorID,
|
||||
}
|
||||
|
||||
g, err := s.repo.Create(ctx, g)
|
||||
if err != nil {
|
||||
return GroupView{}, fmt.Errorf("create group: %w", err)
|
||||
}
|
||||
|
||||
if err := s.repo.AddMember(ctx, GroupMember{
|
||||
ID: uuid.New(),
|
||||
GroupID: g.ID,
|
||||
UserID: creatorID,
|
||||
Role: MemberRoleAdmin,
|
||||
}); err != nil {
|
||||
return GroupView{}, fmt.Errorf("adding creator as admin: %w", err)
|
||||
}
|
||||
|
||||
profile := GroupProfile{GroupID: g.ID, Categories: []string{}}
|
||||
if err := s.repo.UpsertProfile(ctx, profile); err != nil {
|
||||
return GroupView{}, fmt.Errorf("creating group profile: %w", err)
|
||||
}
|
||||
|
||||
return GroupView{Group: g, Profile: profile}, nil
|
||||
}
|
||||
|
||||
func (s *Service) GetGroup(ctx context.Context, id uuid.UUID) (GroupView, error) {
|
||||
g, err := s.repo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return GroupView{}, err
|
||||
}
|
||||
p, err := s.repo.GetProfile(ctx, id)
|
||||
if err != nil {
|
||||
return GroupView{}, fmt.Errorf("get group profile: %w", err)
|
||||
}
|
||||
return GroupView{Group: g, Profile: p}, nil
|
||||
}
|
||||
|
||||
func (s *Service) UpdateProfile(ctx context.Context, requestorID, groupID uuid.UUID, req UpdateProfileRequest) (GroupProfile, error) {
|
||||
if err := s.requireAdmin(ctx, groupID, requestorID); err != nil {
|
||||
return GroupProfile{}, err
|
||||
}
|
||||
|
||||
current, err := s.repo.GetProfile(ctx, groupID)
|
||||
if err != nil {
|
||||
return GroupProfile{}, fmt.Errorf("get current profile: %w", err)
|
||||
}
|
||||
|
||||
if req.Description != nil {
|
||||
current.Description = *req.Description
|
||||
}
|
||||
if req.Categories != nil {
|
||||
current.Categories = req.Categories
|
||||
}
|
||||
if req.AvatarURL != nil {
|
||||
current.AvatarURL = *req.AvatarURL
|
||||
}
|
||||
if req.WebsiteURL != nil {
|
||||
current.WebsiteURL = *req.WebsiteURL
|
||||
}
|
||||
|
||||
if err := s.repo.UpsertProfile(ctx, current); err != nil {
|
||||
return GroupProfile{}, fmt.Errorf("update profile: %w", err)
|
||||
}
|
||||
|
||||
updated, err := s.repo.GetProfile(ctx, groupID)
|
||||
if err != nil {
|
||||
return GroupProfile{}, fmt.Errorf("re-fetch profile: %w", err)
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
func (s *Service) AddMember(ctx context.Context, requestorID, groupID, targetUserID uuid.UUID) error {
|
||||
if err := s.requireAdmin(ctx, groupID, requestorID); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.repo.AddMember(ctx, GroupMember{
|
||||
ID: uuid.New(),
|
||||
GroupID: groupID,
|
||||
UserID: targetUserID,
|
||||
Role: MemberRoleMember,
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Service) RemoveMember(ctx context.Context, requestorID, groupID, targetUserID uuid.UUID) error {
|
||||
// Self-remove is always allowed; otherwise require admin.
|
||||
if requestorID != targetUserID {
|
||||
if err := s.requireAdmin(ctx, groupID, requestorID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Guard against removing the last admin.
|
||||
target, err := s.repo.GetMember(ctx, groupID, targetUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if target.Role == MemberRoleAdmin {
|
||||
n, err := s.repo.CountAdmins(ctx, groupID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("count admins: %w", err)
|
||||
}
|
||||
if n <= 1 {
|
||||
return ErrCannotRemoveLastAdmin
|
||||
}
|
||||
}
|
||||
|
||||
return s.repo.RemoveMember(ctx, groupID, targetUserID)
|
||||
}
|
||||
|
||||
func (s *Service) ListMembers(ctx context.Context, groupID uuid.UUID) ([]GroupMemberView, error) {
|
||||
if _, err := s.repo.GetByID(ctx, groupID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.repo.ListMembers(ctx, groupID)
|
||||
}
|
||||
|
||||
func (s *Service) ListMyGroups(ctx context.Context, userID uuid.UUID) ([]Group, error) {
|
||||
return s.repo.ListByUser(ctx, userID)
|
||||
}
|
||||
|
||||
func (s *Service) requireAdmin(ctx context.Context, groupID, userID uuid.UUID) error {
|
||||
m, err := s.repo.GetMember(ctx, groupID, userID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrMemberNotFound) {
|
||||
return ErrNotGroupAdmin
|
||||
}
|
||||
return fmt.Errorf("checking group membership: %w", err)
|
||||
}
|
||||
if m.Role != MemberRoleAdmin {
|
||||
return ErrNotGroupAdmin
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package lagerleben
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"marktvogt.de/backend/internal/pkg/apierror"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
svc *Service
|
||||
}
|
||||
|
||||
func NewHandler(svc *Service) *Handler {
|
||||
return &Handler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *Handler) ListArticles(c *gin.Context) {
|
||||
articles, err := h.svc.ListArticles(c.Request.Context())
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("internal error")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
if articles == nil {
|
||||
articles = []Article{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": articles})
|
||||
}
|
||||
|
||||
func (h *Handler) GetArticle(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
article, err := h.svc.GetArticle(c.Request.Context(), slug)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apiErr := apierror.NotFound("article")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("internal error")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": article})
|
||||
}
|
||||
|
||||
func (h *Handler) ListCamps(c *gin.Context) {
|
||||
camps, err := h.svc.ListCamps(c.Request.Context())
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("internal error")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
if camps == nil {
|
||||
camps = []Camp{}
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": camps})
|
||||
}
|
||||
|
||||
func (h *Handler) GetCamp(c *gin.Context) {
|
||||
slug := c.Param("slug")
|
||||
camp, err := h.svc.GetCamp(c.Request.Context(), slug)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
apiErr := apierror.NotFound("camp")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("internal error")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": camp})
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package lagerleben_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"marktvogt.de/backend/internal/domain/lagerleben"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// -- in-memory fake --
|
||||
|
||||
type fakeRepo struct {
|
||||
articles []lagerleben.Article
|
||||
camps []lagerleben.Camp
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListArticles(_ context.Context) ([]lagerleben.Article, error) {
|
||||
var out []lagerleben.Article
|
||||
for _, a := range r.articles {
|
||||
if a.Published {
|
||||
out = append(out, a)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetArticleBySlug(_ context.Context, slug string) (lagerleben.Article, error) {
|
||||
for _, a := range r.articles {
|
||||
if a.Slug == slug && a.Published {
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
return lagerleben.Article{}, lagerleben.ErrNotFound
|
||||
}
|
||||
|
||||
func (r *fakeRepo) ListCamps(_ context.Context) ([]lagerleben.Camp, error) {
|
||||
var out []lagerleben.Camp
|
||||
for _, c := range r.camps {
|
||||
if c.Published {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeRepo) GetCampBySlug(_ context.Context, slug string) (lagerleben.Camp, error) {
|
||||
for _, c := range r.camps {
|
||||
if c.Slug == slug && c.Published {
|
||||
return c, nil
|
||||
}
|
||||
}
|
||||
return lagerleben.Camp{}, lagerleben.ErrNotFound
|
||||
}
|
||||
|
||||
func newRouter(repo lagerleben.Repository) *gin.Engine {
|
||||
svc := lagerleben.NewService(repo)
|
||||
h := lagerleben.NewHandler(svc)
|
||||
r := gin.New()
|
||||
lagerleben.RegisterRoutes(r.Group("/api/v1"), h)
|
||||
return r
|
||||
}
|
||||
|
||||
func seedArticle() lagerleben.Article {
|
||||
return lagerleben.Article{
|
||||
Slug: "test-artikel",
|
||||
Title: "Test Artikel",
|
||||
Subtitle: "Untertitel",
|
||||
Category: "Handwerk",
|
||||
PublishedOn: lagerleben.NewDateOnly(time.Date(2026, 4, 12, 0, 0, 0, 0, time.UTC)),
|
||||
Excerpt: "Kurzbeschreibung",
|
||||
Published: true,
|
||||
}
|
||||
}
|
||||
|
||||
func seedCamp() lagerleben.Camp {
|
||||
return lagerleben.Camp{
|
||||
Slug: "test-lager",
|
||||
Name: "Test Lager",
|
||||
Region: "Bayern",
|
||||
Period: "um 1350",
|
||||
Excerpt: "Beschreibung",
|
||||
Members: 12,
|
||||
Published: true,
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: list articles is public — no auth required.
|
||||
func TestListArticles_Public_Returns200(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := &fakeRepo{articles: []lagerleben.Article{seedArticle()}}
|
||||
router := newRouter(repo)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Data []lagerleben.Article `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Data) != 1 {
|
||||
t.Errorf("want 1 article, got %d", len(resp.Data))
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: unpublished articles are excluded from the list.
|
||||
func TestListArticles_ExcludesUnpublished(t *testing.T) {
|
||||
t.Parallel()
|
||||
a := seedArticle()
|
||||
a.Published = false
|
||||
repo := &fakeRepo{articles: []lagerleben.Article{a}}
|
||||
router := newRouter(repo)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var resp struct {
|
||||
Data []lagerleben.Article `json:"data"`
|
||||
}
|
||||
_ = json.NewDecoder(w.Body).Decode(&resp)
|
||||
if len(resp.Data) != 0 {
|
||||
t.Errorf("want 0 articles (all unpublished), got %d", len(resp.Data))
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: get article by slug returns 200 with correct data.
|
||||
func TestGetArticle_KnownSlug_Returns200(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := &fakeRepo{articles: []lagerleben.Article{seedArticle()}}
|
||||
router := newRouter(repo)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles/test-artikel", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Data lagerleben.Article `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Data.Slug != "test-artikel" {
|
||||
t.Errorf("want slug test-artikel, got %s", resp.Data.Slug)
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: unknown article slug returns 404.
|
||||
func TestGetArticle_UnknownSlug_Returns404(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := &fakeRepo{}
|
||||
router := newRouter(repo)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles/does-not-exist", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("want 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: list camps is public.
|
||||
func TestListCamps_Public_Returns200(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := &fakeRepo{camps: []lagerleben.Camp{seedCamp()}}
|
||||
router := newRouter(repo)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/camps", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Data []lagerleben.Camp `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(resp.Data) != 1 {
|
||||
t.Errorf("want 1 camp, got %d", len(resp.Data))
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: unknown camp slug returns 404.
|
||||
func TestGetCamp_UnknownSlug_Returns404(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := &fakeRepo{}
|
||||
router := newRouter(repo)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/camps/does-not-exist", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("want 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: date is serialized as YYYY-MM-DD (not full RFC3339).
|
||||
func TestArticle_DateFormat(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := &fakeRepo{articles: []lagerleben.Article{seedArticle()}}
|
||||
router := newRouter(repo)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/lagerleben/articles/test-artikel", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
var raw map[string]map[string]any
|
||||
_ = json.NewDecoder(bytes.NewReader(w.Body.Bytes())).Decode(&raw)
|
||||
date, _ := raw["data"]["date"].(string)
|
||||
if date != "2026-04-12" {
|
||||
t.Errorf("want date 2026-04-12, got %q", date)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package lagerleben
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
type Article struct {
|
||||
ID uuid.UUID `json:"-"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Subtitle string `json:"subtitle"`
|
||||
Category string `json:"category"`
|
||||
PublishedOn dateOnly `json:"date"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
Body string `json:"body,omitempty"`
|
||||
Published bool `json:"-"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
type Camp struct {
|
||||
ID uuid.UUID `json:"-"`
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Region string `json:"region"`
|
||||
Period string `json:"period"`
|
||||
Excerpt string `json:"excerpt"`
|
||||
Members int `json:"members"`
|
||||
Published bool `json:"-"`
|
||||
CreatedAt time.Time `json:"-"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
// dateOnly marshals a time.Time as "YYYY-MM-DD" for JSON.
|
||||
type dateOnly struct {
|
||||
time.Time
|
||||
}
|
||||
|
||||
func NewDateOnly(t time.Time) dateOnly {
|
||||
return dateOnly{t}
|
||||
}
|
||||
|
||||
func (d dateOnly) MarshalJSON() ([]byte, error) {
|
||||
return []byte(`"` + d.UTC().Format("2006-01-02") + `"`), nil
|
||||
}
|
||||
|
||||
func (d *dateOnly) UnmarshalJSON(data []byte) error {
|
||||
t, err := time.Parse(`"2006-01-02"`, string(data))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
d.Time = t
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package lagerleben
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type Repository interface {
|
||||
ListArticles(ctx context.Context) ([]Article, error)
|
||||
GetArticleBySlug(ctx context.Context, slug string) (Article, error)
|
||||
ListCamps(ctx context.Context) ([]Camp, error)
|
||||
GetCampBySlug(ctx context.Context, slug string) (Camp, error)
|
||||
}
|
||||
|
||||
type pgRepository struct {
|
||||
db *pgxpool.Pool
|
||||
}
|
||||
|
||||
func NewRepository(db *pgxpool.Pool) Repository {
|
||||
return &pgRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *pgRepository) ListArticles(ctx context.Context) ([]Article, error) {
|
||||
rows, err := r.db.Query(ctx,
|
||||
`SELECT id, slug, title, subtitle, category, published_on, excerpt, published, created_at, updated_at
|
||||
FROM lagerleben_articles
|
||||
WHERE published = TRUE
|
||||
ORDER BY published_on DESC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Article
|
||||
for rows.Next() {
|
||||
a, err := scanArticle(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetArticleBySlug(ctx context.Context, slug string) (Article, error) {
|
||||
row := r.db.QueryRow(ctx,
|
||||
`SELECT id, slug, title, subtitle, category, published_on, excerpt, published, created_at, updated_at
|
||||
FROM lagerleben_articles
|
||||
WHERE slug = $1 AND published = TRUE`,
|
||||
slug)
|
||||
a, err := scanArticle(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Article{}, ErrNotFound
|
||||
}
|
||||
return a, err
|
||||
}
|
||||
|
||||
func (r *pgRepository) ListCamps(ctx context.Context) ([]Camp, error) {
|
||||
rows, err := r.db.Query(ctx,
|
||||
`SELECT id, slug, name, region, period, excerpt, members, published, created_at, updated_at
|
||||
FROM lagerleben_camps
|
||||
WHERE published = TRUE
|
||||
ORDER BY name`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []Camp
|
||||
for rows.Next() {
|
||||
c, err := scanCamp(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *pgRepository) GetCampBySlug(ctx context.Context, slug string) (Camp, error) {
|
||||
row := r.db.QueryRow(ctx,
|
||||
`SELECT id, slug, name, region, period, excerpt, members, published, created_at, updated_at
|
||||
FROM lagerleben_camps
|
||||
WHERE slug = $1 AND published = TRUE`,
|
||||
slug)
|
||||
c, err := scanCamp(row)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Camp{}, ErrNotFound
|
||||
}
|
||||
return c, err
|
||||
}
|
||||
|
||||
type scanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanArticle(row scanner) (Article, error) {
|
||||
var a Article
|
||||
var publishedOn time.Time
|
||||
err := row.Scan(
|
||||
&a.ID, &a.Slug, &a.Title, &a.Subtitle, &a.Category,
|
||||
&publishedOn, &a.Excerpt, &a.Published, &a.CreatedAt, &a.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return Article{}, err
|
||||
}
|
||||
a.PublishedOn = dateOnly{publishedOn}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func scanCamp(row scanner) (Camp, error) {
|
||||
var c Camp
|
||||
err := row.Scan(
|
||||
&c.ID, &c.Slug, &c.Name, &c.Region, &c.Period,
|
||||
&c.Excerpt, &c.Members, &c.Published, &c.CreatedAt, &c.UpdatedAt,
|
||||
)
|
||||
return c, err
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package lagerleben
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterRoutes(rg *gin.RouterGroup, h *Handler) {
|
||||
rg.GET("/lagerleben/articles", h.ListArticles)
|
||||
rg.GET("/lagerleben/articles/:slug", h.GetArticle)
|
||||
rg.GET("/lagerleben/camps", h.ListCamps)
|
||||
rg.GET("/lagerleben/camps/:slug", h.GetCamp)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package lagerleben
|
||||
|
||||
import "context"
|
||||
|
||||
type Service struct {
|
||||
repo Repository
|
||||
}
|
||||
|
||||
func NewService(repo Repository) *Service {
|
||||
return &Service{repo: repo}
|
||||
}
|
||||
|
||||
func (s *Service) ListArticles(ctx context.Context) ([]Article, error) {
|
||||
return s.repo.ListArticles(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) GetArticle(ctx context.Context, slug string) (Article, error) {
|
||||
return s.repo.GetArticleBySlug(ctx, slug)
|
||||
}
|
||||
|
||||
func (s *Service) ListCamps(ctx context.Context) ([]Camp, error) {
|
||||
return s.repo.ListCamps(ctx)
|
||||
}
|
||||
|
||||
func (s *Service) GetCamp(ctx context.Context, slug string) (Camp, error) {
|
||||
return s.repo.GetCampBySlug(ctx, slug)
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package market
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
@@ -30,6 +31,7 @@ func (h *Handler) Search(c *gin.Context) { //nolint:dupl // similar structure to
|
||||
|
||||
markets, total, err := h.service.Search(c.Request.Context(), params)
|
||||
if err != nil {
|
||||
slog.ErrorContext(c.Request.Context(), "market search failed", "error", err)
|
||||
apiErr := apierror.Internal("failed to search markets")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
@@ -77,6 +79,7 @@ func (h *Handler) GetBySlug(c *gin.Context) {
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
slog.ErrorContext(c.Request.Context(), "market get by slug failed", "slug", slug, "error", err)
|
||||
apiErr := apierror.Internal("failed to get market")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"marktvogt.de/backend/internal/pkg/apierror"
|
||||
)
|
||||
|
||||
type AdminHandler struct {
|
||||
svc *AdminService
|
||||
}
|
||||
|
||||
func NewAdminHandler(svc *AdminService) *AdminHandler {
|
||||
return &AdminHandler{svc: svc}
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListPending(c *gin.Context) {
|
||||
users, err := h.svc.ListPending(c.Request.Context())
|
||||
if err != nil {
|
||||
apiErr := apierror.Internal("failed to list pending users")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]AdminUserData, len(users))
|
||||
for i, u := range users {
|
||||
items[i] = toAdminUserData(u)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"data": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Approve(c *gin.Context) {
|
||||
id, ok := parseUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.svc.Approve(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
apiErr := apierror.NotFound("user")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to approve user")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": toAdminUserData(u)})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Reject(c *gin.Context) {
|
||||
id, ok := parseUserID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
u, err := h.svc.Reject(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrUserNotFound) {
|
||||
apiErr := apierror.NotFound("user")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
apiErr := apierror.Internal("failed to reject user")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"data": toAdminUserData(u)})
|
||||
}
|
||||
|
||||
func parseUserID(c *gin.Context) (uuid.UUID, bool) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
apiErr := apierror.BadRequest("invalid_user_id", "invalid user id")
|
||||
c.JSON(apiErr.Status, apierror.NewResponse(apiErr))
|
||||
return uuid.Nil, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
|
||||
// AdminUserData is the response shape for admin user endpoints.
|
||||
type AdminUserData struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"display_name"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
ApprovedAt *string `json:"approved_at,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
}
|
||||
|
||||
func toAdminUserData(u User) AdminUserData {
|
||||
d := AdminUserData{
|
||||
ID: u.ID,
|
||||
Email: u.Email,
|
||||
DisplayName: u.DisplayName,
|
||||
Role: u.Role,
|
||||
Status: u.Status,
|
||||
CreatedAt: u.CreatedAt.Format("2006-01-02T15:04:05Z"),
|
||||
}
|
||||
if u.ApprovedAt != nil {
|
||||
s := u.ApprovedAt.Format("2006-01-02T15:04:05Z")
|
||||
d.ApprovedAt = &s
|
||||
}
|
||||
return d
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package user
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterAdminRoutes(rg *gin.RouterGroup, h *AdminHandler, requireAuth, requireAdmin gin.HandlerFunc) {
|
||||
admin := rg.Group("/admin/users", requireAuth, requireAdmin)
|
||||
{
|
||||
admin.GET("/pending", h.ListPending)
|
||||
admin.POST("/:id/approve", h.Approve)
|
||||
admin.POST("/:id/reject", h.Reject)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package user_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"marktvogt.de/backend/internal/domain/user"
|
||||
"marktvogt.de/backend/internal/middleware"
|
||||
)
|
||||
|
||||
func init() {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// fakeUserRepo satisfies user.Repository in-memory.
|
||||
type fakeUserRepo struct {
|
||||
users map[uuid.UUID]user.User
|
||||
}
|
||||
|
||||
func newFakeUserRepo(initial ...user.User) *fakeUserRepo {
|
||||
r := &fakeUserRepo{users: make(map[uuid.UUID]user.User)}
|
||||
for _, u := range initial {
|
||||
r.users[u.ID] = u
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) Create(_ context.Context, email, hash, name string) (user.User, error) {
|
||||
u := user.User{ID: uuid.New(), Email: email, DisplayName: name, Status: user.StatusActive}
|
||||
r.users[u.ID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) CreateOAuthUser(_ context.Context, email, name string, verified bool) (user.User, error) {
|
||||
u := user.User{ID: uuid.New(), Email: email, DisplayName: name, EmailVerified: verified, Status: user.StatusActive}
|
||||
r.users[u.ID] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) GetByID(_ context.Context, id uuid.UUID) (user.User, error) {
|
||||
u, ok := r.users[id]
|
||||
if !ok {
|
||||
return user.User{}, user.ErrUserNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) GetByEmail(_ context.Context, email string) (user.User, error) {
|
||||
for _, u := range r.users {
|
||||
if u.Email == email {
|
||||
return u, nil
|
||||
}
|
||||
}
|
||||
return user.User{}, user.ErrUserNotFound
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) Update(_ context.Context, id uuid.UUID, _ map[string]any) (user.User, error) {
|
||||
u, ok := r.users[id]
|
||||
if !ok {
|
||||
return user.User{}, user.ErrUserNotFound
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) SoftDelete(_ context.Context, _ uuid.UUID) error { return nil }
|
||||
func (r *fakeUserRepo) Restore(_ context.Context, _ uuid.UUID) error { return nil }
|
||||
|
||||
func (r *fakeUserRepo) GetDeletedByID(_ context.Context, id uuid.UUID) (user.User, error) {
|
||||
return user.User{}, user.ErrUserNotFound
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) ListByStatus(_ context.Context, status string) ([]user.User, error) {
|
||||
var out []user.User
|
||||
for _, u := range r.users {
|
||||
if u.Status == status {
|
||||
out = append(out, u)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *fakeUserRepo) SetStatus(_ context.Context, id uuid.UUID, status string, approvedAt *time.Time) (user.User, error) {
|
||||
u, ok := r.users[id]
|
||||
if !ok {
|
||||
return user.User{}, user.ErrUserNotFound
|
||||
}
|
||||
u.Status = status
|
||||
u.ApprovedAt = approvedAt
|
||||
r.users[id] = u
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// fakeSessionRevoker satisfies user.SessionRevoker in-memory.
|
||||
type fakeSessionRevoker struct {
|
||||
revoked []uuid.UUID
|
||||
}
|
||||
|
||||
func (r *fakeSessionRevoker) DeleteUserSessions(_ context.Context, userID uuid.UUID) error {
|
||||
r.revoked = append(r.revoked, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// adminRouter builds a gin.Engine wired with the admin user routes.
|
||||
// The roleMiddleware parameter injects user_id and user_role into the context,
|
||||
// mimicking what RequireAuth does with real tokens.
|
||||
func adminRouter(repo user.Repository, roleMiddleware gin.HandlerFunc) *gin.Engine {
|
||||
svc := user.NewAdminService(repo, &fakeSessionRevoker{})
|
||||
h := user.NewAdminHandler(svc)
|
||||
|
||||
router := gin.New()
|
||||
v1 := router.Group("/api/v1")
|
||||
requireAdmin := middleware.RequireRole(user.RoleAdmin)
|
||||
user.RegisterAdminRoutes(v1, h, roleMiddleware, requireAdmin)
|
||||
return router
|
||||
}
|
||||
|
||||
// stubAuth returns a middleware that sets gin context values to simulate an authenticated user.
|
||||
func stubAuth(userRole string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.Set("user_id", uuid.New())
|
||||
c.Set("user_role", userRole)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// noAuth returns a middleware that aborts with 401, matching RequireAuth with no valid token.
|
||||
func noAuth() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
c.AbortWithStatus(http.StatusUnauthorized)
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: admin user endpoints must reject unauthenticated requests (401).
|
||||
func TestAdminUserEndpoints_Unauthenticated_Returns401(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newFakeUserRepo()
|
||||
router := adminRouter(repo, noAuth())
|
||||
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/admin/users/pending"},
|
||||
{http.MethodPost, "/api/v1/admin/users/" + uuid.New().String() + "/approve"},
|
||||
{http.MethodPost, "/api/v1/admin/users/" + uuid.New().String() + "/reject"},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(ep.method, ep.path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("want 401, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: admin user endpoints must reject non-admin authenticated users (403).
|
||||
func TestAdminUserEndpoints_NonAdmin_Returns403(t *testing.T) {
|
||||
t.Parallel()
|
||||
repo := newFakeUserRepo()
|
||||
router := adminRouter(repo, stubAuth(user.RoleUser))
|
||||
|
||||
endpoints := []struct {
|
||||
method string
|
||||
path string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/admin/users/pending"},
|
||||
{http.MethodPost, "/api/v1/admin/users/" + uuid.New().String() + "/approve"},
|
||||
{http.MethodPost, "/api/v1/admin/users/" + uuid.New().String() + "/reject"},
|
||||
}
|
||||
|
||||
for _, ep := range endpoints {
|
||||
t.Run(ep.method+" "+ep.path, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(ep.method, ep.path, nil)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Errorf("want 403, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// PoC: admin can list pending users and approve/reject them.
|
||||
func TestAdminUserEndpoints_Admin_Succeeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
pendingID := uuid.New()
|
||||
pending := user.User{
|
||||
ID: pendingID,
|
||||
Email: "pending@example.com",
|
||||
DisplayName: "Pending User",
|
||||
Role: user.RoleVeranstalter,
|
||||
Status: user.StatusPending,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
repo := newFakeUserRepo(pending)
|
||||
revoker := &fakeSessionRevoker{}
|
||||
svc := user.NewAdminService(repo, revoker)
|
||||
h := user.NewAdminHandler(svc)
|
||||
|
||||
router := gin.New()
|
||||
v1 := router.Group("/api/v1")
|
||||
requireAdmin := middleware.RequireRole(user.RoleAdmin)
|
||||
user.RegisterAdminRoutes(v1, h, stubAuth(user.RoleAdmin), requireAdmin)
|
||||
|
||||
t.Run("list pending returns pending user", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/users/pending", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), "pending@example.com") {
|
||||
t.Errorf("response missing pending user email: %s", w.Body.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("approve changes status to active and revokes sessions", func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/"+pendingID.String()+"/approve", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"status":"active"`) {
|
||||
t.Errorf("response missing active status: %s", w.Body.String())
|
||||
}
|
||||
if len(revoker.revoked) == 0 {
|
||||
t.Error("expected sessions to be revoked on approval")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// PoC: reject changes status to suspended and revokes sessions.
|
||||
func TestAdminRejectUser_RevokesSessionsAndSuspends(t *testing.T) {
|
||||
t.Parallel()
|
||||
targetID := uuid.New()
|
||||
target := user.User{
|
||||
ID: targetID,
|
||||
Email: "target@example.com",
|
||||
DisplayName: "Target User",
|
||||
Role: user.RoleVeranstalter,
|
||||
Status: user.StatusPending,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
repo := newFakeUserRepo(target)
|
||||
revoker := &fakeSessionRevoker{}
|
||||
svc := user.NewAdminService(repo, revoker)
|
||||
h := user.NewAdminHandler(svc)
|
||||
|
||||
router := gin.New()
|
||||
v1 := router.Group("/api/v1")
|
||||
requireAdmin := middleware.RequireRole(user.RoleAdmin)
|
||||
user.RegisterAdminRoutes(v1, h, stubAuth(user.RoleAdmin), requireAdmin)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/users/"+targetID.String()+"/reject", nil)
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d (body=%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if !strings.Contains(w.Body.String(), `"status":"suspended"`) {
|
||||
t.Errorf("response missing suspended status: %s", w.Body.String())
|
||||
}
|
||||
if len(revoker.revoked) == 0 {
|
||||
t.Error("expected sessions to be revoked on rejection")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package user
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// SessionRevoker is the subset of auth.Repository needed by AdminService.
|
||||
// Defined here to avoid an import cycle between the user and auth packages.
|
||||
type SessionRevoker interface {
|
||||
DeleteUserSessions(ctx context.Context, userID uuid.UUID) error
|
||||
}
|
||||
|
||||
type AdminService struct {
|
||||
repo Repository
|
||||
sessions SessionRevoker
|
||||
}
|
||||
|
||||
func NewAdminService(repo Repository, sessions SessionRevoker) *AdminService {
|
||||
return &AdminService{repo: repo, sessions: sessions}
|
||||
}
|
||||
|
||||
func (s *AdminService) ListPending(ctx context.Context) ([]User, error) {
|
||||
return s.repo.ListByStatus(ctx, StatusPending)
|
||||
}
|
||||
|
||||
func (s *AdminService) Approve(ctx context.Context, id uuid.UUID) (User, error) {
|
||||
if err := s.sessions.DeleteUserSessions(ctx, id); err != nil {
|
||||
return User{}, fmt.Errorf("revoking sessions before approval: %w", err)
|
||||
}
|
||||
now := time.Now()
|
||||
u, err := s.repo.SetStatus(ctx, id, StatusActive, &now)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("approving user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (s *AdminService) Reject(ctx context.Context, id uuid.UUID) (User, error) {
|
||||
if err := s.sessions.DeleteUserSessions(ctx, id); err != nil {
|
||||
return User{}, fmt.Errorf("revoking sessions before rejection: %w", err)
|
||||
}
|
||||
u, err := s.repo.SetStatus(ctx, id, StatusSuspended, nil)
|
||||
if err != nil {
|
||||
return User{}, fmt.Errorf("rejecting user: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
@@ -14,6 +14,8 @@ type User struct {
|
||||
DisplayName string `json:"display_name"`
|
||||
AvatarURL string `json:"avatar_url"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
ApprovedAt *time.Time `json:"approved_at,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
@@ -25,6 +25,8 @@ type Repository interface {
|
||||
SoftDelete(ctx context.Context, id uuid.UUID) error
|
||||
Restore(ctx context.Context, id uuid.UUID) error
|
||||
GetDeletedByID(ctx context.Context, id uuid.UUID) (User, error)
|
||||
ListByStatus(ctx context.Context, status string) ([]User, error)
|
||||
SetStatus(ctx context.Context, id uuid.UUID, status string, approvedAt *time.Time) (User, error)
|
||||
}
|
||||
|
||||
type pgRepository struct {
|
||||
@@ -40,10 +42,10 @@ func (r *pgRepository) Create(ctx context.Context, email, passwordHash, displayN
|
||||
err := r.db.QueryRow(ctx, `
|
||||
INSERT INTO users (email, password_hash, display_name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at
|
||||
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
|
||||
`, email, passwordHash, displayName).Scan(
|
||||
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
|
||||
&u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||
&u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if isDuplicateKeyError(err) {
|
||||
@@ -59,10 +61,10 @@ func (r *pgRepository) CreateOAuthUser(ctx context.Context, email, displayName s
|
||||
err := r.db.QueryRow(ctx, `
|
||||
INSERT INTO users (email, email_verified, display_name)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at
|
||||
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
|
||||
`, email, emailVerified, displayName).Scan(
|
||||
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
|
||||
&u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||
&u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if isDuplicateKeyError(err) {
|
||||
@@ -88,12 +90,12 @@ func (r *pgRepository) GetDeletedByID(ctx context.Context, id uuid.UUID) (User,
|
||||
func (r *pgRepository) getUser(ctx context.Context, where string, arg any) (User, error) {
|
||||
var u User
|
||||
err := r.db.QueryRow(ctx, fmt.Sprintf(`
|
||||
SELECT id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at
|
||||
SELECT id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
|
||||
FROM users
|
||||
WHERE %s
|
||||
`, where), arg).Scan(
|
||||
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
|
||||
&u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||
&u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -125,10 +127,10 @@ func (r *pgRepository) Update(ctx context.Context, id uuid.UUID, fields map[stri
|
||||
err := r.db.QueryRow(ctx, fmt.Sprintf(`
|
||||
UPDATE users SET %s
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, deleted_at, created_at, updated_at
|
||||
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
|
||||
`, setClauses), args...).Scan(
|
||||
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
|
||||
&u.AvatarURL, &u.Role, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||
&u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
@@ -153,6 +155,51 @@ func (r *pgRepository) SoftDelete(ctx context.Context, id uuid.UUID) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) ListByStatus(ctx context.Context, status string) ([]User, error) {
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
|
||||
FROM users
|
||||
WHERE status = $1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC
|
||||
`, status)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing users by status: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var users []User
|
||||
for rows.Next() {
|
||||
var u User
|
||||
if err := rows.Scan(
|
||||
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
|
||||
&u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("scanning user: %w", err)
|
||||
}
|
||||
users = append(users, u)
|
||||
}
|
||||
return users, rows.Err()
|
||||
}
|
||||
|
||||
func (r *pgRepository) SetStatus(ctx context.Context, id uuid.UUID, status string, approvedAt *time.Time) (User, error) {
|
||||
var u User
|
||||
err := r.db.QueryRow(ctx, `
|
||||
UPDATE users SET status = $2, approved_at = $3
|
||||
WHERE id = $1 AND deleted_at IS NULL
|
||||
RETURNING id, email, email_verified, password_hash, display_name, avatar_url, role, status, approved_at, deleted_at, created_at, updated_at
|
||||
`, id, status, approvedAt).Scan(
|
||||
&u.ID, &u.Email, &u.EmailVerified, &u.PasswordHash, &u.DisplayName,
|
||||
&u.AvatarURL, &u.Role, &u.Status, &u.ApprovedAt, &u.DeletedAt, &u.CreatedAt, &u.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return User{}, ErrUserNotFound
|
||||
}
|
||||
return User{}, fmt.Errorf("setting user status: %w", err)
|
||||
}
|
||||
return u, nil
|
||||
}
|
||||
|
||||
func (r *pgRepository) Restore(ctx context.Context, id uuid.UUID) error {
|
||||
tag, err := r.db.Exec(ctx, `
|
||||
UPDATE users SET deleted_at = NULL
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package user
|
||||
|
||||
const (
|
||||
RoleGast = "gast"
|
||||
RoleUser = "user"
|
||||
RoleVeranstalter = "veranstalter"
|
||||
RoleHaendler = "haendler"
|
||||
RoleLager = "lager"
|
||||
RoleAdmin = "admin"
|
||||
)
|
||||
|
||||
const (
|
||||
StatusPending = "pending"
|
||||
StatusActive = "active"
|
||||
StatusSuspended = "suspended"
|
||||
)
|
||||
@@ -2,15 +2,20 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"marktvogt.de/backend/internal/domain/application"
|
||||
"marktvogt.de/backend/internal/domain/auth"
|
||||
"marktvogt.de/backend/internal/domain/discovery"
|
||||
"marktvogt.de/backend/internal/domain/discovery/crawler"
|
||||
"marktvogt.de/backend/internal/domain/discovery/enrich"
|
||||
"marktvogt.de/backend/internal/domain/group"
|
||||
"marktvogt.de/backend/internal/domain/lagerleben"
|
||||
"marktvogt.de/backend/internal/domain/market"
|
||||
"marktvogt.de/backend/internal/domain/settings"
|
||||
"marktvogt.de/backend/internal/domain/user"
|
||||
@@ -85,6 +90,31 @@ func (s *Server) registerRoutes() {
|
||||
userHandler := user.NewHandler(userSvc)
|
||||
user.RegisterRoutes(v1, userHandler, requireAuth)
|
||||
|
||||
// Admin user management routes
|
||||
adminUserSvc := user.NewAdminService(userRepo, authRepo)
|
||||
adminUserHandler := user.NewAdminHandler(adminUserSvc)
|
||||
user.RegisterAdminRoutes(v1, adminUserHandler, requireAuth, middleware.RequireRole(user.RoleAdmin))
|
||||
|
||||
// Group routes
|
||||
groupRepo := group.NewRepository(s.db)
|
||||
groupSvc := group.NewService(groupRepo)
|
||||
groupHandler := group.NewHandler(groupSvc)
|
||||
group.RegisterRoutes(v1, groupHandler, requireAuth)
|
||||
|
||||
// Application routes — GroupMembershipChecker is adapted from groupRepo
|
||||
// to avoid a direct import of the group package from the application package.
|
||||
groupChecker := &groupCheckerAdapter{repo: groupRepo}
|
||||
appRepo := application.NewRepository(s.db)
|
||||
appSvc := application.NewService(appRepo, groupChecker)
|
||||
appHandler := application.NewHandler(appSvc)
|
||||
application.RegisterRoutes(v1, appHandler, requireAuth)
|
||||
|
||||
// Lagerleben routes (public read-only)
|
||||
lagerlebenRepo := lagerleben.NewRepository(s.db)
|
||||
lagerlebenSvc := lagerleben.NewService(lagerlebenRepo)
|
||||
lagerlebenHandler := lagerleben.NewHandler(lagerlebenSvc)
|
||||
lagerleben.RegisterRoutes(v1, lagerlebenHandler)
|
||||
|
||||
// Market routes (public + submission + admin)
|
||||
tsVerifier := turnstile.New(s.cfg.Turnstile.SecretKey)
|
||||
|
||||
@@ -146,6 +176,31 @@ func (s *Server) registerRoutes() {
|
||||
settings.RegisterRoutes(v1, settingsHandler, requireAuth, requireAdmin)
|
||||
}
|
||||
|
||||
// groupCheckerAdapter adapts group.Repository to application.GroupMembershipChecker
|
||||
// so the application package does not import the group package directly.
|
||||
type groupCheckerAdapter struct {
|
||||
repo group.Repository
|
||||
}
|
||||
|
||||
func (a *groupCheckerAdapter) IsGroupAdmin(ctx context.Context, groupID, userID uuid.UUID) (bool, error) {
|
||||
m, err := a.repo.GetMember(ctx, groupID, userID)
|
||||
if errors.Is(err, group.ErrMemberNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return m.Role == group.MemberRoleAdmin, nil
|
||||
}
|
||||
|
||||
func (a *groupCheckerAdapter) IsGroupMember(ctx context.Context, groupID, userID uuid.UUID) (bool, error) {
|
||||
_, err := a.repo.GetMember(ctx, groupID, userID)
|
||||
if errors.Is(err, group.ErrMemberNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func (s *Server) healthz(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE users
|
||||
DROP CONSTRAINT IF EXISTS users_role_check,
|
||||
DROP COLUMN IF EXISTS approved_at,
|
||||
DROP COLUMN IF EXISTS status;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- Extend users table with approval workflow columns.
|
||||
-- status tracks the lifecycle of elevated-role accounts (pending → active | suspended).
|
||||
-- All existing users default to 'active' to preserve current behaviour.
|
||||
-- The role CHECK constraint formalises the allowed role values without an ENUM
|
||||
-- so future roles can be added with a simple ALTER TABLE + constraint update.
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('pending', 'active', 'suspended')),
|
||||
ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ;
|
||||
|
||||
ALTER TABLE users
|
||||
DROP CONSTRAINT IF EXISTS users_role_check;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_role_check
|
||||
CHECK (role IN ('gast', 'user', 'veranstalter', 'haendler', 'lager', 'admin'));
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP INDEX IF EXISTS group_members_group_id_idx;
|
||||
DROP INDEX IF EXISTS group_members_user_id_idx;
|
||||
DROP TABLE IF EXISTS group_profiles;
|
||||
DROP TABLE IF EXISTS group_members;
|
||||
DROP TABLE IF EXISTS groups;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- Groups are the unit through which Haendler/Kuenstler/Lager apply to markets.
|
||||
-- A solo merchant is a one-person group; the model is uniform either way.
|
||||
|
||||
CREATE TABLE groups (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('haendler', 'kuenstler', 'lager')),
|
||||
created_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE group_members (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
role TEXT NOT NULL DEFAULT 'member' CHECK (role IN ('admin', 'member')),
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (group_id, user_id)
|
||||
);
|
||||
|
||||
CREATE TABLE group_profiles (
|
||||
group_id UUID PRIMARY KEY REFERENCES groups(id) ON DELETE CASCADE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
categories TEXT[] NOT NULL DEFAULT '{}',
|
||||
avatar_url TEXT NOT NULL DEFAULT '',
|
||||
website_url TEXT NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX group_members_user_id_idx ON group_members(user_id);
|
||||
CREATE INDEX group_members_group_id_idx ON group_members(group_id);
|
||||
@@ -0,0 +1,5 @@
|
||||
DROP INDEX IF EXISTS application_status_log_app_id_idx;
|
||||
DROP INDEX IF EXISTS applications_market_edition_id_idx;
|
||||
DROP INDEX IF EXISTS applications_group_id_idx;
|
||||
DROP TABLE IF EXISTS application_status_log;
|
||||
DROP TABLE IF EXISTS applications;
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Applications are submitted by groups to specific market editions.
|
||||
-- One application per group per market edition (UNIQUE constraint).
|
||||
-- Status transitions: draft -> submitted -> reviewing -> accepted|rejected|waitlisted.
|
||||
-- Group-side transitions (draft, submit) live here; veranstalter review is Phase 4b.
|
||||
|
||||
CREATE TABLE applications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
group_id UUID NOT NULL REFERENCES groups(id),
|
||||
market_edition_id UUID NOT NULL REFERENCES market_editions(id),
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft', 'submitted', 'reviewing', 'accepted', 'rejected', 'waitlisted')),
|
||||
-- Standard template fields (09-bewerbung.md)
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
area_sqm NUMERIC(8,2),
|
||||
needs_power BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
needs_water BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
num_persons INT NOT NULL DEFAULT 1 CHECK (num_persons >= 1),
|
||||
num_tents INT NOT NULL DEFAULT 0 CHECK (num_tents >= 0),
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
submitted_by UUID REFERENCES users(id),
|
||||
submitted_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
UNIQUE (group_id, market_edition_id)
|
||||
);
|
||||
|
||||
-- Full audit trail of every status change on an application.
|
||||
CREATE TABLE application_status_log (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
|
||||
from_status TEXT,
|
||||
to_status TEXT NOT NULL,
|
||||
changed_by UUID NOT NULL REFERENCES users(id),
|
||||
note TEXT NOT NULL DEFAULT '',
|
||||
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX applications_group_id_idx ON applications(group_id);
|
||||
CREATE INDEX applications_market_edition_id_idx ON applications(market_edition_id);
|
||||
CREATE INDEX application_status_log_app_id_idx ON application_status_log(application_id);
|
||||
@@ -0,0 +1,2 @@
|
||||
DROP TABLE IF EXISTS lagerleben_camps;
|
||||
DROP TABLE IF EXISTS lagerleben_articles;
|
||||
@@ -0,0 +1,72 @@
|
||||
CREATE TABLE lagerleben_articles (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
subtitle TEXT NOT NULL DEFAULT '',
|
||||
category TEXT NOT NULL DEFAULT '',
|
||||
published_on DATE NOT NULL,
|
||||
excerpt TEXT NOT NULL DEFAULT '',
|
||||
body TEXT NOT NULL DEFAULT '',
|
||||
published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE TABLE lagerleben_camps (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
region TEXT NOT NULL DEFAULT '',
|
||||
period TEXT NOT NULL DEFAULT '',
|
||||
excerpt TEXT NOT NULL DEFAULT '',
|
||||
members INT NOT NULL DEFAULT 0 CHECK (members >= 0),
|
||||
published BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Seed articles from the design mock so the frontend isn't empty on first deploy.
|
||||
INSERT INTO lagerleben_articles (slug, title, subtitle, category, published_on, excerpt, published) VALUES
|
||||
('das-handwerk-des-schwertschmieds',
|
||||
'Das Handwerk des Schwertschmieds',
|
||||
'Zwischen Amboss und Feuer — ein Tag in der Werkstatt von Konrad Brenner',
|
||||
'Handwerk', '2026-04-12',
|
||||
'Seit dreißig Jahren schmiedet Konrad Brenner Schwerter für Mittelaltermärkte in ganz Europa. Wir haben ihn in seiner Werkstatt in Dinkelsbühl besucht und zugeschaut.',
|
||||
TRUE),
|
||||
('lager-aufbauen-checkliste',
|
||||
'Lager aufbauen in 4 Stunden',
|
||||
'Die bewährte Checkliste des Compagnie du Cerf Rouge',
|
||||
'Praxis', '2026-03-28',
|
||||
'Wer ein Lager auf dem Markt aufbaut, kennt das Chaos der ersten Stunden. Die Compagnie du Cerf Rouge hat ihre Routine über Jahre verfeinert und teilt sie hier.',
|
||||
TRUE),
|
||||
('historische-stoffe-1350',
|
||||
'Stoffe des 14. Jahrhunderts',
|
||||
'Was ist historisch korrekt — und was sieht nur so aus?',
|
||||
'Recherche', '2026-03-10',
|
||||
'Wollköper, Leinen, gelegentlich Seide: Die Auswahl historisch korrekter Stoffe ist kleiner als der Markt suggeriert. Ein Überblick über Quellen und Fallstricke.',
|
||||
TRUE),
|
||||
('kinder-im-lager',
|
||||
'Kinder im Lager',
|
||||
'Wie Familien das Lagerleben gestalten — ohne auf Authentizität zu verzichten',
|
||||
'Gemeinschaft', '2026-02-20',
|
||||
'Immer mehr Familien sind Teil der Mittelalterszene. Was bedeutet das für das Lagerkonzept, den Marktablauf und die Gemeinschaft?',
|
||||
TRUE)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
|
||||
INSERT INTO lagerleben_camps (slug, name, region, period, excerpt, members, published) VALUES
|
||||
('compagnie-du-cerf-rouge',
|
||||
'Compagnie du Cerf Rouge',
|
||||
'Bayern', 'um 1350',
|
||||
'Lebendige Darstellung eines fahrenden Söldnertrupps des 14. Jahrhunderts. Schwerpunkt: textile Handarbeit, Feldkochen und Waffenkunde.',
|
||||
14, TRUE),
|
||||
('lagergemeinschaft-nordmark',
|
||||
'Lagergemeinschaft Nordmark',
|
||||
'Schleswig-Holstein', 'Wikingerzeit',
|
||||
'Gemeinschaft zur Darstellung des wikingerzeitlichen Alltags auf skandinavischen und deutschen Märkten.',
|
||||
22, TRUE),
|
||||
('familia-von-hohenstein',
|
||||
'Familia von Hohenstein',
|
||||
'Baden-Württemberg', 'Hochmittelalter',
|
||||
'Adelige Haushaltung mit vollständiger Küchenausstattung, Weberei und Kinderdarstellung. Besonderen Wert legen wir auf quellenbasierte Kostümierung.',
|
||||
8, TRUE)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
@@ -0,0 +1,172 @@
|
||||
# Marktvogt Design System — Burgund
|
||||
|
||||
Established May 2026 via Claude Design handoff (bundle: `marktvogt-de.tar.gz`).
|
||||
|
||||
## Identity
|
||||
|
||||
**Burgund** is the locked visual identity for Marktvogt. The name comes from the deep sealing-wax red at its core.
|
||||
|
||||
Design principles chosen in the original session:
|
||||
|
||||
- **Editorial-classical** — Cormorant Garamond display, EB Garamond body, editorial proportions
|
||||
- **Mid-density** — enough whitespace to breathe, enough content to inform
|
||||
- **Minimal decoration** — one ornament glyph (✦), drop-caps, double rules only. No rounded corners, no gradient badges, no color-coded semantic variants.
|
||||
- **Body-lead hierarchy** — lead paragraph at 22px italic, then 16/15/14px body; display headlines at 76/56/44px
|
||||
|
||||
## Tokens
|
||||
|
||||
Source of truth: `web/src/app.css` (`@theme` block and `:root.dark` overrides).
|
||||
|
||||
| Token | Light | Dark |
|
||||
| --------------------- | -------------------------------- | ------------------------ |
|
||||
| `--color-bg` | `#f5efe4` (warm parchment) | `#0f0c0a` (deep ink) |
|
||||
| `--color-surface` | `#ffffff` | `#191411` |
|
||||
| `--color-surface-alt` | `#ece4d4` | `#241c17` |
|
||||
| `--color-ink` | `#181410` | `#f0e6d2` |
|
||||
| `--color-ink-soft` | `#3a322a` | `#c0b094` |
|
||||
| `--color-ink-muted` | `#6e6253` | `#74644f` |
|
||||
| `--color-rule` | `#181410` | `#3a2e22` |
|
||||
| `--color-rule-soft` | `#c9b58c` | `#2a221d` |
|
||||
| `--color-accent` | `#9a1e2c` (sealing-wax burgundy) | `#d86268` (halbton rose) |
|
||||
| `--color-accent-soft` | `#c84858` | `#8a2a32` |
|
||||
| `--color-on-accent` | `#f5efe4` | `#0f0c0a` |
|
||||
|
||||
The dark accent is the "Halbton" step chosen from the design session — midway between the original loud `#e84a5e` and the subdued `#c87a7a`. The dark Submit-CTA "Bordeau block" uses `surface-alt #241c17` with cream `ink` foreground.
|
||||
|
||||
## Typography
|
||||
|
||||
| Role | Font | Usage |
|
||||
| -------------- | ------------------ | ------------------------------------------------------------------- |
|
||||
| `font-display` | Cormorant Garamond | Headlines, section titles, drop-caps |
|
||||
| `font-serif` | EB Garamond | Body text, nav links, buttons, table content |
|
||||
| `font-sans` | Inter | Reserved; currently unused in UI — available for UI forms if needed |
|
||||
| `font-mono` | JetBrains Mono | CAPS labels, tags, mono counters |
|
||||
|
||||
All fonts self-hosted under `web/static/fonts/`. Variable fonts covering the declared weight ranges.
|
||||
|
||||
### Type scale (derived from design files)
|
||||
|
||||
| Use | Size | Weight | Font |
|
||||
| --------------- | ----------- | ------- | --------------------------------------- |
|
||||
| Display hero | 76–88px | 500 | display |
|
||||
| Display large | 56px | 500 | display |
|
||||
| Display medium | 44px | 500 | display |
|
||||
| Display small | 24px | 400–500 | display |
|
||||
| Lead / intro | 22px italic | 400 | serif |
|
||||
| Body large | 19px | 400 | serif |
|
||||
| Body | 16px | 400 | serif |
|
||||
| Body small | 15px | 400 | serif |
|
||||
| UI text | 14px | 400 | serif |
|
||||
| Caption | 13px | 400 | serif |
|
||||
| Mono caps large | 11px | 400 | mono + uppercase + tracking 0.18em |
|
||||
| Mono caps | 10px | 400 | mono + uppercase + tracking 0.12–0.15em |
|
||||
| Mono caps small | 9px | 400 | mono + uppercase + tracking 0.1em |
|
||||
|
||||
## Atoms
|
||||
|
||||
Source files: `web/src/lib/components/atoms/`
|
||||
|
||||
### `MarktvogtMark`
|
||||
|
||||
The shield-M logo. Props: `size` (default 32). Uses `currentColor` — inherits from parent element color.
|
||||
|
||||
```svelte
|
||||
<MarktvogtMark size={36} />
|
||||
```
|
||||
|
||||
The SVG source lives at `web/src/lib/assets/marktvogt-logo.svg`.
|
||||
|
||||
### `Caps`
|
||||
|
||||
Mono uppercase label. Props: `size` (px, default 11), `color` (CSS value, defaults to `ink-muted`).
|
||||
|
||||
```svelte
|
||||
<Caps size={10}>Hessen · Nr. 015</Caps>
|
||||
```
|
||||
|
||||
### `Tag`
|
||||
|
||||
Pill/badge. Props: `accent` (boolean, default false). Accent variant: bg + border in accent color, on-accent foreground.
|
||||
|
||||
```svelte
|
||||
<Tag accent>Empfohlen</Tag>
|
||||
<Tag>Burg</Tag>
|
||||
```
|
||||
|
||||
### `Rule`
|
||||
|
||||
Section divider. Props: `kind`: `'thin'` | `'double'` | `'ornament'` (default `'thin'`).
|
||||
|
||||
```svelte
|
||||
<Rule kind="ornament" />
|
||||
<!-- ─────── ✦ ─────── -->
|
||||
<Rule kind="double" />
|
||||
<!-- thin + thin gap -->
|
||||
<Rule />
|
||||
<!-- single rule-soft border -->
|
||||
```
|
||||
|
||||
### `Heraldry`
|
||||
|
||||
Procedural heraldic SVG by string seed. Used as market card hero fallback when no real photo is available. Props: `seed` (string), `palette` (`{ a, b, bg, fg }`).
|
||||
|
||||
```svelte
|
||||
<Heraldry seed={market.slug} />
|
||||
```
|
||||
|
||||
8 deterministic variants: Stripes, Checky, Chevron, Banner, Tower, Cross, Saltire, Fleury.
|
||||
|
||||
## Decoration vocabulary
|
||||
|
||||
**Allowed:**
|
||||
|
||||
- `✦` ornament glyph — ornament Rule, section transitions
|
||||
- Drop-cap — first character of lead paragraph, 40–56px Cormorant Garamond, accent color
|
||||
- Double rule — between major page sections
|
||||
- Section headers: mono-caps label + thin rule below
|
||||
- Breadcrumb line: mono-caps "Verzeichnis · Region · Nr. XXX"
|
||||
|
||||
**Forbidden:**
|
||||
|
||||
- Gold underline on headings (the old `h1::after` accent-400 stripe — removed)
|
||||
- Color-coded semantic Alert variants (info/success/warning/error with distinct hues) — use `surface-alt + border-rule-soft` for neutral blocks, `border-accent` for errors only
|
||||
- Rounded corners (`rounded-*`) on content elements — squares only; `rounded-sm` allowed on focus rings only
|
||||
- Badge pills with backgrounds other than accent (the full surface-alt + rule-soft pair only)
|
||||
|
||||
## Dark mode
|
||||
|
||||
Dark mode applies via `.dark` class on `<html>` (set by `lib/theme.ts`, user-controlled via ThemeToggle). Three states: light / dark / system.
|
||||
|
||||
The dark "Bordeau block" pattern (used in Submit-CTA, strong CTAs):
|
||||
|
||||
```css
|
||||
background: var(--color-surface-alt); /* #241c17 */
|
||||
color: var(--color-ink); /* #f0e6d2 cream */
|
||||
accent: var(--color-accent); /* #d86268 */
|
||||
```
|
||||
|
||||
## Voice & tone
|
||||
|
||||
Editorial-warm. "Hannes" is the editorial voice — a Kunstfigur (constructed character) representing the collective editorial team. The disclaimer appears under his signature:
|
||||
|
||||
> ✦ — Hannes, der Marktvogt · Hessen · Met-Brauer · Lagergänger seit 2003
|
||||
> _eine Kunstfigur · die Redaktion arbeitet kollektiv_
|
||||
|
||||
This is intentional and not removable. See `chat1.md` lines 860–895 for the design decision.
|
||||
|
||||
## Open items (Phase 1 does not address)
|
||||
|
||||
- Photography pipeline — Lagerleben articles, camp profiles, real market photos
|
||||
- OCR/AI program parsing — Submit-Flow optional upload step
|
||||
- Real-time messaging — dashboard inboxes use REST for Phase 4b; WebSocket later
|
||||
- Newsletter/Saisonbrief backend — Home form ships as non-functional in Phase 2
|
||||
|
||||
## Phase roadmap
|
||||
|
||||
| Phase | Branch | Status |
|
||||
| ---------------------------------------------------------------------------------- | ------------------------- | ----------- |
|
||||
| 1 · Design system | `feat/burgund-redesign` | In progress |
|
||||
| 2 · Public surfaces (Startseite, Verzeichnis, Detail, Kalender, Karte, Lagerleben) | `feat/burgund-public` | Planned |
|
||||
| 3 · Flows (Auth, Submit-Flow) | `feat/burgund-flows` | Planned |
|
||||
| 4a · Backend (roles, groups, applications, Lagerleben CMS) | `feat/burgund-backend` | Planned |
|
||||
| 4b · Dashboards (Veranstalter, Admin, Händler, Lager) | `feat/burgund-dashboards` | Planned |
|
||||
@@ -2,154 +2,211 @@
|
||||
|
||||
/* ── Fonts ───────────────────────────────────────────────── */
|
||||
|
||||
/* Cormorant Garamond — display */
|
||||
@font-face {
|
||||
font-family: 'MedievalSharp';
|
||||
src: url('/fonts/medievalsharp-400.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Crimson Pro';
|
||||
src: url('/fonts/crimsonpro-400.woff2') format('woff2');
|
||||
font-family: 'Cormorant Garamond';
|
||||
src: url('/fonts/cormorant-garamond-400-ext.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
|
||||
U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Crimson Pro';
|
||||
src: url('/fonts/crimsonpro-400i.woff2') format('woff2');
|
||||
font-weight: 400;
|
||||
font-family: 'Cormorant Garamond';
|
||||
src: url('/fonts/cormorant-garamond-400.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Cormorant Garamond';
|
||||
src: url('/fonts/cormorant-garamond-400i-ext.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
|
||||
U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Cormorant Garamond';
|
||||
src: url('/fonts/cormorant-garamond-400i.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* ── Dark mode variant: .dark class only (JS resolves system preference) ── */
|
||||
/* EB Garamond — body serif */
|
||||
@font-face {
|
||||
font-family: 'EB Garamond';
|
||||
src: url('/fonts/eb-garamond-400-ext.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
|
||||
U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'EB Garamond';
|
||||
src: url('/fonts/eb-garamond-400.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'EB Garamond';
|
||||
src: url('/fonts/eb-garamond-400i-ext.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
|
||||
U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'EB Garamond';
|
||||
src: url('/fonts/eb-garamond-400i.woff2') format('woff2');
|
||||
font-weight: 400 700;
|
||||
font-style: italic;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* Inter — sans UI */
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/inter-400-ext.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
|
||||
U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
src: url('/fonts/inter-400.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* JetBrains Mono — mono caps labels */
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/jetbrains-mono-400-ext.woff2') format('woff2');
|
||||
font-weight: 100 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0100-02AF, U+0304, U+0308, U+0329, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0,
|
||||
U+2113, U+2C60-2C7F, U+A720-A7FF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'JetBrains Mono';
|
||||
src: url('/fonts/jetbrains-mono-400.woff2') format('woff2');
|
||||
font-weight: 100 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
unicode-range:
|
||||
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||
}
|
||||
|
||||
/* ── Dark mode: .dark class (JS resolves system preference) ── */
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ── Theme tokens ────────────────────────────────────────── */
|
||||
/* ── Burgund design token system ─────────────────────────── */
|
||||
|
||||
@theme {
|
||||
/* Forest green primary */
|
||||
--color-primary-50: oklch(0.97 0.02 150);
|
||||
--color-primary-100: oklch(0.93 0.04 150);
|
||||
--color-primary-200: oklch(0.86 0.08 150);
|
||||
--color-primary-300: oklch(0.77 0.13 150);
|
||||
--color-primary-400: oklch(0.67 0.16 150);
|
||||
--color-primary-500: oklch(0.55 0.16 150);
|
||||
--color-primary-600: oklch(0.47 0.14 150);
|
||||
--color-primary-700: oklch(0.4 0.12 150);
|
||||
--color-primary-800: oklch(0.33 0.09 150);
|
||||
--color-primary-900: oklch(0.27 0.07 150);
|
||||
--color-primary-950: oklch(0.18 0.05 150);
|
||||
|
||||
/* Gold / amber accent */
|
||||
--color-accent-50: oklch(0.98 0.02 75);
|
||||
--color-accent-100: oklch(0.94 0.06 70);
|
||||
--color-accent-200: oklch(0.88 0.11 65);
|
||||
--color-accent-300: oklch(0.82 0.15 60);
|
||||
--color-accent-400: oklch(0.75 0.16 58);
|
||||
--color-accent-500: oklch(0.68 0.16 55);
|
||||
--color-accent-600: oklch(0.58 0.14 55);
|
||||
--color-accent-700: oklch(0.48 0.11 55);
|
||||
--color-accent-800: oklch(0.4 0.08 55);
|
||||
--color-accent-900: oklch(0.32 0.06 55);
|
||||
--color-accent-950: oklch(0.22 0.04 55);
|
||||
|
||||
/* Warm stone neutrals (constant scale — dark mode uses dark: utilities) */
|
||||
--color-stone-50: oklch(0.98 0.005 70);
|
||||
--color-stone-100: oklch(0.96 0.008 60);
|
||||
--color-stone-200: oklch(0.92 0.01 55);
|
||||
--color-stone-300: oklch(0.87 0.012 50);
|
||||
--color-stone-400: oklch(0.71 0.013 55);
|
||||
--color-stone-500: oklch(0.56 0.013 58);
|
||||
--color-stone-600: oklch(0.45 0.012 58);
|
||||
--color-stone-700: oklch(0.38 0.011 55);
|
||||
--color-stone-800: oklch(0.32 0.01 50);
|
||||
--color-stone-900: oklch(0.25 0.008 45);
|
||||
--color-stone-950: oklch(0.17 0.006 40);
|
||||
|
||||
/* Semantic surface colors (overridden in .dark via CSS vars) */
|
||||
--color-parchment: oklch(0.96 0.012 70);
|
||||
--color-vellum: oklch(0.99 0.006 70);
|
||||
|
||||
/* Danger / brick red */
|
||||
--color-danger-50: oklch(0.97 0.015 25);
|
||||
--color-danger-100: oklch(0.93 0.04 25);
|
||||
--color-danger-200: oklch(0.87 0.08 25);
|
||||
--color-danger-300: oklch(0.78 0.12 25);
|
||||
--color-danger-400: oklch(0.68 0.15 25);
|
||||
--color-danger-500: oklch(0.58 0.16 25);
|
||||
--color-danger-600: oklch(0.5 0.15 25);
|
||||
--color-danger-700: oklch(0.42 0.12 25);
|
||||
--color-danger-800: oklch(0.35 0.09 25);
|
||||
--color-danger-900: oklch(0.28 0.06 25);
|
||||
--color-danger-950: oklch(0.2 0.04 25);
|
||||
/* Color tokens */
|
||||
--color-bg: #f5efe4;
|
||||
--color-surface: #ffffff;
|
||||
--color-surface-alt: #ece4d4;
|
||||
--color-ink: #181410;
|
||||
--color-ink-soft: #3a322a;
|
||||
--color-ink-muted: #6e6253;
|
||||
--color-rule: #181410;
|
||||
--color-rule-soft: #c9b58c;
|
||||
--color-accent: #9a1e2c;
|
||||
--color-accent-soft: #c84858;
|
||||
--color-on-accent: #f5efe4;
|
||||
|
||||
/* Typography */
|
||||
--font-heading: 'MedievalSharp', cursive;
|
||||
--font-sans: 'Crimson Pro', 'Georgia', serif;
|
||||
--font-display: 'Cormorant Garamond', 'Garamond', serif;
|
||||
--font-serif: 'EB Garamond', 'Garamond', serif;
|
||||
--font-sans: 'Inter', system-ui, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, monospace;
|
||||
}
|
||||
|
||||
/* ── Dark surface overrides (.dark class set by JS) ──────── */
|
||||
/* ── Dark mode overrides (.dark on <html>) ───────────────── */
|
||||
|
||||
:root.dark {
|
||||
color-scheme: dark;
|
||||
--color-parchment: oklch(0.14 0.007 20);
|
||||
--color-vellum: oklch(0.19 0.009 18);
|
||||
--color-bg: #0f0c0a;
|
||||
--color-surface: #191411;
|
||||
--color-surface-alt: #241c17;
|
||||
--color-ink: #f0e6d2;
|
||||
--color-ink-soft: #c0b094;
|
||||
--color-ink-muted: #74644f;
|
||||
--color-rule: #3a2e22;
|
||||
--color-rule-soft: #2a221d;
|
||||
--color-accent: #d86268;
|
||||
--color-accent-soft: #8a2a32;
|
||||
--color-on-accent: #0f0c0a;
|
||||
}
|
||||
|
||||
/* ── Base layer ──────────────────────────────────────────── */
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-parchment text-stone-800 antialiased;
|
||||
@apply bg-bg text-ink font-serif antialiased;
|
||||
}
|
||||
|
||||
.dark body {
|
||||
@apply text-stone-200;
|
||||
}
|
||||
|
||||
/* Skip-to-content link (visible on focus only) */
|
||||
/* Skip-to-content link */
|
||||
.skip-link {
|
||||
@apply bg-primary-700 absolute -top-full left-4 z-50 rounded-b-lg px-4 py-2 text-sm font-medium text-white;
|
||||
@apply focus:ring-primary-400 focus:top-0 focus:ring-2 focus:outline-none;
|
||||
@apply bg-accent text-on-accent absolute -top-full left-4 z-50 px-4 py-2 text-sm font-medium;
|
||||
@apply focus:ring-accent focus:top-0 focus:ring-2 focus:outline-none;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-family: var(--font-heading);
|
||||
font-family: var(--font-display);
|
||||
}
|
||||
|
||||
h1 {
|
||||
@apply relative pb-3;
|
||||
}
|
||||
|
||||
h1::after {
|
||||
content: '';
|
||||
@apply bg-accent-400 absolute bottom-0 left-0 h-0.5 w-16;
|
||||
}
|
||||
|
||||
h1:is([class*='text-center'])::after {
|
||||
@apply left-1/2 -translate-x-1/2;
|
||||
}
|
||||
|
||||
/* ── Shared form control styles ───────────────────────── */
|
||||
/* ── Form controls ───────────────────────────────────── */
|
||||
|
||||
input:where(
|
||||
:not([type='hidden']):not([type='checkbox']):not([type='radio']):not([type='submit']):not(
|
||||
[type='button']
|
||||
):not([type='reset'])
|
||||
),
|
||||
textarea {
|
||||
@apply bg-vellum block w-full rounded-lg border border-stone-300 px-3 py-2 text-sm text-stone-900 shadow-sm transition-colors;
|
||||
@apply placeholder-stone-400;
|
||||
@apply focus:border-primary-500 focus:ring-primary-500 focus:ring-2 focus:outline-none;
|
||||
textarea,
|
||||
select {
|
||||
@apply border-rule-soft bg-surface text-ink block w-full border px-3 py-2 text-sm shadow-sm transition-colors;
|
||||
@apply placeholder-ink-muted;
|
||||
@apply focus:border-accent focus:ring-accent focus:ring-1 focus:outline-none;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.dark
|
||||
@@ -158,23 +215,18 @@
|
||||
[type='button']
|
||||
):not([type='reset'])
|
||||
),
|
||||
.dark textarea {
|
||||
@apply border-stone-600 bg-stone-800 text-stone-100 placeholder-stone-500;
|
||||
.dark textarea,
|
||||
.dark select {
|
||||
@apply border-rule-soft bg-surface-alt text-ink placeholder-ink-muted;
|
||||
}
|
||||
|
||||
/* Error state */
|
||||
input[aria-invalid='true'],
|
||||
textarea[aria-invalid='true'] {
|
||||
@apply border-danger-400 text-danger-900 placeholder-danger-300;
|
||||
textarea[aria-invalid='true'],
|
||||
select[aria-invalid='true'] {
|
||||
@apply border-accent-soft;
|
||||
}
|
||||
|
||||
.dark input[aria-invalid='true'],
|
||||
.dark textarea[aria-invalid='true'] {
|
||||
@apply border-danger-500 text-danger-200;
|
||||
}
|
||||
|
||||
/* Focus ring offset matches background */
|
||||
*:focus-visible {
|
||||
--tw-ring-offset-color: var(--color-parchment);
|
||||
--tw-ring-offset-color: var(--color-bg);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,18 +7,18 @@
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" sizes="32x32" />
|
||||
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png" />
|
||||
<link rel="manifest" href="%sveltekit.assets%/site.webmanifest" />
|
||||
<meta name="theme-color" content="#1a3d24" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0f2818" media="(prefers-color-scheme: dark)" />
|
||||
<meta name="theme-color" content="#f5efe4" media="(prefers-color-scheme: light)" />
|
||||
<meta name="theme-color" content="#0f0c0a" media="(prefers-color-scheme: dark)" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="%sveltekit.assets%/fonts/crimsonpro-400.woff2"
|
||||
href="%sveltekit.assets%/fonts/eb-garamond-400.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
/>
|
||||
<link
|
||||
rel="preload"
|
||||
href="%sveltekit.assets%/fonts/medievalsharp-400.woff2"
|
||||
href="%sveltekit.assets%/fonts/cormorant-garamond-400.woff2"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
crossorigin
|
||||
|
||||
@@ -352,3 +352,23 @@ export interface MarketSearchParams {
|
||||
page?: number;
|
||||
per_page?: number;
|
||||
}
|
||||
|
||||
// Lagerleben types
|
||||
export interface LagerlebenArticle {
|
||||
slug: string;
|
||||
title: string;
|
||||
subtitle: string;
|
||||
category: string;
|
||||
date: string; // YYYY-MM-DD
|
||||
excerpt: string;
|
||||
body?: string;
|
||||
}
|
||||
|
||||
export interface LagerlebenCamp {
|
||||
slug: string;
|
||||
name: string;
|
||||
region: string;
|
||||
period: string;
|
||||
excerpt: string;
|
||||
members: number;
|
||||
}
|
||||
|
||||
@@ -1 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="460" viewBox="0 0 40 46">
|
||||
<path d="M2 2 L38 2 L38 26 C38 36 30 42 20 44 C10 42 2 36 2 26 Z" fill="none" stroke="#9a1e2c" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M9 32 L9 14 L14 14 L20 24 L26 14 L31 14 L31 32 L27 32 L27 22 L22 30 L18 30 L13 22 L13 32 Z" fill="#9a1e2c"/>
|
||||
<circle cx="20" cy="9" r="1.5" fill="#9a1e2c"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 403 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="460" viewBox="0 0 40 46">
|
||||
<path d="M2 2 L38 2 L38 26 C38 36 30 42 20 44 C10 42 2 36 2 26 Z" fill="none" stroke="#1a1612" stroke-width="2" stroke-linejoin="round"></path>
|
||||
<path d="M9 32 L9 14 L14 14 L20 24 L26 14 L31 14 L31 32 L27 32 L27 22 L22 30 L18 30 L13 22 L13 32 Z" fill="#1a1612"></path>
|
||||
<circle cx="20" cy="9" r="1.5" fill="#1a1612"></circle>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 422 B |
@@ -270,18 +270,14 @@
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<div
|
||||
class="border-danger-200 bg-danger-50 text-danger-800 dark:border-danger-800 dark:bg-danger-950 dark:text-danger-200 mb-4 rounded-lg
|
||||
border p-4 text-sm"
|
||||
role="alert"
|
||||
>
|
||||
<div class="border-rule-soft bg-surface-alt text-accent mb-4 border p-4 text-sm" role="alert">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-6">
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Allgemein</legend>
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]">Allgemein</legend>
|
||||
|
||||
<Input
|
||||
label="Name {mode === 'public' ? 'des Marktes' : ''} *"
|
||||
@@ -293,16 +289,17 @@
|
||||
/>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label for="description" class="block text-sm font-medium text-stone-700 dark:text-stone-200">
|
||||
<label
|
||||
for="description"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Beschreibung
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
rows="4"
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
class=""
|
||||
placeholder={mode === 'public' ? 'Beschreibe den Markt kurz...' : ''}
|
||||
bind:value={description}
|
||||
></textarea>
|
||||
@@ -310,7 +307,7 @@
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Standort</legend>
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]">Standort</legend>
|
||||
|
||||
<Input
|
||||
label="Straße"
|
||||
@@ -347,18 +344,13 @@
|
||||
placeholder={mode === 'public' ? 'z.B. 80331' : ''}
|
||||
/>
|
||||
<div class="space-y-1">
|
||||
<label for="country" class="block text-sm font-medium text-stone-700 dark:text-stone-200">
|
||||
<label
|
||||
for="country"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Land *
|
||||
</label>
|
||||
<select
|
||||
id="country"
|
||||
name="country"
|
||||
required
|
||||
bind:value={selectedCountry}
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
>
|
||||
<select id="country" name="country" required bind:value={selectedCountry} class="">
|
||||
<option value="DE">Deutschland</option>
|
||||
<option value="AT">Österreich</option>
|
||||
<option value="CH">Schweiz</option>
|
||||
@@ -431,30 +423,30 @@
|
||||
type="button"
|
||||
onclick={geocodeAddress}
|
||||
disabled={geocoding}
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm font-medium disabled:opacity-50"
|
||||
class="text-accent font-serif text-sm font-[500] disabled:opacity-50"
|
||||
>
|
||||
{geocoding ? 'Ermittle...' : 'Koordinaten aus Adresse ermitteln'}
|
||||
</button>
|
||||
{#if geocodeError}
|
||||
<span class="text-danger-600 dark:text-danger-400 text-xs">{geocodeError}</span>
|
||||
<span class="text-accent text-xs">{geocodeError}</span>
|
||||
{/if}
|
||||
<span class="text-stone-300 dark:text-stone-600" aria-hidden="true">·</span>
|
||||
<span class="text-rule-soft" aria-hidden="true">·</span>
|
||||
<button
|
||||
type="button"
|
||||
onclick={reverseGeocode}
|
||||
disabled={reverseGeocoding}
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm font-medium disabled:opacity-50"
|
||||
class="text-accent font-serif text-sm font-[500] disabled:opacity-50"
|
||||
>
|
||||
{reverseGeocoding ? 'Ermittle...' : 'Adresse aus Koordinaten ermitteln'}
|
||||
</button>
|
||||
{#if reverseGeocodeError}
|
||||
<span class="text-danger-600 dark:text-danger-400 text-xs">{reverseGeocodeError}</span>
|
||||
<span class="text-accent text-xs">{reverseGeocodeError}</span>
|
||||
{/if}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Zeitraum</legend>
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]">Zeitraum</legend>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<Input label="Startdatum *" name="start_date" type="date" required value={startDate} />
|
||||
@@ -463,7 +455,7 @@
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Weitere Infos</legend>
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]">Weitere Infos</legend>
|
||||
|
||||
<Input
|
||||
label="Website"
|
||||
@@ -527,22 +519,17 @@
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Öffnungszeiten</legend>
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]">Öffnungszeiten</legend>
|
||||
|
||||
{#each hours as row, i}
|
||||
<div class="flex items-end gap-2">
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="hours-day-{i}"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200">Tag</label
|
||||
>
|
||||
<select
|
||||
id="hours-day-{i}"
|
||||
bind:value={row.day}
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 rounded-lg border border-stone-300 px-3 py-2
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>Tag</label
|
||||
>
|
||||
<select id="hours-day-{i}" bind:value={row.day} class="">
|
||||
{#each days as d}
|
||||
<option value={d}>{d}</option>
|
||||
{/each}
|
||||
@@ -571,18 +558,14 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => removeHoursRow(i)}
|
||||
class="text-danger-600 hover:text-danger-800 dark:text-danger-400 pb-1 text-sm"
|
||||
class="text-accent-soft pb-1 font-serif text-sm"
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onclick={addHoursRow}
|
||||
class="text-primary-600 hover:text-primary-800 dark:text-primary-400 text-sm font-medium"
|
||||
>
|
||||
<button type="button" onclick={addHoursRow} class="text-accent font-serif text-sm font-[500]">
|
||||
+ Zeile hinzufügen
|
||||
</button>
|
||||
|
||||
@@ -590,14 +573,13 @@
|
||||
</fieldset>
|
||||
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Eintrittspreise</legend
|
||||
>
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]">Eintrittspreise</legend>
|
||||
|
||||
<div class="grid grid-cols-3 gap-4">
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admission-adult"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Erwachsene ({currency})
|
||||
</label>
|
||||
@@ -610,15 +592,13 @@
|
||||
onchange={(e) => {
|
||||
admission.adult_cents = Math.round(parseFloat(e.currentTarget.value || '0') * 100);
|
||||
}}
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
class=""
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admission-child"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Kinder ({currency})
|
||||
</label>
|
||||
@@ -631,15 +611,13 @@
|
||||
onchange={(e) => {
|
||||
admission.child_cents = Math.round(parseFloat(e.currentTarget.value || '0') * 100);
|
||||
}}
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
class=""
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admission-reduced"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Ermäßigt ({currency})
|
||||
</label>
|
||||
@@ -652,9 +630,7 @@
|
||||
onchange={(e) => {
|
||||
admission.reduced_cents = Math.round(parseFloat(e.currentTarget.value || '0') * 100);
|
||||
}}
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
class=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -663,7 +639,7 @@
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admission-free-under"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Frei unter (Alter)
|
||||
</label>
|
||||
@@ -672,9 +648,7 @@
|
||||
type="number"
|
||||
min="0"
|
||||
bind:value={admission.free_under_age}
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
class=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -682,18 +656,11 @@
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="admission-notes"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Hinweise
|
||||
</label>
|
||||
<textarea
|
||||
id="admission-notes"
|
||||
rows="2"
|
||||
bind:value={admission.notes}
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
></textarea>
|
||||
<textarea id="admission-notes" rows="2" bind:value={admission.notes} class=""></textarea>
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="admission_info" value={admissionJson} />
|
||||
@@ -701,17 +668,14 @@
|
||||
|
||||
{#if mode === 'admin'}
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">Admin-Notizen</legend
|
||||
>
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]">Admin-Notizen</legend>
|
||||
|
||||
<div class="space-y-1">
|
||||
<textarea
|
||||
id="admin_notes"
|
||||
name="admin_notes"
|
||||
rows="3"
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
class=""
|
||||
placeholder="Interne Notizen...">{market?.admin_notes ?? ''}</textarea
|
||||
>
|
||||
</div>
|
||||
@@ -722,7 +686,7 @@
|
||||
{@render extraFields()}
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-3 border-t border-stone-200 pt-6 dark:border-stone-700">
|
||||
<div class="border-rule-soft flex gap-3 border-t pt-6">
|
||||
<Button type="submit" {loading}>
|
||||
{#if mode === 'public'}
|
||||
Markt einreichen
|
||||
@@ -734,7 +698,7 @@
|
||||
</Button>
|
||||
{#if mode === 'admin'}
|
||||
<a href="/admin/maerkte">
|
||||
<Button variant="secondary" type="button">Abbrechen</Button>
|
||||
<Button variant="outline" type="button">Abbrechen</Button>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import type { AdminMarketDetail, DuplicateMarket, MarketMergeProposal } from '$lib/api/types.js';
|
||||
import { fieldLabels, formatValue } from './fieldRenderers.js';
|
||||
@@ -15,7 +16,7 @@
|
||||
let { proposal, candidate, current, applying, onApply, onClose }: Props = $props();
|
||||
|
||||
// Local copy the admin can edit before applying.
|
||||
let edited = $state($state.snapshot(proposal));
|
||||
let edited = $state(untrack(() => $state.snapshot(proposal)));
|
||||
|
||||
const fieldOrder = Object.keys(edited.field_merges ?? {});
|
||||
|
||||
@@ -156,7 +157,7 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<div class="flex gap-2">
|
||||
<Button variant="danger" loading={applying} onclick={handleApply}>Merge anwenden</Button>
|
||||
<Button variant="secondary" onclick={onClose}>Abbrechen</Button>
|
||||
<Button variant="primary" loading={applying} onclick={handleApply}>Merge anwenden</Button>
|
||||
<Button variant="outline" onclick={onClose}>Abbrechen</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -429,7 +429,7 @@
|
||||
{#if totalSelectable > 0}
|
||||
<div class="mt-4 flex gap-2">
|
||||
<Button type="button" onclick={handleApply}>Übernehmen</Button>
|
||||
<Button type="button" variant="secondary" onclick={onClose}>Abbrechen</Button>
|
||||
<Button type="button" variant="outline" onclick={onClose}>Abbrechen</Button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
size?: number;
|
||||
color?: string;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { size = 11, color, class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="font-mono tracking-[0.18em] uppercase {className}"
|
||||
style="font-size:{size}px;{color ? `color:${color}` : 'color:var(--color-ink-muted)'}"
|
||||
>
|
||||
{@render children()}
|
||||
</span>
|
||||
@@ -0,0 +1,166 @@
|
||||
<script lang="ts">
|
||||
interface Palette {
|
||||
a?: string;
|
||||
b?: string;
|
||||
bg?: string;
|
||||
fg?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
seed: string;
|
||||
palette?: Palette;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { seed, palette = {}, class: className = '' }: Props = $props();
|
||||
|
||||
const VARIANTS = [
|
||||
'Stripes',
|
||||
'Checky',
|
||||
'Chevron',
|
||||
'Banner',
|
||||
'Tower',
|
||||
'Cross',
|
||||
'Saltire',
|
||||
'Fleury'
|
||||
] as const;
|
||||
type Variant = (typeof VARIANTS)[number];
|
||||
|
||||
function hashString(s: string): number {
|
||||
let h = 0;
|
||||
for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
|
||||
return Math.abs(h);
|
||||
}
|
||||
|
||||
const variant: Variant = $derived(VARIANTS[hashString(seed) % VARIANTS.length]);
|
||||
|
||||
const a = $derived(palette.a ?? 'var(--color-accent)');
|
||||
const _b = $derived(palette.b ?? 'var(--color-surface)');
|
||||
const bg = $derived(palette.bg ?? 'var(--color-surface-alt)');
|
||||
const fg = $derived(palette.fg ?? 'var(--color-accent)');
|
||||
</script>
|
||||
|
||||
<div class="h-full w-full overflow-hidden {className}">
|
||||
{#if variant === 'Stripes'}
|
||||
<svg width="100%" height="100%" preserveAspectRatio="xMidYMid slice" style="display:block">
|
||||
<defs>
|
||||
<pattern
|
||||
id="h-stripes-{seed}"
|
||||
width="28"
|
||||
height="28"
|
||||
patternUnits="userSpaceOnUse"
|
||||
patternTransform="rotate(45)"
|
||||
>
|
||||
<rect width="28" height="28" fill={bg} />
|
||||
<rect width="14" height="28" fill={a} opacity="0.35" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#h-stripes-{seed})" />
|
||||
</svg>
|
||||
{:else if variant === 'Checky'}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
viewBox="0 0 8 8"
|
||||
style="display:block"
|
||||
>
|
||||
<rect width="8" height="8" fill={bg} />
|
||||
{#each Array.from({ length: 64 }, (_, i) => i) as i}
|
||||
{@const x = i % 8}
|
||||
{@const y = Math.floor(i / 8)}
|
||||
{#if (x + y) % 2 === 0}
|
||||
<rect {x} {y} width="1" height="1" fill={a} opacity="0.5" />
|
||||
{/if}
|
||||
{/each}
|
||||
</svg>
|
||||
{:else if variant === 'Chevron'}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 100 60"
|
||||
style="display:block"
|
||||
>
|
||||
<rect width="100" height="60" fill={bg} />
|
||||
<path d="M0,60 L50,18 L100,60 Z" fill={a} opacity="0.55" />
|
||||
<path d="M0,60 L50,30 L100,60 Z" fill={bg} />
|
||||
</svg>
|
||||
{:else if variant === 'Banner'}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 100 60"
|
||||
style="display:block"
|
||||
>
|
||||
<rect width="100" height="60" fill={bg} />
|
||||
<rect x="33" width="34" height="60" fill={a} opacity="0.4" />
|
||||
<rect x="48" width="4" height="60" fill={a} opacity="0.7" />
|
||||
</svg>
|
||||
{:else if variant === 'Tower'}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
viewBox="0 0 100 60"
|
||||
style="display:block"
|
||||
>
|
||||
<rect width="100" height="60" fill={bg} />
|
||||
<g fill={fg} opacity="0.6">
|
||||
<rect x="20" y="30" width="14" height="22" />
|
||||
<rect x="43" y="20" width="14" height="32" />
|
||||
<rect x="66" y="34" width="14" height="18" />
|
||||
<rect x="20" y="26" width="3" height="4" />
|
||||
<rect x="26" y="26" width="3" height="4" />
|
||||
<rect x="32" y="26" width="2" height="4" />
|
||||
<rect x="43" y="16" width="3" height="4" />
|
||||
<rect x="49" y="16" width="3" height="4" />
|
||||
<rect x="55" y="16" width="2" height="4" />
|
||||
<rect x="66" y="30" width="3" height="4" />
|
||||
<rect x="72" y="30" width="3" height="4" />
|
||||
<rect x="78" y="30" width="2" height="4" />
|
||||
</g>
|
||||
</svg>
|
||||
{:else if variant === 'Cross'}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="xMidYMid slice"
|
||||
viewBox="0 0 100 60"
|
||||
style="display:block"
|
||||
>
|
||||
<rect width="100" height="60" fill={bg} />
|
||||
<rect x="44" y="10" width="12" height="40" fill={a} opacity="0.6" />
|
||||
<rect x="30" y="24" width="40" height="12" fill={a} opacity="0.6" />
|
||||
</svg>
|
||||
{:else if variant === 'Saltire'}
|
||||
<svg
|
||||
width="100%"
|
||||
height="100%"
|
||||
preserveAspectRatio="none"
|
||||
viewBox="0 0 100 60"
|
||||
style="display:block"
|
||||
>
|
||||
<rect width="100" height="60" fill={bg} />
|
||||
<path d="M0,0 L100,60 M100,0 L0,60" stroke={a} stroke-width="14" opacity="0.5" />
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Fleury -->
|
||||
<svg width="100%" height="100%" preserveAspectRatio="xMidYMid slice" style="display:block">
|
||||
<defs>
|
||||
<pattern id="h-fleury-{seed}" width="64" height="64" patternUnits="userSpaceOnUse">
|
||||
<rect width="64" height="64" fill={bg} />
|
||||
<g transform="translate(32,32)" fill={fg} opacity="0.55">
|
||||
<circle r="2" />
|
||||
<path d="M0,-14 C4,-10 4,-6 0,-3 C-4,-6 -4,-10 0,-14 Z" />
|
||||
<path d="M0,14 C4,10 4,6 0,3 C-4,6 -4,10 0,14 Z" />
|
||||
<path d="M-14,0 C-10,4 -6,4 -3,0 C-6,-4 -10,-4 -14,0 Z" />
|
||||
<path d="M14,0 C10,4 6,4 3,0 C6,-4 10,-4 14,0 Z" />
|
||||
</g>
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#h-fleury-{seed})" />
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
size?: number;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { size = 32, class: className = '' }: Props = $props();
|
||||
|
||||
const height = $derived(Math.round(size * 1.15));
|
||||
</script>
|
||||
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={size}
|
||||
{height}
|
||||
viewBox="0 0 40 46"
|
||||
aria-hidden="true"
|
||||
class={className}
|
||||
style="display:block;color:inherit"
|
||||
>
|
||||
<path
|
||||
d="M2 2 L38 2 L38 26 C38 36 30 42 20 44 C10 42 2 36 2 26 Z"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 32 L9 14 L14 14 L20 24 L26 14 L31 14 L31 32 L27 32 L27 22 L22 30 L18 30 L13 22 L13 32 Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<circle cx="20" cy="9" r="1.5" fill="currentColor" />
|
||||
</svg>
|
||||
@@ -0,0 +1,25 @@
|
||||
<script lang="ts">
|
||||
type Kind = 'thin' | 'double' | 'ornament';
|
||||
|
||||
interface Props {
|
||||
kind?: Kind;
|
||||
class?: string;
|
||||
}
|
||||
|
||||
let { kind = 'thin', class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
{#if kind === 'double'}
|
||||
<div class="h-1.5 {className}">
|
||||
<div class="border-rule-soft border-t"></div>
|
||||
<div class="border-rule-soft mt-0.5 border-t"></div>
|
||||
</div>
|
||||
{:else if kind === 'ornament'}
|
||||
<div class="flex items-center gap-3 {className}">
|
||||
<div class="border-rule-soft flex-1 border-t"></div>
|
||||
<span class="font-display text-accent text-lg leading-none">✦</span>
|
||||
<div class="border-rule-soft flex-1 border-t"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="border-rule-soft border-t {className}"></div>
|
||||
{/if}
|
||||
@@ -0,0 +1,19 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
interface Props {
|
||||
accent?: boolean;
|
||||
class?: string;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let { accent = false, class: className = '', children }: Props = $props();
|
||||
</script>
|
||||
|
||||
<span
|
||||
class="inline-block border px-2 py-0.5 font-mono text-[10px] tracking-[0.12em] uppercase {accent
|
||||
? 'border-accent bg-accent text-on-accent'
|
||||
: 'border-rule-soft bg-surface text-ink-soft'} {className}"
|
||||
>
|
||||
{@render children()}
|
||||
</span>
|
||||
@@ -34,5 +34,5 @@
|
||||
|
||||
<Input name="email" label="E-Mail" type="email" required autocomplete="email" />
|
||||
|
||||
<Button type="submit" variant="secondary" {loading} class="w-full">Magic Link senden</Button>
|
||||
<Button type="submit" variant="outline" {loading} class="w-full">Magic Link senden</Button>
|
||||
</form>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{#each providers as provider}
|
||||
<a
|
||||
href="/api/v1/auth/oauth/{provider.id}/start"
|
||||
class="bg-vellum flex w-full items-center justify-center gap-2 rounded-lg border border-stone-300 px-4 py-2 text-sm font-medium text-stone-700 shadow-sm hover:bg-stone-100 dark:border-stone-600 dark:text-stone-200 dark:hover:bg-stone-700"
|
||||
class="border-rule-soft bg-surface hover:bg-surface-alt text-ink flex w-full items-center justify-center gap-2 border px-4 py-2.5 font-serif text-[14px]"
|
||||
>
|
||||
Mit {provider.label} anmelden
|
||||
</a>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
interface Props {
|
||||
@@ -16,61 +17,79 @@
|
||||
</script>
|
||||
|
||||
{#if error}
|
||||
<Alert variant="error">{error}</Alert>
|
||||
<div class="mb-4"><Alert variant="error">{error}</Alert></div>
|
||||
{/if}
|
||||
{#if success}
|
||||
<Alert variant="success">{success}</Alert>
|
||||
<div class="mb-4"><Alert variant="success">{success}</Alert></div>
|
||||
{/if}
|
||||
|
||||
{#if secret && url}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-stone-600 dark:text-stone-300">
|
||||
Scanne den QR-Code mit deiner Authenticator-App oder gib den Schlüssel manuell ein.
|
||||
</p>
|
||||
<Caps class="mb-6">QR-Code scannen</Caps>
|
||||
<p class="text-ink-soft mb-6 font-serif text-[15px] leading-[1.65]">
|
||||
Scanne den QR-Code mit deiner Authenticator-App oder gib den Schlüssel manuell ein.
|
||||
</p>
|
||||
|
||||
<div class="flex justify-center">
|
||||
<img
|
||||
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encodeURIComponent(
|
||||
url
|
||||
)}"
|
||||
alt="TOTP QR-Code"
|
||||
class="rounded-lg"
|
||||
width="200"
|
||||
height="200"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-center py-4">
|
||||
<img
|
||||
src="https://api.qrserver.com/v1/create-qr-code/?size=200x200&data={encodeURIComponent(url)}"
|
||||
alt="TOTP QR-Code"
|
||||
width="200"
|
||||
height="200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="rounded-lg bg-stone-50 p-3 text-center dark:bg-stone-800">
|
||||
<p class="text-xs text-stone-500 dark:text-stone-400">Schlüssel</p>
|
||||
<p class="mt-1 font-mono text-sm font-medium text-stone-900 select-all dark:text-stone-100">
|
||||
{secret}
|
||||
</p>
|
||||
</div>
|
||||
<Rule kind="thin" class="my-6" />
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/profile/security?/verify"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
<div class="bg-surface-alt border-rule-soft mb-6 border p-4 text-center">
|
||||
<span class="text-ink-muted block font-mono text-[9px] tracking-[0.15em] uppercase"
|
||||
>Schlüssel</span
|
||||
>
|
||||
<Input
|
||||
<span class="text-ink mt-2 block font-mono text-[15px] font-[500] tracking-[0.08em] select-all">
|
||||
{secret}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="/profile/security?/verify"
|
||||
use:enhance={() => {
|
||||
loading = true;
|
||||
return async ({ update }) => {
|
||||
loading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-5"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="totp_code"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Bestätigungscode
|
||||
</label>
|
||||
<input
|
||||
id="totp_code"
|
||||
name="code"
|
||||
label="Bestätigungscode"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
maxlength={6}
|
||||
pattern={'[0-9]{6}'}
|
||||
pattern="[0-9]{6}"
|
||||
placeholder="123456"
|
||||
required
|
||||
autocomplete="one-time-code"
|
||||
/>
|
||||
<Button type="submit" {loading}>Bestätigen</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="border-rule-soft border-t pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
class="border-ink bg-ink text-bg flex items-center gap-2 px-5 py-2.5 font-serif text-[14px] font-[500] disabled:opacity-50"
|
||||
>
|
||||
{#if loading}<Spinner size={14} />{/if}
|
||||
Bestätigen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
@@ -1,20 +1,52 @@
|
||||
<script lang="ts">
|
||||
import ThemeToggle from '$lib/components/ui/ThemeToggle.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
</script>
|
||||
|
||||
<footer class="border-primary-800 bg-primary-950 border-t">
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="flex flex-col items-center justify-between gap-4 sm:flex-row">
|
||||
<p class="text-primary-400 text-sm">© {new Date().getFullYear()} Marktvogt</p>
|
||||
<div class="flex items-center gap-6">
|
||||
<nav class="flex gap-6">
|
||||
<a href="/impressum" class="text-primary-400 hover:text-primary-200 text-sm">Impressum</a>
|
||||
<a href="/datenschutz" class="text-primary-400 hover:text-primary-200 text-sm"
|
||||
<footer class="border-rule-soft bg-bg border-t">
|
||||
<div class="mx-auto max-w-7xl px-8 py-10">
|
||||
<div class="grid grid-cols-2 gap-8 md:grid-cols-4">
|
||||
<div>
|
||||
<p class="text-ink-muted font-mono text-[10px] tracking-[0.18em] uppercase">Entdecken</p>
|
||||
<nav class="mt-3 flex flex-col gap-2">
|
||||
<a href="/maerkte" class="text-ink-soft hover:text-ink font-serif text-sm">Märkte</a>
|
||||
<a href="/kalender" class="text-ink-soft hover:text-ink font-serif text-sm">Kalender</a>
|
||||
<a href="/karte" class="text-ink-soft hover:text-ink font-serif text-sm">Karte</a>
|
||||
<a href="/lagerleben" class="text-ink-soft hover:text-ink font-serif text-sm"
|
||||
>Lagerleben</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-ink-muted font-mono text-[10px] tracking-[0.18em] uppercase">Mitmachen</p>
|
||||
<nav class="mt-3 flex flex-col gap-2">
|
||||
<a href="/markt/einreichen" class="text-ink-soft hover:text-ink font-serif text-sm"
|
||||
>Markt einreichen</a
|
||||
>
|
||||
<a href="/auth/registrieren" class="text-ink-soft hover:text-ink font-serif text-sm"
|
||||
>Konto erstellen</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-ink-muted font-mono text-[10px] tracking-[0.18em] uppercase">Rechtliches</p>
|
||||
<nav class="mt-3 flex flex-col gap-2">
|
||||
<a href="/impressum" class="text-ink-soft hover:text-ink font-serif text-sm">Impressum</a>
|
||||
<a href="/datenschutz" class="text-ink-soft hover:text-ink font-serif text-sm"
|
||||
>Datenschutz</a
|
||||
>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="flex flex-col items-start gap-3">
|
||||
<p class="text-ink-muted font-mono text-[10px] tracking-[0.18em] uppercase">Darstellung</p>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Rule kind="ornament" class="my-8" />
|
||||
|
||||
<p class="text-ink-muted font-mono text-[10px] tracking-[0.14em] uppercase">
|
||||
© {new Date().getFullYear()} Marktvogt · Ein Verzeichnis historischer Märkte im DACH-Raum
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { ProfileData } from '$lib/api/types.js';
|
||||
import MarktvogtMark from '$lib/components/atoms/MarktvogtMark.svelte';
|
||||
import MobileNav from './MobileNav.svelte';
|
||||
import UserMenu from './UserMenu.svelte';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
interface Props {
|
||||
user: ProfileData | null;
|
||||
@@ -9,73 +11,91 @@
|
||||
|
||||
let { user }: Props = $props();
|
||||
let mobileOpen = $state(false);
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/maerkte', label: 'Märkte' },
|
||||
{ href: '/kalender', label: 'Kalender' },
|
||||
{ href: '/karte', label: 'Karte' },
|
||||
{ href: '/lagerleben', label: 'Lagerleben' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<header class="border-primary-800 bg-primary-900 border-b">
|
||||
<div class="mx-auto flex h-16 max-w-7xl items-center justify-between px-4 sm:px-6 lg:px-8">
|
||||
<a href="/" class="font-heading text-accent-300 flex items-center gap-2 text-xl font-bold">
|
||||
<svg class="h-8 w-7 shrink-0" viewBox="0 0 32 36" aria-hidden="true">
|
||||
<path
|
||||
d="M5,22 Q5,34 16,34 Q27,34 27,22"
|
||||
fill="none"
|
||||
stroke="#c4952e"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<circle cx="16" cy="14" r="12.5" fill="#1a3d24" stroke="#c4952e" stroke-width="1.8" />
|
||||
<g transform="translate(16,13) scale(0.18)" fill="#d4a63a">
|
||||
<ellipse cx="0" cy="-24" rx="6.5" ry="25" />
|
||||
<ellipse cx="19" cy="-13" rx="6" ry="20" transform="rotate(42, 19, -13)" />
|
||||
<ellipse cx="-19" cy="-13" rx="6" ry="20" transform="rotate(-42, -19, -13)" />
|
||||
<rect x="-24" y="-3" width="48" height="8" rx="2" />
|
||||
<path d="M-8,5 L-9,46 C-9,56 9,56 9,46 L8,5 Z" />
|
||||
</g>
|
||||
</svg>
|
||||
Marktvogt
|
||||
<header class="border-rule-soft bg-bg border-b">
|
||||
<!-- Top strip: anno + count -->
|
||||
<div class="border-rule-soft flex items-center justify-between border-b px-8 py-2.5 opacity-80">
|
||||
<span class="text-ink-muted font-mono text-[10px] tracking-[0.18em] uppercase">
|
||||
Anno MMXXVI · Verzeichnis historischer Märkte
|
||||
</span>
|
||||
<span class="text-ink-muted font-mono text-[10px] tracking-[0.18em] uppercase">
|
||||
DACH-Region
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Main row -->
|
||||
<div class="flex items-center justify-between gap-8 px-8 py-5">
|
||||
<!-- Logo -->
|
||||
<a href="/" class="text-ink flex items-center gap-2.5" aria-label="Marktvogt">
|
||||
<MarktvogtMark size={30} />
|
||||
<span class="font-display text-2xl leading-none font-semibold tracking-[0.01em]">
|
||||
Marktvogt
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<!-- Desktop nav -->
|
||||
<nav class="hidden items-center gap-6 md:flex">
|
||||
<a href="/" class="text-primary-200 text-sm font-medium hover:text-white">Suche</a>
|
||||
<a href="/markt/einreichen" class="text-primary-200 text-sm font-medium hover:text-white">
|
||||
<nav class="hidden items-center gap-8 md:flex">
|
||||
{#each navLinks as link}
|
||||
{@const active = $page.url.pathname.startsWith(link.href)}
|
||||
<a
|
||||
href={link.href}
|
||||
class="font-serif text-[15px] transition-colors {active
|
||||
? 'border-ink text-ink border-b'
|
||||
: 'text-ink-soft hover:text-ink'}"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="hidden items-center gap-2 md:flex">
|
||||
<a
|
||||
href="/markt/einreichen"
|
||||
class="border-ink text-ink hover:bg-surface-alt inline-flex items-center border px-4 py-1.5 font-serif text-sm transition-colors"
|
||||
>
|
||||
Markt einreichen
|
||||
</a>
|
||||
{#if user}
|
||||
<UserMenu {user} />
|
||||
{:else}
|
||||
<a href="/auth/anmelden" class="text-accent-300 hover:text-accent-200 text-sm font-medium">
|
||||
<a
|
||||
href="/auth/anmelden"
|
||||
class="border-accent bg-accent text-on-accent hover:bg-accent-soft inline-flex items-center border px-4 py-1.5 font-serif text-sm transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</a>
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
<!-- Mobile: menu button -->
|
||||
<div class="flex items-center gap-2 md:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-300 rounded-md p-2 hover:text-white"
|
||||
onclick={() => (mobileOpen = !mobileOpen)}
|
||||
aria-label="Menu"
|
||||
>
|
||||
<svg
|
||||
class="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
{#if mobileOpen}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: hamburger -->
|
||||
<button
|
||||
type="button"
|
||||
class="text-ink-soft hover:text-ink md:hidden"
|
||||
onclick={() => (mobileOpen = !mobileOpen)}
|
||||
aria-label="Menü"
|
||||
aria-expanded={mobileOpen}
|
||||
>
|
||||
<svg class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
{#if mobileOpen}
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
{:else}
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5"
|
||||
/>
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if mobileOpen}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { ProfileData } from '$lib/api/types.js';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
|
||||
interface Props {
|
||||
user: ProfileData | null;
|
||||
@@ -9,45 +10,66 @@
|
||||
let { user, onclose }: Props = $props();
|
||||
</script>
|
||||
|
||||
<nav class="border-primary-800 bg-primary-900 border-t px-4 py-4 md:hidden">
|
||||
<div class="flex flex-col gap-3">
|
||||
<a href="/" class="text-primary-200 text-sm font-medium hover:text-white" onclick={onclose}>
|
||||
Suche
|
||||
<nav class="border-rule-soft bg-bg border-t px-6 py-5 md:hidden">
|
||||
<div class="flex flex-col gap-4">
|
||||
<a href="/maerkte" class="text-ink hover:text-ink-soft font-serif text-base" onclick={onclose}>
|
||||
Märkte
|
||||
</a>
|
||||
<a href="/kalender" class="text-ink hover:text-ink-soft font-serif text-base" onclick={onclose}>
|
||||
Kalender
|
||||
</a>
|
||||
<a href="/karte" class="text-ink hover:text-ink-soft font-serif text-base" onclick={onclose}>
|
||||
Karte
|
||||
</a>
|
||||
<a
|
||||
href="/lagerleben"
|
||||
class="text-ink hover:text-ink-soft font-serif text-base"
|
||||
onclick={onclose}
|
||||
>
|
||||
Lagerleben
|
||||
</a>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<a
|
||||
href="/markt/einreichen"
|
||||
class="text-primary-200 text-sm font-medium hover:text-white"
|
||||
class="text-ink hover:text-ink-soft font-serif text-base"
|
||||
onclick={onclose}
|
||||
>
|
||||
Markt einreichen
|
||||
</a>
|
||||
|
||||
{#if user?.role === 'admin'}
|
||||
<a
|
||||
href="/admin/maerkte"
|
||||
class="text-accent-300 hover:text-accent-200 text-sm font-medium"
|
||||
class="text-accent font-mono text-[11px] tracking-[0.15em] uppercase"
|
||||
onclick={onclose}
|
||||
>
|
||||
Admin
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
{#if user}
|
||||
<Rule kind="thin" />
|
||||
<a
|
||||
href="/profile"
|
||||
class="text-primary-200 text-sm font-medium hover:text-white"
|
||||
class="text-ink hover:text-ink-soft font-serif text-base"
|
||||
onclick={onclose}
|
||||
>
|
||||
Profil
|
||||
</a>
|
||||
<form method="POST" action="/auth/abmelden">
|
||||
<button type="submit" class="text-primary-200 text-sm font-medium hover:text-white">
|
||||
<button type="submit" class="text-ink-soft hover:text-ink font-serif text-base">
|
||||
Abmelden
|
||||
</button>
|
||||
</form>
|
||||
<span class="text-primary-300 text-sm">{user.display_name}</span>
|
||||
<span class="text-ink-muted font-mono text-[10px] tracking-[0.1em] uppercase">
|
||||
{user.display_name}
|
||||
</span>
|
||||
{:else}
|
||||
<a
|
||||
href="/auth/anmelden"
|
||||
class="text-accent-300 hover:text-accent-200 text-sm font-medium"
|
||||
class="border-accent bg-accent text-on-accent inline-flex w-full items-center justify-center border px-4 py-2 font-serif text-sm"
|
||||
onclick={onclose}
|
||||
>
|
||||
Anmelden
|
||||
|
||||
@@ -39,14 +39,14 @@
|
||||
<div class="relative" bind:this={menuRef} onkeydown={onKeydown}>
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary-200 flex items-center gap-1 text-sm font-medium hover:text-white"
|
||||
class="text-ink-soft hover:text-ink flex items-center gap-1 font-serif text-sm transition-colors"
|
||||
onclick={toggle}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="true"
|
||||
>
|
||||
{user.display_name}
|
||||
<svg
|
||||
class="h-4 w-4 transition-transform {open ? 'rotate-180' : ''}"
|
||||
class="h-3.5 w-3.5 transition-transform {open ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
@@ -58,12 +58,12 @@
|
||||
|
||||
{#if open}
|
||||
<div
|
||||
class="bg-primary-800 ring-primary-700 absolute right-0 z-50 mt-2 w-48 rounded-md py-1 shadow-lg ring-1"
|
||||
class="border-rule-soft bg-surface absolute right-0 z-50 mt-2 w-44 border py-1 shadow-sm"
|
||||
role="menu"
|
||||
>
|
||||
<a
|
||||
href="/profile"
|
||||
class="text-primary-100 hover:bg-primary-700 block px-4 py-2 text-sm"
|
||||
class="text-ink hover:bg-surface-alt block px-4 py-2 font-serif text-sm"
|
||||
role="menuitem"
|
||||
onclick={close}
|
||||
>
|
||||
@@ -71,7 +71,7 @@
|
||||
</a>
|
||||
<a
|
||||
href="/profile/security"
|
||||
class="text-primary-100 hover:bg-primary-700 block px-4 py-2 text-sm"
|
||||
class="text-ink hover:bg-surface-alt block px-4 py-2 font-serif text-sm"
|
||||
role="menuitem"
|
||||
onclick={close}
|
||||
>
|
||||
@@ -79,10 +79,10 @@
|
||||
</a>
|
||||
|
||||
{#if user.role === 'admin'}
|
||||
<div class="border-primary-700 my-1 border-t"></div>
|
||||
<div class="border-rule-soft my-1 border-t"></div>
|
||||
<a
|
||||
href="/admin/maerkte"
|
||||
class="text-accent-300 hover:bg-primary-700 block px-4 py-2 text-sm"
|
||||
class="text-accent hover:bg-surface-alt block px-4 py-2 font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
role="menuitem"
|
||||
onclick={close}
|
||||
>
|
||||
@@ -90,11 +90,11 @@
|
||||
</a>
|
||||
{/if}
|
||||
|
||||
<div class="border-primary-700 my-1 border-t"></div>
|
||||
<div class="border-rule-soft my-1 border-t"></div>
|
||||
<form method="POST" action="/auth/abmelden">
|
||||
<button
|
||||
type="submit"
|
||||
class="text-primary-100 hover:bg-primary-700 block w-full px-4 py-2 text-left text-sm"
|
||||
class="text-ink-soft hover:bg-surface-alt hover:text-ink block w-full px-4 py-2 text-left font-serif text-sm"
|
||||
role="menuitem"
|
||||
>
|
||||
Abmelden
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
import Heraldry from '$lib/components/atoms/Heraldry.svelte';
|
||||
|
||||
interface Props {
|
||||
market: MarketSummary;
|
||||
@@ -27,10 +28,11 @@
|
||||
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="group bg-vellum block rounded-lg border border-stone-200 shadow-sm transition-shadow hover:shadow-md dark:border-stone-700"
|
||||
class="group border-rule-soft bg-surface block border transition-shadow hover:shadow-md"
|
||||
>
|
||||
<!-- Image / Logo / Heraldry hero -->
|
||||
{#if showImage}
|
||||
<div class="h-[150px] overflow-hidden rounded-t-lg">
|
||||
<div class="h-[150px] overflow-hidden">
|
||||
<img
|
||||
src={market.image_url}
|
||||
alt={market.name}
|
||||
@@ -42,82 +44,49 @@
|
||||
/>
|
||||
</div>
|
||||
{:else if showLogo}
|
||||
<img
|
||||
src={market.logo_url}
|
||||
alt={market.name}
|
||||
class="w-full rounded-t-lg"
|
||||
style="padding: 16px 16px 0; max-height: 150px; object-fit: contain;"
|
||||
loading="lazy"
|
||||
onerror={() => {
|
||||
logoFailed = true;
|
||||
}}
|
||||
/>
|
||||
<div class="bg-surface-alt flex h-[150px] items-center justify-center p-4">
|
||||
<img
|
||||
src={market.logo_url}
|
||||
alt={market.name}
|
||||
class="max-h-[120px] w-auto object-contain"
|
||||
loading="lazy"
|
||||
onerror={() => {
|
||||
logoFailed = true;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="flex h-[150px] items-center justify-center rounded-t-lg bg-gradient-to-br from-stone-800 to-stone-900 dark:from-stone-900 dark:to-stone-950"
|
||||
>
|
||||
<span class="text-5xl font-bold text-stone-600 uppercase select-none dark:text-stone-700">
|
||||
{market.city.charAt(0)}
|
||||
</span>
|
||||
<div class="bg-surface-alt h-[150px]">
|
||||
<Heraldry seed={market.slug ?? market.name} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="p-4">
|
||||
<h3
|
||||
class="group-hover:text-primary-600 dark:group-hover:text-primary-400 text-lg font-semibold text-stone-900 dark:text-stone-100"
|
||||
>
|
||||
<h3 class="font-display text-ink group-hover:text-accent text-xl leading-tight font-medium">
|
||||
{market.name}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-stone-500 dark:text-stone-400">
|
||||
<p class="text-ink-muted mt-1 font-mono text-[10px] tracking-[0.12em] uppercase">
|
||||
{market.city}{#if market.state}, {market.state}{/if}
|
||||
</p>
|
||||
<div class="mt-3 flex flex-wrap items-center gap-3 text-sm text-stone-600 dark:text-stone-300">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
<div class="text-ink-soft mt-3 flex flex-wrap items-center gap-3 font-serif text-sm">
|
||||
<span>
|
||||
{formatDate(market.start_date)} – {formatDate(market.end_date)}
|
||||
</span>
|
||||
{#if market.edition_count && market.edition_count > 1}
|
||||
<span class="text-primary-600 dark:text-primary-400 text-xs font-medium">
|
||||
<span class="text-accent font-mono text-[10px] tracking-[0.1em] uppercase">
|
||||
+{market.edition_count - 1} weitere {market.edition_count > 2 ? 'Termine' : 'Termin'}
|
||||
</span>
|
||||
{/if}
|
||||
{#if market.distance !== undefined}
|
||||
<span class="text-primary-600 dark:text-primary-400 flex items-center gap-1">
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="text-ink-muted">
|
||||
{formatDistance(market.distance)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if market.organizer_name}
|
||||
<p class="mt-2 text-xs text-stone-400 dark:text-stone-500">von {market.organizer_name}</p>
|
||||
<p class="text-ink-muted mt-2 font-mono text-[10px] tracking-[0.1em] uppercase">
|
||||
{market.organizer_name}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { untrack } from 'svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
@@ -22,7 +23,7 @@
|
||||
|
||||
let dialogEl = $state<HTMLDialogElement | null>(null);
|
||||
let loading = $state(false);
|
||||
let category = $state(form?.category ?? 'incorrect_data');
|
||||
let category = $state(untrack(() => form?.category ?? 'incorrect_data'));
|
||||
|
||||
$effect(() => {
|
||||
if (!dialogEl) return;
|
||||
@@ -71,7 +72,7 @@
|
||||
<div class="px-6 py-6">
|
||||
<Alert variant="success">Danke! Dein Feedback wurde übermittelt und wird geprüft.</Alert>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button variant="secondary" onclick={onClose}>Schließen</Button>
|
||||
<Button variant="outline" onclick={onClose}>Schließen</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -6,78 +6,126 @@
|
||||
interface Props {
|
||||
markets: MarketSummary[];
|
||||
class?: string;
|
||||
selected?: MarketSummary | null;
|
||||
onSelect?: (market: MarketSummary) => void;
|
||||
}
|
||||
|
||||
let { markets, class: className = '' }: Props = $props();
|
||||
let { markets, class: className = '', selected, onSelect }: Props = $props();
|
||||
|
||||
let mapContainer: HTMLDivElement;
|
||||
let map: LeafletMap | undefined;
|
||||
let map = $state<LeafletMap | undefined>(undefined);
|
||||
let tileLayer: import('leaflet').TileLayer | undefined;
|
||||
// slug → marker reference so sidebar clicks can fly to the right spot
|
||||
const markerRefs = new Map<string, import('leaflet').Marker>();
|
||||
|
||||
function isDark(): boolean {
|
||||
return document.documentElement.classList.contains('dark');
|
||||
}
|
||||
|
||||
function tileUrl(dark: boolean): string {
|
||||
return dark
|
||||
? 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png'
|
||||
: 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
|
||||
}
|
||||
|
||||
const cartoAttribution =
|
||||
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>';
|
||||
|
||||
function makeDot(L: typeof import('leaflet')): import('leaflet').DivIcon {
|
||||
return L.divIcon({
|
||||
className: '',
|
||||
html: '<div style="width:10px;height:10px;border-radius:50%;background:var(--color-accent);border:2px solid var(--color-bg);box-shadow:0 1px 3px rgba(0,0,0,.4)"></div>',
|
||||
iconSize: [10, 10],
|
||||
iconAnchor: [5, 5]
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
let link: HTMLLinkElement;
|
||||
let observer: MutationObserver;
|
||||
|
||||
(async () => {
|
||||
const L = await import('leaflet');
|
||||
|
||||
// Leaflet CSS
|
||||
link = document.createElement('link');
|
||||
link.rel = 'stylesheet';
|
||||
link.href = 'https://unpkg.com/leaflet@1.9.4/dist/leaflet.css';
|
||||
document.head.appendChild(link);
|
||||
|
||||
// Fix default icon paths
|
||||
// @ts-expect-error — Leaflet icon path workaround
|
||||
delete L.Icon.Default.prototype._getIconUrl;
|
||||
L.Icon.Default.mergeOptions({
|
||||
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
||||
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png'
|
||||
});
|
||||
// assign to $state variable so $effects that depend on `map` re-run
|
||||
map = L.map(mapContainer, { zoomControl: false }).setView([51.1657, 10.4515], 6);
|
||||
L.control.zoom({ position: 'bottomright' }).addTo(map);
|
||||
|
||||
map = L.map(mapContainer).setView([51.1657, 10.4515], 6); // Germany center
|
||||
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
tileLayer = L.tileLayer(tileUrl(isDark()), {
|
||||
attribution: cartoAttribution,
|
||||
subdomains: 'abcd',
|
||||
maxZoom: 19
|
||||
}).addTo(map);
|
||||
|
||||
observer = new MutationObserver(() => tileLayer?.setUrl(tileUrl(isDark())));
|
||||
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
|
||||
|
||||
updateMarkers(L, markets);
|
||||
})();
|
||||
|
||||
return () => {
|
||||
map?.remove();
|
||||
link?.remove();
|
||||
observer?.disconnect();
|
||||
};
|
||||
});
|
||||
|
||||
function updateMarkers(L: typeof import('leaflet'), items: MarketSummary[]) {
|
||||
if (!map) return;
|
||||
|
||||
// Clear existing markers
|
||||
map.eachLayer((layer) => {
|
||||
if (layer instanceof L.Marker) map!.removeLayer(layer);
|
||||
});
|
||||
markerRefs.clear();
|
||||
|
||||
if (items.length === 0) return;
|
||||
|
||||
const dot = makeDot(L);
|
||||
const bounds = L.latLngBounds([]);
|
||||
|
||||
for (const m of items) {
|
||||
const marker = L.marker([m.latitude, m.longitude]).addTo(map);
|
||||
marker.bindPopup(`<strong><a href="/markt/${m.slug}">${m.name}</a></strong><br>${m.city}`);
|
||||
if (!m.latitude || !m.longitude) continue;
|
||||
const marker = L.marker([m.latitude, m.longitude], { icon: dot }).addTo(map!);
|
||||
marker.bindPopup(
|
||||
`<div style="font-family:var(--font-serif);min-width:160px;line-height:1.4">` +
|
||||
`<a href="/markt/${m.slug}" style="font-weight:600;text-decoration:none;color:inherit">${m.name}</a>` +
|
||||
`<br><span style="font-size:12px;opacity:.7">${m.city} · ${m.state}</span>` +
|
||||
`</div>`
|
||||
);
|
||||
if (onSelect) marker.on('click', () => onSelect(m));
|
||||
markerRefs.set(m.slug, marker);
|
||||
bounds.extend([m.latitude, m.longitude]);
|
||||
}
|
||||
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [40, 40], maxZoom: 12 });
|
||||
}
|
||||
}
|
||||
|
||||
// Re-draw markers when the markets list changes
|
||||
$effect(() => {
|
||||
if (map) {
|
||||
import('leaflet').then((L) => updateMarkers(L, markets));
|
||||
}
|
||||
});
|
||||
|
||||
// Fly to selected market when sidebar selection changes
|
||||
$effect(() => {
|
||||
if (!map || !selected) return;
|
||||
const marker = markerRefs.get(selected.slug);
|
||||
if (marker) {
|
||||
map.flyTo(marker.getLatLng(), Math.max((map as LeafletMap).getZoom(), 10), { duration: 0.5 });
|
||||
// open popup after the fly animation settles
|
||||
setTimeout(() => marker.openPopup(), 550);
|
||||
} else if (selected.latitude && selected.longitude) {
|
||||
map.flyTo([selected.latitude, selected.longitude], 10, { duration: 0.5 });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div
|
||||
bind:this={mapContainer}
|
||||
class="h-[400px] w-full rounded-lg border border-stone-200 dark:border-stone-700 {className}"
|
||||
></div>
|
||||
<div bind:this={mapContainer} class="h-full w-full {className}"></div>
|
||||
|
||||
@@ -7,18 +7,13 @@
|
||||
}
|
||||
|
||||
let { variant = 'info', children }: Props = $props();
|
||||
|
||||
const styles = {
|
||||
info: 'bg-blue-50 text-blue-800 border-blue-200 dark:bg-blue-950 dark:text-blue-200 dark:border-blue-800',
|
||||
success:
|
||||
'bg-green-50 text-green-800 border-green-200 dark:bg-green-950 dark:text-green-200 dark:border-green-800',
|
||||
warning:
|
||||
'bg-amber-50 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-200 dark:border-amber-800',
|
||||
error:
|
||||
'bg-danger-50 text-danger-800 border-danger-200 dark:bg-danger-950 dark:text-danger-200 dark:border-danger-800'
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="rounded-lg border p-4 text-sm {styles[variant]}" role="alert">
|
||||
<div
|
||||
class="border-rule-soft bg-surface-alt text-ink border-l-2 p-4 text-sm {variant === 'error'
|
||||
? 'border-accent'
|
||||
: ''}"
|
||||
role="alert"
|
||||
>
|
||||
{@render children()}
|
||||
</div>
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
import type { HTMLButtonAttributes } from 'svelte/elements';
|
||||
|
||||
interface Props extends HTMLButtonAttributes {
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
variant?: 'primary' | 'outline' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
loading?: boolean;
|
||||
children: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
variant = 'primary',
|
||||
variant = 'outline',
|
||||
size = 'md',
|
||||
loading = false,
|
||||
children,
|
||||
@@ -20,23 +20,19 @@
|
||||
}: Props = $props();
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed';
|
||||
'inline-flex items-center justify-center font-serif transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-accent focus-visible:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer';
|
||||
|
||||
const variants = {
|
||||
primary:
|
||||
'bg-primary-700 text-white border border-primary-800 hover:bg-primary-800 focus-visible:ring-primary-500 dark:bg-primary-600 dark:border-primary-700 dark:hover:bg-primary-700',
|
||||
secondary:
|
||||
'bg-vellum text-stone-700 border border-stone-300 hover:bg-stone-100 focus-visible:ring-primary-500 dark:text-stone-200 dark:border-stone-600 dark:bg-stone-800 dark:hover:bg-stone-700',
|
||||
danger:
|
||||
'bg-danger-600 text-white border border-danger-700 hover:bg-danger-700 focus-visible:ring-danger-500 dark:bg-danger-500 dark:border-danger-600 dark:hover:bg-danger-600',
|
||||
primary: 'bg-accent text-on-accent border border-accent hover:bg-accent-soft',
|
||||
outline: 'bg-transparent text-ink border border-ink hover:bg-surface-alt',
|
||||
ghost:
|
||||
'text-stone-600 hover:text-stone-900 hover:bg-stone-100 focus-visible:ring-primary-500 dark:text-stone-300 dark:hover:text-stone-100 dark:hover:bg-stone-700'
|
||||
'bg-transparent text-ink-soft border border-transparent hover:text-ink hover:bg-surface-alt'
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-sm',
|
||||
lg: 'px-6 py-3 text-base'
|
||||
sm: 'px-3.5 py-1.5 text-sm',
|
||||
md: 'px-5 py-2 text-base',
|
||||
lg: 'px-7 py-2.5 text-lg'
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -14,8 +14,9 @@
|
||||
|
||||
<div class="space-y-1">
|
||||
{#if label}
|
||||
<label for={inputId} class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
>{label}</label
|
||||
<label
|
||||
for={inputId}
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase">{label}</label
|
||||
>
|
||||
{/if}
|
||||
<input
|
||||
@@ -26,6 +27,12 @@
|
||||
{...rest}
|
||||
/>
|
||||
{#if error}
|
||||
<p id={errorId} class="text-danger-600 dark:text-danger-400 text-sm" role="alert">{error}</p>
|
||||
<p
|
||||
id={errorId}
|
||||
class="text-accent font-mono text-[10px] tracking-[0.1em] uppercase"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -166,8 +166,9 @@
|
||||
|
||||
<div class="relative space-y-1">
|
||||
{#if label}
|
||||
<label for={selectId} class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
>{label}</label
|
||||
<label
|
||||
for={selectId}
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase">{label}</label
|
||||
>
|
||||
{/if}
|
||||
|
||||
@@ -184,17 +185,14 @@
|
||||
aria-describedby={errorId}
|
||||
onclick={toggle}
|
||||
onkeydown={onTriggerKeydown}
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 flex w-full items-center justify-between rounded-lg border border-stone-300 px-3 py-2 text-left text-sm
|
||||
shadow-sm transition-colors focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800
|
||||
{error ? 'border-danger-400 dark:border-danger-500' : ''}
|
||||
{selectedLabel ? 'text-stone-900 dark:text-stone-100' : 'text-stone-400 dark:text-stone-500'}"
|
||||
class="border-rule-soft bg-surface focus:border-accent focus:ring-accent flex w-full items-center justify-between border px-3 py-2
|
||||
text-left text-sm shadow-sm transition-colors focus:ring-1 focus:outline-none
|
||||
{error ? 'border-accent-soft' : ''}
|
||||
{selectedLabel ? 'text-ink' : 'text-ink-muted'}"
|
||||
>
|
||||
<span class="truncate">{selectedLabel || placeholder}</span>
|
||||
<span class="truncate font-serif">{selectedLabel || placeholder}</span>
|
||||
<svg
|
||||
class="ml-2 h-4 w-4 shrink-0 text-stone-400 transition-transform dark:text-stone-500 {open
|
||||
? 'rotate-180'
|
||||
: ''}"
|
||||
class="text-ink-muted ml-2 h-4 w-4 shrink-0 transition-transform {open ? 'rotate-180' : ''}"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
@@ -212,7 +210,7 @@
|
||||
role="listbox"
|
||||
tabindex="-1"
|
||||
onkeydown={onListboxKeydown}
|
||||
class="bg-vellum absolute z-40 mt-1 max-h-60 w-full overflow-auto rounded-lg border border-stone-200 py-1 text-sm shadow-lg focus:outline-none dark:border-stone-600 dark:bg-stone-800"
|
||||
class="border-rule-soft bg-surface absolute z-40 mt-1 max-h-60 w-full overflow-auto border py-1 text-sm shadow-md focus:outline-none"
|
||||
>
|
||||
{#each options as opt, i}
|
||||
<li
|
||||
@@ -225,22 +223,16 @@
|
||||
select(opt);
|
||||
}}
|
||||
onmouseenter={() => (activeIndex = i)}
|
||||
class="cursor-pointer px-3 py-2 transition-colors select-none
|
||||
{i === activeIndex
|
||||
? 'bg-primary-100 text-primary-900 dark:bg-primary-900 dark:text-primary-100'
|
||||
: ''}
|
||||
{opt.value === value && i !== activeIndex
|
||||
? 'text-primary-700 dark:text-primary-300 font-medium'
|
||||
: ''}
|
||||
{opt.value !== value && i !== activeIndex
|
||||
? 'text-stone-900 hover:bg-stone-100 dark:text-stone-100 dark:hover:bg-stone-700'
|
||||
: ''}"
|
||||
class="cursor-pointer px-3 py-2 font-serif transition-colors select-none
|
||||
{i === activeIndex ? 'bg-surface-alt text-ink' : ''}
|
||||
{opt.value === value && i !== activeIndex ? 'text-accent font-medium' : ''}
|
||||
{opt.value !== value && i !== activeIndex ? 'text-ink hover:bg-surface-alt' : ''}"
|
||||
>
|
||||
<span class="flex items-center justify-between">
|
||||
{opt.label}
|
||||
{#if opt.value === value}
|
||||
<svg
|
||||
class="text-primary-600 dark:text-primary-400 h-4 w-4"
|
||||
class="text-accent h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
@@ -261,6 +253,12 @@
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<p id={errorId} class="text-danger-600 dark:text-danger-400 text-sm" role="alert">{error}</p>
|
||||
<p
|
||||
id={errorId}
|
||||
class="text-accent font-mono text-[10px] tracking-[0.1em] uppercase"
|
||||
role="alert"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</script>
|
||||
|
||||
<svg
|
||||
class="text-primary-600 animate-spin {sizes[size]}"
|
||||
class="text-accent animate-spin {sizes[size]}"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
const modes: ThemeMode[] = ['system', 'light', 'dark'];
|
||||
const labels: Record<ThemeMode, string> = {
|
||||
system: 'System',
|
||||
system: 'Auto',
|
||||
light: 'Hell',
|
||||
dark: 'Dunkel'
|
||||
};
|
||||
@@ -19,57 +19,9 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={cycle}
|
||||
class="text-primary-300 hover:bg-primary-800 hover:text-primary-100 focus-visible:ring-primary-400 rounded-lg p-2 transition-colors focus-visible:ring-2 focus-visible:outline-none"
|
||||
class="text-ink-muted hover:text-ink focus-visible:ring-accent font-mono text-[10px] tracking-[0.15em] uppercase transition-colors focus-visible:ring-1 focus-visible:outline-none"
|
||||
title="Farbschema: {labels[$theme]}"
|
||||
aria-label="Farbschema wechseln, aktuell: {labels[$theme]}"
|
||||
>
|
||||
{#if $theme === 'light'}
|
||||
<!-- Sun -->
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 3v2.25m6.364.386l-1.591 1.591M21 12h-2.25m-.386 6.364l-1.591-1.591M12 18.75V21m-4.773-4.227l-1.591 1.591M5.25 12H3m4.227-4.773L5.636 5.636M15.75 12a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if $theme === 'dark'}
|
||||
<!-- Moon -->
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M21.752 15.002A9.718 9.718 0 0118 15.75c-5.385 0-9.75-4.365-9.75-9.75 0-1.33.266-2.597.748-3.752A9.753 9.753 0 003 11.25C3 16.635 7.365 21 12.75 21a9.753 9.753 0 009.002-5.998z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<!-- Monitor / system -->
|
||||
<svg
|
||||
class="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M9 17.25v1.007a3 3 0 01-.879 2.122L7.5 21h9l-.621-.621A3 3 0 0115 18.257V17.25m6-12V15a2.25 2.25 0 01-2.25 2.25H5.25A2.25 2.25 0 013 15V5.25A2.25 2.25 0 015.25 3h13.5A2.25 2.25 0 0121 5.25z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
{labels[$theme]}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"articles": [
|
||||
{
|
||||
"slug": "das-handwerk-des-schwertschmieds",
|
||||
"title": "Das Handwerk des Schwertschmieds",
|
||||
"subtitle": "Zwischen Amboss und Feuer — ein Tag in der Werkstatt von Konrad Brenner",
|
||||
"category": "Handwerk",
|
||||
"date": "2026-04-12",
|
||||
"excerpt": "Seit dreißig Jahren schmiedet Konrad Brenner Schwerter für Mittelaltermärkte in ganz Europa. Wir haben ihn in seiner Werkstatt in Dinkelsbühl besucht und zugeschaut.",
|
||||
"image_placeholder": "handwerk"
|
||||
},
|
||||
{
|
||||
"slug": "lager-aufbauen-checkliste",
|
||||
"title": "Lager aufbauen in 4 Stunden",
|
||||
"subtitle": "Die bewährte Checkliste des Compagnie du Cerf Rouge",
|
||||
"category": "Praxis",
|
||||
"date": "2026-03-28",
|
||||
"excerpt": "Wer ein Lager auf dem Markt aufbaut, kennt das Chaos der ersten Stunden. Die Compagnie du Cerf Rouge hat ihre Routine über Jahre verfeinert und teilt sie hier.",
|
||||
"image_placeholder": "praxis"
|
||||
},
|
||||
{
|
||||
"slug": "historische-stoffe-1350",
|
||||
"title": "Stoffe des 14. Jahrhunderts",
|
||||
"subtitle": "Was ist historisch korrekt — und was sieht nur so aus?",
|
||||
"category": "Recherche",
|
||||
"date": "2026-03-10",
|
||||
"excerpt": "Wollköper, Leinen, gelegentlich Seide: Die Auswahl historisch korrekter Stoffe ist kleiner als der Markt suggeriert. Ein Überblick über Quellen und Fallstricke.",
|
||||
"image_placeholder": "recherche"
|
||||
},
|
||||
{
|
||||
"slug": "kinder-im-lager",
|
||||
"title": "Kinder im Lager",
|
||||
"subtitle": "Wie Familien das Lagerleben gestalten — ohne auf Authentizität zu verzichten",
|
||||
"category": "Gemeinschaft",
|
||||
"date": "2026-02-20",
|
||||
"excerpt": "Immer mehr Familien sind Teil der Mittelalterszene. Was bedeutet das für das Lagerkonzept, den Marktablauf und die Gemeinschaft?",
|
||||
"image_placeholder": "gemeinschaft"
|
||||
}
|
||||
],
|
||||
"camps": [
|
||||
{
|
||||
"slug": "compagnie-du-cerf-rouge",
|
||||
"name": "Compagnie du Cerf Rouge",
|
||||
"region": "Bayern",
|
||||
"period": "um 1350",
|
||||
"excerpt": "Lebendige Darstellung eines fahrenden Söldnertrupps des 14. Jahrhunderts. Schwerpunkt: textile Handarbeit, Feldkochen und Waffenkunde.",
|
||||
"members": 14
|
||||
},
|
||||
{
|
||||
"slug": "lagergemeinschaft-nordmark",
|
||||
"name": "Lagergemeinschaft Nordmark",
|
||||
"region": "Schleswig-Holstein",
|
||||
"period": "Wikingerzeit",
|
||||
"excerpt": "Gemeinschaft zur Darstellung des wikingerzeitlichen Alltags auf skandinavischen und deutschen Märkten.",
|
||||
"members": 22
|
||||
},
|
||||
{
|
||||
"slug": "familia-von-hohenstein",
|
||||
"name": "Familia von Hohenstein",
|
||||
"region": "Baden-Württemberg",
|
||||
"period": "Hochmittelalter",
|
||||
"excerpt": "Adelige Haushaltung mit vollständiger Küchenausstattung, Weberei und Kinderdarstellung. Besonderen Wert legen wir auf quellenbasierte Kostümierung.",
|
||||
"members": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
|
||||
<a href="#main-content" class="skip-link">Zum Inhalt springen</a>
|
||||
|
||||
<div class="bg-parchment flex min-h-screen flex-col">
|
||||
<div class="bg-bg text-ink flex min-h-screen flex-col font-serif">
|
||||
<Header user={data.user} />
|
||||
<main id="main-content" class="flex-1" tabindex="-1">
|
||||
{@render children()}
|
||||
|
||||
@@ -1,81 +1,33 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { apiFetch, buildSearchQuery } from '$lib/api/client.js';
|
||||
import { apiFetch } from '$lib/api/client.js';
|
||||
import type { MarketSummary, PaginationMeta } from '$lib/api/types.js';
|
||||
|
||||
type Coords = { lat?: string; lon?: string };
|
||||
|
||||
async function resolveCoords(
|
||||
plz: string | null,
|
||||
urlLat: string | null,
|
||||
urlLon: string | null,
|
||||
fetch: typeof globalThis.fetch
|
||||
): Promise<Coords> {
|
||||
if (urlLat && urlLon) return { lat: urlLat, lon: urlLon };
|
||||
if (!plz) return {};
|
||||
|
||||
try {
|
||||
const res = await apiFetch<{ latitude: number | null; longitude: number | null }>('/geocode', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ city: '', zip: plz, country: 'DE' }),
|
||||
fetch
|
||||
});
|
||||
const { latitude, longitude } = res.data;
|
||||
if (latitude == null || longitude == null) return {};
|
||||
return { lat: String(latitude), lon: String(longitude) };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
function isoDate(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
const q = url.searchParams.get('q');
|
||||
const plz = url.searchParams.get('plz');
|
||||
const lat = url.searchParams.get('lat');
|
||||
const lon = url.searchParams.get('lon');
|
||||
const radius = url.searchParams.get('radius');
|
||||
const from = url.searchParams.get('from');
|
||||
const to = url.searchParams.get('to');
|
||||
const sort = url.searchParams.get('sort');
|
||||
const page = url.searchParams.get('page');
|
||||
function daysFromNow(n: number): string {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + n);
|
||||
return isoDate(d);
|
||||
}
|
||||
|
||||
const coords = await resolveCoords(plz, lat, lon, fetch);
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const fromDate = isoDate(new Date());
|
||||
const weekendTo = daysFromNow(8);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (q) params.q = q;
|
||||
if (coords.lat) params.lat = coords.lat;
|
||||
if (coords.lon) params.lon = coords.lon;
|
||||
if (radius) params.radius = radius;
|
||||
if (from) params.from = from;
|
||||
if (to) params.to = to;
|
||||
if (sort) params.sort = sort;
|
||||
if (page) params.page = page;
|
||||
const [weekendRes, seasonRes, totalRes] = await Promise.allSettled([
|
||||
apiFetch<MarketSummary[]>(`/markets?from=${fromDate}&to=${weekendTo}&per_page=4`, { fetch }),
|
||||
apiFetch<MarketSummary[]>(`/markets?from=${fromDate}&per_page=6`, { fetch }),
|
||||
apiFetch<MarketSummary[]>('/markets?per_page=1', { fetch })
|
||||
]);
|
||||
|
||||
const query = buildSearchQuery(params);
|
||||
const path = `/markets${query ? `?${query}` : ''}`;
|
||||
const weekendMarkets = weekendRes.status === 'fulfilled' ? weekendRes.value.data : [];
|
||||
const seasonMarkets = seasonRes.status === 'fulfilled' ? seasonRes.value.data : [];
|
||||
const total =
|
||||
totalRes.status === 'fulfilled'
|
||||
? ((totalRes.value.meta as PaginationMeta | undefined)?.total ?? 0)
|
||||
: 0;
|
||||
|
||||
const searchParams = {
|
||||
q: q ?? '',
|
||||
plz: plz ?? '',
|
||||
lat: lat ? Number(lat) : undefined,
|
||||
lon: lon ? Number(lon) : undefined,
|
||||
radius: radius ? Number(radius) : 25,
|
||||
from: from ?? '',
|
||||
to: to ?? '',
|
||||
sort: sort ?? ''
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiFetch<MarketSummary[]>(path, { fetch });
|
||||
return {
|
||||
markets: res.data,
|
||||
meta: res.meta as PaginationMeta,
|
||||
searchParams
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
markets: [] as MarketSummary[],
|
||||
meta: { page: 1, per_page: 20, total: 0, total_pages: 0 } as PaginationMeta,
|
||||
searchParams
|
||||
};
|
||||
}
|
||||
return { weekendMarkets, seasonMarkets, total };
|
||||
};
|
||||
|
||||
@@ -1,125 +1,392 @@
|
||||
<script lang="ts">
|
||||
import SearchForm from '$lib/components/market/SearchForm.svelte';
|
||||
import MarketCard from '$lib/components/market/MarketCard.svelte';
|
||||
import MarketMap from '$lib/components/market/MarketMap.svelte';
|
||||
import Pagination from '$lib/components/market/Pagination.svelte';
|
||||
import Heraldry from '$lib/components/atoms/Heraldry.svelte';
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let view = $state<'list' | 'map'>('list');
|
||||
function fmtDay(iso: string): string {
|
||||
return String(new Date(iso).getUTCDate());
|
||||
}
|
||||
function fmtMonthShort(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-DE', { month: 'short', timeZone: 'UTC' });
|
||||
}
|
||||
function fmtDateRange(from: string, to: string): string {
|
||||
const fDay = new Date(from).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
const tDay = new Date(to).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
return `${fDay} – ${tDay}`;
|
||||
}
|
||||
function padNum(n: number): string {
|
||||
return String(n).padStart(3, '0');
|
||||
}
|
||||
|
||||
const jsonLdHtml =
|
||||
'<script type="application/ld+json">' +
|
||||
JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'WebSite',
|
||||
name: 'Marktvogt',
|
||||
url: 'https://marktvogt.de',
|
||||
description:
|
||||
'Verzeichnis für Mittelaltermärkte, Ritterturniere und historische Feste in Deutschland',
|
||||
potentialAction: {
|
||||
'@type': 'SearchAction',
|
||||
target: 'https://marktvogt.de/?q={search_term_string}',
|
||||
'query-input': 'required name=search_term_string'
|
||||
}
|
||||
}) +
|
||||
'</' +
|
||||
'script>';
|
||||
// Compute next weekend label
|
||||
const now = new Date();
|
||||
const daysToSat = (6 - now.getDay() + 7) % 7 || 7;
|
||||
const sat = new Date(now);
|
||||
sat.setDate(now.getDate() + daysToSat);
|
||||
const sun = new Date(sat);
|
||||
sun.setDate(sat.getDate() + 1);
|
||||
const weekendLabel = `${sat.toLocaleDateString('de-DE', { day: '2-digit', month: 'short' })} – ${sun.toLocaleDateString('de-DE', { day: '2-digit', month: 'short', year: 'numeric' })}`;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Marktvogt - Mittelaltermärkte finden</title>
|
||||
<meta property="og:title" content="Marktvogt - Mittelaltermärkte finden" />
|
||||
<title>Marktvogt — Verzeichnis historischer Märkte</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Das deutschsprachige Verzeichnis historischer Märkte — Spektakel, Lichterfeste und Lagerleben, gepflegt von einem, der hingeht."
|
||||
/>
|
||||
<meta property="og:title" content="Marktvogt — Verzeichnis historischer Märkte" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Finde Mittelaltermärkte, Ritterturniere und historische Feste in deiner Nähe. Suche nach Ort, Datum oder Stichwort."
|
||||
content="Das deutschsprachige Verzeichnis historischer Märkte — Spektakel, Lichterfeste und Lagerleben."
|
||||
/>
|
||||
<meta property="og:type" content="website" />
|
||||
{@html jsonLdHtml}
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="mb-8 text-center">
|
||||
<h1 class="text-3xl font-bold text-stone-900 sm:text-4xl dark:text-stone-100">
|
||||
Mittelaltermärkte finden
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
HERO — editorial
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="bg-bg relative overflow-hidden px-10 pt-[100px] pb-[90px]">
|
||||
<span
|
||||
class="font-display text-accent absolute top-[60px] left-[12%] text-[32px] opacity-40"
|
||||
aria-hidden="true">✦</span
|
||||
>
|
||||
<span
|
||||
class="font-display text-accent absolute right-[14%] bottom-[80px] text-[24px] opacity-30"
|
||||
aria-hidden="true">✦</span
|
||||
>
|
||||
|
||||
<div class="relative mx-auto max-w-[1200px] text-center">
|
||||
<Caps>Saisonal kuratiert · Anno {new Date().getFullYear()} · {data.total} Einträge</Caps>
|
||||
|
||||
<h1
|
||||
class="font-display text-ink mt-[26px] mb-[22px] text-[clamp(56px,10vw,132px)] leading-[0.92] font-[500] tracking-[-0.015em]"
|
||||
>
|
||||
Wo das <em class="text-accent italic">Mittelalter</em><br />
|
||||
nach Glühmet riecht.
|
||||
</h1>
|
||||
<p class="mt-2 text-lg text-stone-600 dark:text-stone-300">
|
||||
Entdecke Mittelaltermärkte, Ritterturniere und historische Feste in deiner Nähe.
|
||||
|
||||
<p
|
||||
class="text-ink-soft mx-auto mb-9 max-w-[680px] font-serif text-[22px] leading-[1.45] italic"
|
||||
>
|
||||
Das deutschsprachige Verzeichnis historischer Märkte — Spektakel, Lichterfeste und
|
||||
Lager­leben, gepflegt von einem, der hingeht.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<SearchForm
|
||||
q={data.searchParams.q}
|
||||
plz={data.searchParams.plz}
|
||||
radius={data.searchParams.radius}
|
||||
from={data.searchParams.from}
|
||||
to={data.searchParams.to}
|
||||
sort={data.searchParams.sort}
|
||||
lat={data.searchParams.lat}
|
||||
lon={data.searchParams.lon}
|
||||
/>
|
||||
|
||||
<div class="mt-8">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<p class="text-sm text-stone-500 dark:text-stone-400">
|
||||
{data.meta.total}
|
||||
{data.meta.total === 1 ? 'Markt' : 'Märkte'} gefunden
|
||||
</p>
|
||||
<div
|
||||
class="bg-vellum flex gap-1 rounded-lg border border-stone-200 p-1 dark:border-stone-700"
|
||||
<span class="inline-flex items-center gap-[14px]">
|
||||
<a
|
||||
href="/maerkte"
|
||||
class="border-accent bg-accent text-on-accent border px-[26px] py-[13px] font-serif text-[15px] font-[500] tracking-[0.01em] no-underline"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {view === 'list'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300'
|
||||
: 'text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200'}"
|
||||
onclick={() => (view = 'list')}
|
||||
>
|
||||
Liste
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-md px-3 py-1.5 text-sm font-medium transition-colors {view === 'map'
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-300'
|
||||
: 'text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200'}"
|
||||
onclick={() => (view = 'map')}
|
||||
>
|
||||
Karte
|
||||
</button>
|
||||
</div>
|
||||
Was läuft dieses Wochenende ›
|
||||
</a>
|
||||
<a
|
||||
href="/maerkte"
|
||||
class="border-ink text-ink border bg-transparent px-[26px] py-[13px] font-serif text-[15px] font-[500] tracking-[0.01em] no-underline"
|
||||
>
|
||||
Verzeichnis durchstöbern
|
||||
</a>
|
||||
</span>
|
||||
|
||||
<div class="mt-14">
|
||||
<Rule kind="ornament" />
|
||||
</div>
|
||||
|
||||
{#if view === 'list'}
|
||||
{#if data.markets.length > 0}
|
||||
<div class="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each data.markets as market (market.id)}
|
||||
<MarketCard {market} />
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-vellum rounded-lg border border-stone-200 py-16 text-center dark:border-stone-700"
|
||||
>
|
||||
<p class="text-stone-500 dark:text-stone-400">
|
||||
Keine Märkte gefunden. Versuche andere Suchkriterien.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<MarketMap markets={data.markets} class="h-[600px]" />
|
||||
{/if}
|
||||
|
||||
{#if view === 'list' && data.meta.total_pages > 1}
|
||||
<div class="mt-8">
|
||||
<Pagination
|
||||
meta={data.meta}
|
||||
baseUrl="/?{new URLSearchParams(
|
||||
Object.entries(data.searchParams)
|
||||
.filter(([, v]) => v !== undefined && v !== '')
|
||||
.map(([k, v]) => [k, String(v)])
|
||||
).toString()}"
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="mt-9 flex justify-center gap-14">
|
||||
{#each [[String(data.total), 'Märkte'], ['16', 'Regionen'], ['52', 'Wochen'], ['seit 2019', 'kuratiert']] as [n, l]}
|
||||
<span class="text-center">
|
||||
<div class="font-display text-ink text-[36px] leading-none font-[500]">{n}</div>
|
||||
<Caps size={10} class="mt-1 block">{l}</Caps>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
WOCHENENDE
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
{#if data.weekendMarkets.length > 0}
|
||||
<section class="border-rule-soft bg-bg border-t px-10 py-[90px]">
|
||||
<div class="mx-auto max-w-[1320px]">
|
||||
<div class="mb-9 flex items-end justify-between">
|
||||
<span>
|
||||
<Caps>Frisch im Kalender · {weekendLabel}</Caps>
|
||||
<h2
|
||||
class="font-display text-ink mt-3 mb-1 text-[clamp(36px,5vw,64px)] leading-none font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Was läuft <em class="text-accent italic">dieses Wochenende</em>
|
||||
</h2>
|
||||
<div class="font-display text-ink-soft text-[18px] italic">
|
||||
{data.weekendMarkets.length}
|
||||
{data.weekendMarkets.length === 1 ? 'Markt' : 'Märkte'} in den nächsten Tagen.
|
||||
</div>
|
||||
</span>
|
||||
<a
|
||||
href="/maerkte"
|
||||
class="border-accent text-accent border-b pb-0.5 font-serif text-[15px] whitespace-nowrap no-underline"
|
||||
>Alle anzeigen ›</a
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6 lg:grid-cols-4">
|
||||
{#each data.weekendMarkets as market, i}
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="border-rule-soft bg-surface block border no-underline"
|
||||
>
|
||||
<div
|
||||
class="border-rule-soft bg-surface-alt relative border-b"
|
||||
style="aspect-ratio: 1.3 / 1"
|
||||
>
|
||||
<span class="absolute inset-0 flex items-center justify-center" aria-hidden="true">
|
||||
<span class="h-[75%] w-[55%]">
|
||||
<Heraldry seed={market.slug} class="h-full w-full" />
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="border-rule-soft bg-bg absolute top-3 left-3 border px-2.5 py-1.5 text-center"
|
||||
>
|
||||
<div class="font-display text-accent text-[24px] leading-none font-[500]">
|
||||
{fmtDay(market.start_date)}
|
||||
</div>
|
||||
<Caps size={9}>{fmtMonthShort(market.start_date)}</Caps>
|
||||
</span>
|
||||
<Caps size={9} class="absolute top-3.5 right-3">№ {padNum(i + 1)}</Caps>
|
||||
</div>
|
||||
<div class="px-5 pt-[18px] pb-[22px]">
|
||||
<div class="font-display text-ink text-[22px] leading-[1.1] font-[500]">
|
||||
{market.name}
|
||||
</div>
|
||||
<div class="font-display text-ink-soft mt-1 text-[14px] italic">
|
||||
{market.city} · {market.state}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
SAISONBLOCK
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
{#if data.seasonMarkets.length > 0}
|
||||
{@const lead = data.seasonMarkets[0]}
|
||||
{@const secondaries = data.seasonMarkets.slice(1, 5)}
|
||||
<section class="border-rule-soft bg-surface-alt border-y px-10 py-[100px]">
|
||||
<div class="mx-auto max-w-[1320px]">
|
||||
<div class="mb-14 text-center">
|
||||
<Caps>Aktuelle Saison · Demnächst</Caps>
|
||||
<h2
|
||||
class="font-display text-ink mt-[18px] mb-4 text-[clamp(44px,7vw,88px)] leading-[0.95] font-[500] tracking-[-0.01em] italic"
|
||||
>
|
||||
Der Saisonauftakt
|
||||
</h2>
|
||||
<p class="text-ink-soft mx-auto max-w-[640px] font-serif text-[19px] leading-[1.5] italic">
|
||||
Wenn die ersten Burgtore wieder öffnen — Ostermärkte, Lenzfeste und Saisonauftakte.
|
||||
</p>
|
||||
<div class="mt-7">
|
||||
<Rule kind="ornament" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 md:grid-cols-[1.4fr_1fr_1fr]">
|
||||
<!-- lead — spans 2 rows on md+ -->
|
||||
<a
|
||||
href="/markt/{lead.slug}"
|
||||
class="border-rule-soft bg-bg flex flex-col border font-serif no-underline md:row-span-2"
|
||||
>
|
||||
<div
|
||||
class="border-rule-soft bg-surface-alt relative overflow-hidden border-b"
|
||||
style="aspect-ratio: 5 / 4"
|
||||
>
|
||||
<span class="absolute inset-0 flex items-center justify-center" aria-hidden="true">
|
||||
<span class="h-[78%] w-[60%]">
|
||||
<Heraldry seed={lead.slug} class="h-full w-full" />
|
||||
</span>
|
||||
</span>
|
||||
<Caps
|
||||
size={9}
|
||||
color="var(--color-on-accent)"
|
||||
class="bg-accent absolute top-4 left-4 px-2 py-1">Empfohlen</Caps
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col px-7 pt-[26px] pb-[30px]">
|
||||
<Caps size={10} color="var(--color-accent)"
|
||||
>{fmtDateRange(lead.start_date, lead.end_date)}</Caps
|
||||
>
|
||||
<div class="font-display text-ink mt-2 text-[36px] leading-[1.05] font-[500] italic">
|
||||
{lead.name}
|
||||
</div>
|
||||
<div class="font-display text-ink-soft mt-1.5 text-[16px] italic">
|
||||
{lead.city} · {lead.state}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- secondaries -->
|
||||
{#each secondaries as market}
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="border-rule-soft bg-bg flex gap-4 border p-5 no-underline"
|
||||
>
|
||||
<span class="w-20 flex-shrink-0">
|
||||
<span class="relative block" style="aspect-ratio: 1 / 1.15">
|
||||
<Heraldry seed={market.slug} class="h-full w-full" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex-1">
|
||||
<Caps size={9} color="var(--color-accent)"
|
||||
>{new Date(market.start_date).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: 'UTC'
|
||||
})}</Caps
|
||||
>
|
||||
<div class="font-display text-ink mt-1 text-[19px] leading-[1.15] font-[500]">
|
||||
{market.name}
|
||||
</div>
|
||||
<div class="font-display text-ink-soft mt-0.5 text-[13px] italic">
|
||||
{market.city} · {market.state}
|
||||
</div>
|
||||
</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="mt-10 text-center">
|
||||
<a
|
||||
href="/maerkte"
|
||||
class="border-ink text-ink border bg-transparent px-[26px] py-[13px] font-serif text-[15px] font-[500] no-underline"
|
||||
>
|
||||
Alle Märkte im Verzeichnis ›
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
MANIFEST
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="bg-bg px-10 py-[120px]">
|
||||
<div class="mx-auto max-w-[880px] text-center">
|
||||
<Caps>Über Marktvogt · Manifest</Caps>
|
||||
<blockquote
|
||||
class="font-display text-ink my-8 text-[clamp(28px,4.5vw,52px)] leading-[1.15] font-normal tracking-[-0.005em] italic"
|
||||
style="text-wrap: balance"
|
||||
>
|
||||
„Ein Verzeichnis ist kein Algorithmus. Es ist <span class="text-accent">jemand</span>, der
|
||||
hingeht, zuhört und ehrlich aufschreibt, ob es sich gelohnt hat."
|
||||
</blockquote>
|
||||
<div class="mt-8 flex flex-col items-center gap-1.5">
|
||||
<span class="text-ink font-serif text-[14px] italic">— Hannes, der Marktvogt</span>
|
||||
<Caps size={9}>Hessen · Met-Brauer · Lager­gänger seit 2003</Caps>
|
||||
<span class="text-ink-muted mt-1 font-serif text-[12px] italic">
|
||||
✦ Hannes ist eine Kunstfigur — die Redaktion arbeitet kollektiv.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
NEWSLETTER
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="border-rule-soft bg-surface-alt border-y px-10 py-[90px]">
|
||||
<div class="mx-auto grid max-w-[980px] grid-cols-1 items-center gap-[60px] md:grid-cols-2">
|
||||
<span>
|
||||
<Caps>Saisonbrief · viermal im Jahr</Caps>
|
||||
<h2
|
||||
class="font-display text-ink mt-4 mb-3.5 text-[clamp(36px,4.5vw,56px)] leading-none font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Der <em class="text-accent italic">Saisonbrief</em>.
|
||||
</h2>
|
||||
<p class="text-ink-soft font-serif text-[17px] leading-[1.55] italic">
|
||||
Vier Briefe im Jahr — zu Imbolc, vor Pfingsten, im Hochsommer, vor dem ersten Schnee. Was
|
||||
lohnt sich, was wird neu, wo war ich gerade. Kein Marketing.
|
||||
</p>
|
||||
</span>
|
||||
<span class="border-rule-soft bg-bg border p-8">
|
||||
<Caps size={9} class="mb-2.5 block">E-Mail-Adresse</Caps>
|
||||
<span class="border-ink flex border">
|
||||
<input
|
||||
type="email"
|
||||
placeholder="elsa@beispiel.de"
|
||||
class="bg-bg text-ink-muted flex-1 px-4 py-3.5 font-serif text-[15px] italic outline-none"
|
||||
disabled
|
||||
aria-label="E-Mail für Saisonbrief"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="border-ink bg-ink text-bg cursor-not-allowed border-l px-6 font-mono text-[11px] font-[500] tracking-[0.18em] uppercase"
|
||||
disabled
|
||||
>
|
||||
Abonnieren
|
||||
</button>
|
||||
</span>
|
||||
<div class="text-ink-muted mt-4 font-serif text-[12px] leading-[1.5] italic">
|
||||
Keine Werbung, kein Tracking. Abmelden mit einem Klick. Demnächst verfügbar.
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
SUBMIT CTA
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="bg-ink text-bg relative overflow-hidden px-10 py-[100px]">
|
||||
<span
|
||||
class="absolute top-[-20px] right-[-40px] h-[380px] w-[320px] opacity-[0.06]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Heraldry seed="submit-watermark" class="h-full w-full" />
|
||||
</span>
|
||||
<div
|
||||
class="relative mx-auto grid max-w-[1100px] grid-cols-1 items-center gap-[60px] md:grid-cols-[1.4fr_1fr]"
|
||||
>
|
||||
<span>
|
||||
<Caps color="var(--color-accent)">Für Veranstalter</Caps>
|
||||
<h2
|
||||
class="font-display text-bg mt-4 mb-[18px] text-[clamp(36px,5.5vw,68px)] leading-none font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Veranstalten Sie einen Markt?
|
||||
</h2>
|
||||
<p class="text-bg/70 mb-7 max-w-[560px] font-serif text-[18px] leading-[1.55] italic">
|
||||
Eintrag im Verzeichnis ist kostenlos und bleibt es. Wir prüfen jeden Markt redaktionell —
|
||||
kein Vermittlungsgeschäft, keine Provision.
|
||||
</p>
|
||||
<a
|
||||
href="/markt/einreichen"
|
||||
class="border-accent bg-accent text-on-accent border px-7 py-3.5 font-serif text-[16px] font-[500] tracking-[0.01em] no-underline"
|
||||
>
|
||||
Markt einreichen ›
|
||||
</a>
|
||||
</span>
|
||||
<span class="border-bg/15 border-l pl-10">
|
||||
<Caps size={10} color="rgba(245,239,228,0.65)" class="mb-[18px] block">So läuft's</Caps>
|
||||
{#each [['I.', 'Formular ausfüllen', 'Eckdaten, Veranstalter, Stilrichtung.'], ['II.', 'Redaktionelle Prüfung', 'Wir lesen, manchmal kommen wir vorbei.'], ['III.', 'Eintrag geht live', 'In der Regel binnen 5 Werktagen.']] as [n, h, b]}
|
||||
<span class="border-bg/15 flex gap-4 border-t py-3.5">
|
||||
<span class="font-display text-accent min-w-[32px] text-[22px] leading-none italic"
|
||||
>{n}</span
|
||||
>
|
||||
<span>
|
||||
<div class="text-bg mb-0.5 font-serif text-[16px] font-[500]">{h}</div>
|
||||
<div class="text-bg/70 font-serif text-[13px] leading-[1.5]">{b}</div>
|
||||
</span>
|
||||
</span>
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -133,10 +133,10 @@
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
/>
|
||||
<Button type="submit" variant="secondary" size="sm">Suchen</Button>
|
||||
<Button type="submit" variant="outline" size="sm">Suchen</Button>
|
||||
{#if currentQ}
|
||||
<a href={buildUrl({ q: '' })}>
|
||||
<Button type="button" variant="secondary" size="sm">Zurücksetzen</Button>
|
||||
<Button type="button" variant="outline" size="sm">Zurücksetzen</Button>
|
||||
</a>
|
||||
{/if}
|
||||
</form>
|
||||
@@ -365,12 +365,12 @@
|
||||
<div class="flex gap-2">
|
||||
{#if data.meta.page > 1}
|
||||
<a href={buildUrl({ page: String(data.meta.page - 1) })}>
|
||||
<Button variant="secondary" size="sm">Zurück</Button>
|
||||
<Button variant="outline" size="sm">Zurück</Button>
|
||||
</a>
|
||||
{/if}
|
||||
{#if data.meta.page < data.meta.total_pages}
|
||||
<a href={buildUrl({ page: String(data.meta.page + 1) })}>
|
||||
<Button variant="secondary" size="sm">Weiter</Button>
|
||||
<Button variant="outline" size="sm">Weiter</Button>
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -215,7 +215,7 @@
|
||||
+ Neue Edition
|
||||
</button>
|
||||
<a href="/admin/maerkte/{data.market.id}/bearbeiten">
|
||||
<Button variant="secondary" size="sm">Bearbeiten</Button>
|
||||
<Button variant="outline" size="sm">Bearbeiten</Button>
|
||||
</a>
|
||||
<form
|
||||
method="POST"
|
||||
@@ -232,7 +232,7 @@
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button variant="danger" size="sm" type="submit" {loading}>Löschen</Button>
|
||||
<Button variant="primary" size="sm" type="submit" {loading}>Löschen</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -385,7 +385,7 @@
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Button type="submit" name="status" value="approved" {loading}>Genehmigen</Button>
|
||||
<Button type="submit" name="status" value="rejected" variant="danger" {loading}>
|
||||
<Button type="submit" name="status" value="rejected" variant="primary" {loading}>
|
||||
Ablehnen
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
<h1 class="mt-1 text-2xl font-bold">Markt bearbeiten</h1>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-1">
|
||||
<Button type="button" variant="secondary" loading={researching} onclick={runResearchPlan}>
|
||||
<Button type="button" variant="outline" loading={researching} onclick={runResearchPlan}>
|
||||
Mit KI recherchieren
|
||||
</Button>
|
||||
{#if planError}
|
||||
|
||||
@@ -11,26 +11,26 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md px-4 py-16">
|
||||
<h1 class="mb-8 text-center text-2xl font-bold text-stone-900 dark:text-stone-100">Anmelden</h1>
|
||||
<h1 class="font-display text-ink mb-8 text-center text-[32px] font-[500]">Anmelden</h1>
|
||||
|
||||
<div class="bg-vellum rounded-lg border border-stone-200 p-6 shadow-sm dark:border-stone-700">
|
||||
<div
|
||||
class="mb-6 flex gap-1 rounded-lg border border-stone-200 bg-stone-50 p-1 dark:border-stone-700 dark:bg-stone-800"
|
||||
>
|
||||
<div class="border-rule-soft bg-surface border p-8">
|
||||
<div class="border-rule-soft mb-6 flex border">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {tab === 'password'
|
||||
? 'bg-vellum text-stone-900 shadow-sm dark:bg-stone-700 dark:text-stone-100'
|
||||
: 'text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200'}"
|
||||
class="flex-1 px-3 py-2 font-mono text-[11px] tracking-[0.12em] uppercase transition-colors {tab ===
|
||||
'password'
|
||||
? 'bg-ink text-bg'
|
||||
: 'bg-bg text-ink-muted hover:text-ink'}"
|
||||
onclick={() => (tab = 'password')}
|
||||
>
|
||||
Passwort
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 rounded-md px-3 py-2 text-sm font-medium transition-colors {tab === 'magic'
|
||||
? 'bg-vellum text-stone-900 shadow-sm dark:bg-stone-700 dark:text-stone-100'
|
||||
: 'text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200'}"
|
||||
class="flex-1 px-3 py-2 font-mono text-[11px] tracking-[0.12em] uppercase transition-colors {tab ===
|
||||
'magic'
|
||||
? 'bg-ink text-bg'
|
||||
: 'bg-bg text-ink-muted hover:text-ink'}"
|
||||
onclick={() => (tab = 'magic')}
|
||||
>
|
||||
Magic Link
|
||||
@@ -45,12 +45,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-stone-500 dark:text-stone-400">
|
||||
<p class="text-ink-muted mt-6 text-center font-serif text-sm">
|
||||
Noch kein Konto?
|
||||
<a
|
||||
href="/auth/registrieren"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium"
|
||||
>Registrieren</a
|
||||
>
|
||||
<a href="/auth/registrieren" class="text-accent font-[500]">Registrieren</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -9,20 +9,14 @@
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-md px-4 py-16">
|
||||
<h1 class="mb-8 text-center text-2xl font-bold text-stone-900 dark:text-stone-100">
|
||||
Konto erstellen
|
||||
</h1>
|
||||
<h1 class="font-display text-ink mb-8 text-center text-[32px] font-[500]">Konto erstellen</h1>
|
||||
|
||||
<div class="bg-vellum rounded-lg border border-stone-200 p-6 shadow-sm dark:border-stone-700">
|
||||
<div class="border-rule-soft bg-surface border p-8">
|
||||
<RegisterForm error={form?.error} />
|
||||
</div>
|
||||
|
||||
<p class="mt-6 text-center text-sm text-stone-500 dark:text-stone-400">
|
||||
<p class="text-ink-muted mt-6 text-center font-serif text-sm">
|
||||
Bereits ein Konto?
|
||||
<a
|
||||
href="/auth/anmelden"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 font-medium"
|
||||
>Anmelden</a
|
||||
>
|
||||
<a href="/auth/anmelden" class="text-accent font-[500]">Anmelden</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,443 +1,512 @@
|
||||
<script lang="ts">
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Datenschutzerklärung - Marktvogt</title>
|
||||
<title>Datenschutzerklärung — Marktvogt</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Datenschutzerklärung für Marktvogt – Informationen zur Verarbeitung personenbezogener Daten."
|
||||
/>
|
||||
<meta property="og:title" content="Datenschutzerklärung - Marktvogt" />
|
||||
<meta
|
||||
property="og:description"
|
||||
content="Datenschutzerklärung für Marktvogt – Informationen zur Verarbeitung personenbezogener Daten."
|
||||
/>
|
||||
<meta property="og:title" content="Datenschutzerklärung — Marktvogt" />
|
||||
<meta property="og:type" content="website" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<h1 class="text-3xl font-bold text-stone-900 dark:text-stone-100">Datenschutzerklärung</h1>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">1. Verantwortlicher</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Christian Nachtigall<br />
|
||||
Karwendelstr. 21<br />
|
||||
82061 Neuried<br />
|
||||
E-Mail:
|
||||
<a
|
||||
href="mailto:contact@marktvogt.de"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>contact@marktvogt.de</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
2. Überblick der Verarbeitungen
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Marktvogt ist ein Verzeichnis für Mittelaltermärkte und historische Feste. Wir verarbeiten
|
||||
personenbezogene Daten nur, soweit dies zur Bereitstellung der Funktionen unserer Website
|
||||
erforderlich ist. Die Verarbeitung erfolgt auf Grundlage der DSGVO
|
||||
(Datenschutz-Grundverordnung).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">3. Hosting</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Diese Website wird auf Infrastruktur von <strong>itsh.dev</strong> gehostet. Beim Aufruf unserer
|
||||
Website werden durch den Hostinganbieter automatisch Informationen in sogenannten Server-Logfiles
|
||||
erfasst. Dazu gehören:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>IP-Adresse des zugreifenden Geräts</li>
|
||||
<li>Datum und Uhrzeit der Anfrage</li>
|
||||
<li>HTTP-Methode und aufgerufene URL</li>
|
||||
<li>HTTP-Statuscode</li>
|
||||
<li>Antwortzeit des Servers</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Diese Daten werden zur Sicherstellung eines störungsfreien Betriebs erhoben und zur Erkennung
|
||||
von Missbrauch ausgewertet. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes
|
||||
Interesse).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
4. Registrierung und Benutzerkonto
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sie können auf unserer Website ein Benutzerkonto erstellen. Dabei werden folgende Daten
|
||||
verarbeitet:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li><strong>E-Mail-Adresse</strong> – zur Identifikation und Kommunikation</li>
|
||||
<li>
|
||||
<strong>Passwort</strong> – wird ausschließlich als bcrypt-Hash gespeichert; das Klartext-Passwort
|
||||
wird nicht gespeichert
|
||||
</li>
|
||||
<li><strong>Anzeigename</strong> – frei wählbarer Name zur Darstellung im Profil</li>
|
||||
<li><strong>Profilbild-URL</strong> – sofern über einen OAuth-Anbieter bereitgestellt</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) sowie
|
||||
zur Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO). Ihr Konto kann jederzeit gelöscht werden.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
5. Anmeldung über Drittanbieter (OAuth)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sie können sich mit einem bestehenden Konto bei folgenden Anbietern anmelden:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<strong>Google</strong> – Abgerufene Daten: E-Mail-Adresse, Name, Profilbild, E-Mail-Verifizierungsstatus
|
||||
</li>
|
||||
<li>
|
||||
<strong>GitHub</strong> – Abgerufene Daten: E-Mail-Adresse (primäre, verifizierte E-Mail)
|
||||
</li>
|
||||
<li><strong>Facebook</strong> – Abgerufene Daten: E-Mail-Adresse, Name, Profilbild</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Wir speichern die vom Anbieter übermittelten Daten (Anbieter-ID, Name, E-Mail) sowie ein
|
||||
Zugriffstoken zur Verifizierung der Verknüpfung. Die OAuth-Anmeldung erfolgt auf Grundlage
|
||||
Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Sie können die Verknüpfung jederzeit in Ihren
|
||||
Profileinstellungen aufheben. Bitte beachten Sie die Datenschutzerklärungen der jeweiligen
|
||||
Anbieter:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<a
|
||||
href="https://policies.google.com/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>Google Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>GitHub Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://www.facebook.com/privacy/policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>Facebook Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
6. Magic-Link-Anmeldung
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sie können sich über einen per E-Mail versendeten Einmal-Link (Magic Link) anmelden. Dabei
|
||||
wird Ihre E-Mail-Adresse verarbeitet und ein einmalig gültiger, zeitlich begrenzter Token (15
|
||||
Minuten) erzeugt. Der Token wird als SHA-256-Hash gespeichert und nach Verwendung ungültig.
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
7. Sitzungsverwaltung (Sessions)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Nach der Anmeldung wird eine Sitzung erstellt. Dabei werden folgende Daten gespeichert:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li><strong>IP-Adresse</strong> – zum Zeitpunkt der Sitzungserstellung</li>
|
||||
<li><strong>User-Agent</strong> – Browserkennung zum Zeitpunkt der Anmeldung</li>
|
||||
<li><strong>Sitzungstoken</strong> – als SHA-256-Hash gespeichert</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sitzungen laufen nach 30 Tagen automatisch ab. Die Speicherung dient der Sicherheit Ihres
|
||||
Kontos (Erkennung ungewöhnlicher Anmeldeaktivitäten). Rechtsgrundlage ist Art. 6 Abs. 1 lit. f
|
||||
DSGVO (berechtigtes Interesse an der Kontosicherheit).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
8. Zwei-Faktor-Authentifizierung (2FA)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Optional können Sie die Zwei-Faktor-Authentifizierung über ein TOTP-Verfahren (z. B.
|
||||
Google Authenticator) aktivieren. Dabei wird ein kryptografisches Geheimnis (TOTP-Secret) mit
|
||||
Ihrem Konto verknüpft gespeichert. Dieses wird bei Deaktivierung der 2FA oder Löschung des
|
||||
Kontos gelöscht.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
9. Cookies und lokale Speicherung
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Wir verwenden ausschließlich technisch notwendige Cookies:
|
||||
</p>
|
||||
<div class="mt-4 overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-stone-200 dark:border-stone-700">
|
||||
<th class="py-2 pr-4 text-left font-semibold text-stone-900 dark:text-stone-100"
|
||||
>Name</th
|
||||
>
|
||||
<th class="py-2 pr-4 text-left font-semibold text-stone-900 dark:text-stone-100"
|
||||
>Zweck</th
|
||||
>
|
||||
<th class="py-2 pr-4 text-left font-semibold text-stone-900 dark:text-stone-100"
|
||||
>Dauer</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 pr-4 font-mono text-stone-700 dark:text-stone-300">access_token</td>
|
||||
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300"
|
||||
>Zugriffstoken für authentifizierte Anfragen</td
|
||||
>
|
||||
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300">30 Minuten</td>
|
||||
</tr>
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 pr-4 font-mono text-stone-700 dark:text-stone-300">refresh_token</td>
|
||||
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300"
|
||||
>Sitzungstoken zur Erneuerung des Zugriffstokens</td
|
||||
>
|
||||
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300">30 Tage</td>
|
||||
</tr>
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 pr-4 font-mono text-stone-700 dark:text-stone-300">access_expires_at</td
|
||||
>
|
||||
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300"
|
||||
>Ablaufzeitpunkt des Zugriffstokens (kein HttpOnly)</td
|
||||
>
|
||||
<td class="py-2 pr-4 text-stone-700 dark:text-stone-300">30 Minuten</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<!-- Title block -->
|
||||
<section class="bg-bg px-10 pt-[64px] pb-[48px]">
|
||||
<div class="mx-auto max-w-[760px]">
|
||||
<Caps class="mb-3">Rechtliche Angaben</Caps>
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(36px,5vw,60px)] leading-[0.95] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Datenschutz­erklärung
|
||||
</h1>
|
||||
<div class="mt-6">
|
||||
<Rule kind="thin" />
|
||||
</div>
|
||||
<p class="mt-4 text-stone-700 dark:text-stone-300">
|
||||
Zusätzlich wird im <strong>localStorage</strong> des Browsers die Einstellung für das
|
||||
Farbschema (<code class="rounded bg-stone-100 px-1 dark:bg-stone-800">marktvogt-theme</code>)
|
||||
gespeichert. Dies enthält keine personenbezogenen Daten.
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Es werden keine Tracking-, Analyse- oder Werbe-Cookies eingesetzt.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
10. Markt einreichen (Einreichungsformular)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sie können über das Formular unter „Markt einreichen" einen Mittelaltermarkt zur Aufnahme
|
||||
vorschlagen. Dabei werden folgende Daten verarbeitet:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<strong>Marktdaten</strong> – Name, Beschreibung, Ort, Zeitraum, Website, Veranstalter, ggf. Koordinaten
|
||||
</li>
|
||||
<li>
|
||||
<strong>Kontaktdaten</strong> – Ihr Name und Ihre E-Mail-Adresse (werden nicht veröffentlicht)
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Die Kontaktdaten werden ausschließlich für Rückfragen zur Einreichung verwendet und nicht an
|
||||
Dritte weitergegeben. Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1
|
||||
lit. a DSGVO). Eingereichte Daten werden bei Ablehnung des Marktes gelöscht.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
11. Spam-Schutz (Cloudflare Turnstile)
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Zum Schutz des Einreichungsformulars vor automatisiertem Missbrauch setzen wir
|
||||
<strong>Cloudflare Turnstile</strong> ein. Dabei werden technische Daten (z. B. IP-Adresse,
|
||||
Browser-Informationen) an Cloudflare, Inc. übermittelt, um zu prüfen, ob die Eingabe von einem Menschen
|
||||
stammt. Es werden keine Cookies gesetzt und kein Nutzer-Tracking durchgeführt.
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am Schutz vor Spam).
|
||||
Weitere Informationen finden Sie in der
|
||||
<a
|
||||
href="https://www.cloudflare.com/privacypolicy/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>Datenschutzerklärung von Cloudflare</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">12. Standortdaten</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Für die Umkreissuche nach Märkten können Sie optional Ihren Standort freigeben. Dabei kommen
|
||||
zwei Verfahren zum Einsatz:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<strong>Browser-Geolokalisierung</strong> – Ihr Browser fragt Ihre Erlaubnis, bevor Standortdaten
|
||||
bereitgestellt werden. Die Koordinaten werden nicht auf unserem Server gespeichert, sondern nur
|
||||
zur einmaligen Berechnung der Entfernung zu Märkten verwendet.
|
||||
</li>
|
||||
<li>
|
||||
<strong>IP-basierte Geolokalisierung (Fallback)</strong> – Falls die
|
||||
Browser-Geolokalisierung nicht verfügbar ist, wird der Dienst
|
||||
<a
|
||||
href="https://www.geojs.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>geojs.io</a
|
||||
<!-- Content -->
|
||||
<div class="bg-bg px-10 pb-20">
|
||||
<div class="mx-auto max-w-[760px] space-y-10">
|
||||
<section>
|
||||
<Caps class="mb-3">1 · Verantwortlicher</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Christian Nachtigall<br />
|
||||
Karwendelstr. 21<br />
|
||||
82061 Neuried<br />
|
||||
E-Mail:
|
||||
<a href="mailto:contact@marktvogt.de" class="text-accent hover:underline"
|
||||
>contact@marktvogt.de</a
|
||||
>
|
||||
zur ungefähren Standortbestimmung genutzt. Dabei wird Ihre IP-Adresse an geojs.io übermittelt.
|
||||
Bitte beachten Sie die
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">2 · Überblick der Verarbeitungen</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Marktvogt ist ein Verzeichnis für Mittelaltermärkte und historische Feste. Wir verarbeiten
|
||||
personenbezogene Daten nur, soweit dies zur Bereitstellung der Funktionen unserer Website
|
||||
erforderlich ist. Die Verarbeitung erfolgt auf Grundlage der DSGVO
|
||||
(Datenschutz-Grundverordnung).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">3 · Hosting</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Diese Website wird auf Infrastruktur von <strong class="text-ink font-[600]"
|
||||
>itsh.dev</strong
|
||||
> gehostet. Beim Aufruf unserer Website werden durch den Hostinganbieter automatisch Informationen
|
||||
in sogenannten Server-Logfiles erfasst. Dazu gehören:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-1.5 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
IP-Adresse des zugreifenden Geräts
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
Datum und Uhrzeit der Anfrage
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
HTTP-Methode und aufgerufene URL
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">HTTP-Statuscode</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
Antwortzeit des Servers
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Diese Daten werden zur Sicherstellung eines störungsfreien Betriebs erhoben und zur
|
||||
Erkennung von Missbrauch ausgewertet. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO
|
||||
(berechtigtes Interesse).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">4 · Registrierung und Benutzerkonto</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Sie können auf unserer Website ein Benutzerkonto erstellen. Dabei werden folgende Daten
|
||||
verarbeitet:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-1.5 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">E-Mail-Adresse</strong> – zur Identifikation und Kommunikation
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Passwort</strong> – wird ausschließlich als bcrypt-Hash
|
||||
gespeichert; das Klartext-Passwort wird nicht gespeichert
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Anzeigename</strong> – frei wählbarer Name zur Darstellung
|
||||
im Profil
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Profilbild-URL</strong> – sofern über einen OAuth-Anbieter
|
||||
bereitgestellt
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO) sowie
|
||||
zur Vertragserfüllung (Art. 6 Abs. 1 lit. b DSGVO). Ihr Konto kann jederzeit gelöscht
|
||||
werden.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">5 · Anmeldung über Drittanbieter (OAuth)</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Sie können sich mit einem bestehenden Konto bei folgenden Anbietern anmelden:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-1.5 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Google</strong> – Abgerufene Daten: E-Mail-Adresse, Name,
|
||||
Profilbild, E-Mail-Verifizierungsstatus
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">GitHub</strong> – Abgerufene Daten: E-Mail-Adresse (primäre,
|
||||
verifizierte E-Mail)
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Facebook</strong> – Abgerufene Daten: E-Mail-Adresse, Name,
|
||||
Profilbild
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Wir speichern die vom Anbieter übermittelten Daten (Anbieter-ID, Name, E-Mail) sowie ein
|
||||
Zugriffstoken zur Verifizierung der Verknüpfung. Die OAuth-Anmeldung erfolgt auf Grundlage
|
||||
Ihrer Einwilligung (Art. 6 Abs. 1 lit. a DSGVO). Sie können die Verknüpfung jederzeit in
|
||||
Ihren Profileinstellungen aufheben. Bitte beachten Sie die Datenschutzerklärungen der
|
||||
jeweiligen Anbieter:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-1.5 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<a
|
||||
href="https://policies.google.com/privacy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">Google Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<a
|
||||
href="https://docs.github.com/en/site-policy/privacy-policies/github-general-privacy-statement"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">GitHub Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<a
|
||||
href="https://www.facebook.com/privacy/policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">Facebook Datenschutzerklärung</a
|
||||
>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">6 · Magic-Link-Anmeldung</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Sie können sich über einen per E-Mail versendeten Einmal-Link (Magic Link) anmelden. Dabei
|
||||
wird Ihre E-Mail-Adresse verarbeitet und ein einmalig gültiger, zeitlich begrenzter Token
|
||||
(15 Minuten) erzeugt. Der Token wird als SHA-256-Hash gespeichert und nach Verwendung
|
||||
ungültig. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertragserfüllung).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">7 · Sitzungsverwaltung (Sessions)</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Nach der Anmeldung wird eine Sitzung erstellt. Dabei werden folgende Daten gespeichert:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-1.5 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">IP-Adresse</strong> – zum Zeitpunkt der Sitzungserstellung
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">User-Agent</strong> – Browserkennung zum Zeitpunkt der Anmeldung
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Sitzungstoken</strong> – als SHA-256-Hash gespeichert
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Sitzungen laufen nach 30 Tagen automatisch ab. Die Speicherung dient der Sicherheit Ihres
|
||||
Kontos (Erkennung ungewöhnlicher Anmeldeaktivitäten). Rechtsgrundlage ist Art. 6 Abs. 1 lit.
|
||||
f DSGVO (berechtigtes Interesse an der Kontosicherheit).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">8 · Zwei-Faktor-Authentifizierung (2FA)</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Optional können Sie die Zwei-Faktor-Authentifizierung über ein TOTP-Verfahren (z. B.
|
||||
Google Authenticator) aktivieren. Dabei wird ein kryptografisches Geheimnis (TOTP-Secret)
|
||||
mit Ihrem Konto verknüpft gespeichert. Dieses wird bei Deaktivierung der 2FA oder Löschung
|
||||
des Kontos gelöscht.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">9 · Cookies und lokale Speicherung</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Wir verwenden ausschließlich technisch notwendige Cookies:
|
||||
</p>
|
||||
<div class="border-rule-soft mt-4 overflow-x-auto border">
|
||||
<table class="w-full">
|
||||
<thead>
|
||||
<tr class="border-rule-soft bg-surface-alt border-b">
|
||||
<th
|
||||
class="text-ink-muted px-4 py-2.5 text-left font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>Name</th
|
||||
>
|
||||
<th
|
||||
class="text-ink-muted px-4 py-2.5 text-left font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>Zweck</th
|
||||
>
|
||||
<th
|
||||
class="text-ink-muted px-4 py-2.5 text-left font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>Dauer</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink px-4 py-2.5 font-mono text-[12px]">access_token</td>
|
||||
<td class="text-ink-soft px-4 py-2.5 font-serif text-[14px]"
|
||||
>Zugriffstoken für authentifizierte Anfragen</td
|
||||
>
|
||||
<td class="text-ink-soft px-4 py-2.5 font-serif text-[14px]">30 Minuten</td>
|
||||
</tr>
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink px-4 py-2.5 font-mono text-[12px]">refresh_token</td>
|
||||
<td class="text-ink-soft px-4 py-2.5 font-serif text-[14px]"
|
||||
>Sitzungstoken zur Erneuerung des Zugriffstokens</td
|
||||
>
|
||||
<td class="text-ink-soft px-4 py-2.5 font-serif text-[14px]">30 Tage</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td class="text-ink px-4 py-2.5 font-mono text-[12px]">access_expires_at</td>
|
||||
<td class="text-ink-soft px-4 py-2.5 font-serif text-[14px]"
|
||||
>Ablaufzeitpunkt des Zugriffstokens (kein HttpOnly)</td
|
||||
>
|
||||
<td class="text-ink-soft px-4 py-2.5 font-serif text-[14px]">30 Minuten</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Zusätzlich wird im <strong class="text-ink font-[600]">localStorage</strong> des Browsers
|
||||
die Einstellung für das Farbschema (<code
|
||||
class="bg-surface-alt px-1.5 py-0.5 font-mono text-[13px]">marktvogt-theme</code
|
||||
>) gespeichert. Dies enthält keine personenbezogenen Daten.
|
||||
</p>
|
||||
<p class="text-ink-soft mt-3 font-serif text-[16px] leading-[1.7]">
|
||||
Es werden keine Tracking-, Analyse- oder Werbe-Cookies eingesetzt.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">10 · Markt einreichen (Einreichungsformular)</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Sie können über das Formular unter „Markt einreichen" einen Mittelaltermarkt zur Aufnahme
|
||||
vorschlagen. Dabei werden folgende Daten verarbeitet:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-1.5 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Marktdaten</strong> – Name, Beschreibung, Ort, Zeitraum,
|
||||
Website, Veranstalter, ggf. Koordinaten
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Kontaktdaten</strong> – Ihr Name und Ihre E-Mail-Adresse
|
||||
(werden nicht veröffentlicht)
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Die Kontaktdaten werden ausschließlich für Rückfragen zur Einreichung verwendet und nicht an
|
||||
Dritte weitergegeben. Die Verarbeitung erfolgt auf Grundlage Ihrer Einwilligung (Art. 6 Abs.
|
||||
1 lit. a DSGVO). Eingereichte Daten werden bei Ablehnung des Marktes gelöscht.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">11 · Spam-Schutz (Cloudflare Turnstile)</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Zum Schutz des Einreichungsformulars vor automatisiertem Missbrauch setzen wir
|
||||
<strong class="text-ink font-[600]">Cloudflare Turnstile</strong> ein. Dabei werden technische
|
||||
Daten (z. B. IP-Adresse, Browser-Informationen) an Cloudflare, Inc. übermittelt, um zu prüfen,
|
||||
ob die Eingabe von einem Menschen stammt. Es werden keine Cookies gesetzt und kein Nutzer-Tracking
|
||||
durchgeführt.
|
||||
</p>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse am Schutz vor Spam).
|
||||
Weitere Informationen finden Sie in der
|
||||
<a
|
||||
href="https://www.geojs.io/privacy/"
|
||||
href="https://www.cloudflare.com/privacypolicy/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>Datenschutzerklärung von geojs.io</a
|
||||
class="text-accent hover:underline">Datenschutzerklärung von Cloudflare</a
|
||||
>.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">13. Kartendarstellung</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Zur Darstellung von Karten verwenden wir <strong>Leaflet</strong> mit Kartenkacheln von
|
||||
<strong>OpenStreetMap</strong>. Beim Laden der Karte werden Kartendaten von den Servern der
|
||||
OpenStreetMap Foundation (<code class="rounded bg-stone-100 px-1 dark:bg-stone-800"
|
||||
>tile.openstreetmap.org</code
|
||||
>) abgerufen. Dabei wird Ihre IP-Adresse an die OpenStreetMap Foundation übermittelt. Weitere
|
||||
Informationen finden Sie in der
|
||||
<a
|
||||
href="https://wiki.osmfoundation.org/wiki/Privacy_Policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>Datenschutzerklärung der OpenStreetMap Foundation</a
|
||||
>.
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Die Leaflet-Bibliothek wird über <code class="rounded bg-stone-100 px-1 dark:bg-stone-800"
|
||||
>unpkg.com</code
|
||||
> (CDN) geladen. Dabei kann Ihre IP-Adresse an den CDN-Betreiber übermittelt werden.
|
||||
</p>
|
||||
</section>
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">14. Ihre Rechte</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sie haben gemäß DSGVO folgende Rechte bezüglich Ihrer personenbezogenen Daten:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<strong>Auskunft</strong> (Art. 15 DSGVO) – Sie können Auskunft über Ihre gespeicherten Daten
|
||||
verlangen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Berichtigung</strong> (Art. 16 DSGVO) – Sie können die Berichtigung unrichtiger Daten
|
||||
verlangen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Löschung</strong> (Art. 17 DSGVO) – Sie können die Löschung Ihrer Daten verlangen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Einschränkung</strong> (Art. 18 DSGVO) – Sie können die Einschränkung der Verarbeitung
|
||||
verlangen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Datenübertragbarkeit</strong> (Art. 20 DSGVO) – Sie können Ihre Daten in einem maschinenlesbaren
|
||||
Format erhalten.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Widerspruch</strong> (Art. 21 DSGVO) – Sie können der Verarbeitung auf Basis berechtigter
|
||||
Interessen widersprechen.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Widerruf der Einwilligung</strong> (Art. 7 Abs. 3 DSGVO) – Erteilte Einwilligungen können
|
||||
jederzeit widerrufen werden.
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Zur Ausübung Ihrer Rechte wenden Sie sich an: <a
|
||||
href="mailto:contact@marktvogt.de"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>contact@marktvogt.de</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<Caps class="mb-3">12 · Standortdaten</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Für die Umkreissuche nach Märkten können Sie optional Ihren Standort freigeben. Dabei kommen
|
||||
zwei Verfahren zum Einsatz:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-2 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Browser-Geolokalisierung</strong> – Ihr Browser fragt Ihre
|
||||
Erlaubnis, bevor Standortdaten bereitgestellt werden. Die Koordinaten werden nicht auf unserem
|
||||
Server gespeichert, sondern nur zur einmaligen Berechnung der Entfernung zu Märkten verwendet.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">IP-basierte Geolokalisierung (Fallback)</strong> –
|
||||
Falls die Browser-Geolokalisierung nicht verfügbar ist, wird der Dienst
|
||||
<a
|
||||
href="https://www.geojs.io/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">geojs.io</a
|
||||
>
|
||||
zur ungefähren Standortbestimmung genutzt. Dabei wird Ihre IP-Adresse an geojs.io übermittelt.
|
||||
Bitte beachten Sie die
|
||||
<a
|
||||
href="https://www.geojs.io/privacy/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">Datenschutzerklärung von geojs.io</a
|
||||
>.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">15. Beschwerderecht</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde über die Verarbeitung Ihrer
|
||||
personenbezogenen Daten zu beschweren. Die für uns zuständige Aufsichtsbehörde ist:
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)<br />
|
||||
Promenade 18<br />
|
||||
91522 Ansbach<br />
|
||||
<a
|
||||
href="https://www.lda.bayern.de"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>www.lda.bayern.de</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
16. Datenlöschung und Speicherdauer
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Personenbezogene Daten werden gelöscht, sobald der Zweck der Speicherung entfällt:
|
||||
</p>
|
||||
<ul class="mt-2 list-inside list-disc space-y-1 text-stone-700 dark:text-stone-300">
|
||||
<li>
|
||||
<strong>Benutzerkonto</strong> – Bei Löschung Ihres Kontos werden Ihre Daten zunächst für 30 Tage
|
||||
zur möglichen Wiederherstellung aufbewahrt und anschließend endgültig gelöscht.
|
||||
</li>
|
||||
<li><strong>Sitzungsdaten</strong> – Automatische Löschung nach Ablauf (30 Tage).</li>
|
||||
<li><strong>Magic-Link-Tokens</strong> – Laufen nach 15 Minuten ab.</li>
|
||||
<li>
|
||||
<strong>Server-Logfiles</strong> – Werden nach den beim Hostinganbieter üblichen Fristen gelöscht.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
<section>
|
||||
<Caps class="mb-3">13 · Kartendarstellung</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Zur Darstellung von Karten verwenden wir <strong class="text-ink font-[600]">Leaflet</strong
|
||||
>
|
||||
mit Kartenkacheln von <strong class="text-ink font-[600]">CARTO</strong> (basierend auf
|
||||
OpenStreetMap-Daten). Beim Laden der Karte werden Kartendaten von den CARTO-Servern
|
||||
abgerufen. Dabei wird Ihre IP-Adresse an CARTO übermittelt. Weitere Informationen finden Sie
|
||||
in der
|
||||
<a
|
||||
href="https://carto.com/privacy/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">Datenschutzerklärung von CARTO</a
|
||||
>
|
||||
sowie der
|
||||
<a
|
||||
href="https://wiki.osmfoundation.org/wiki/Privacy_Policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">Datenschutzerklärung der OpenStreetMap Foundation</a
|
||||
>.
|
||||
</p>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Die Leaflet-Bibliothek wird über <code
|
||||
class="bg-surface-alt px-1.5 py-0.5 font-mono text-[13px]">unpkg.com</code
|
||||
>
|
||||
(CDN) geladen. Dabei kann Ihre IP-Adresse an den CDN-Betreiber übermittelt werden.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8 mb-4">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
17. Änderungen dieser Datenschutzerklärung
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Wir behalten uns vor, diese Datenschutzerklärung anzupassen, um sie an geänderte Rechtslagen
|
||||
oder Änderungen des Dienstes anzupassen. Die aktuelle Version finden Sie stets auf dieser
|
||||
Seite.
|
||||
</p>
|
||||
<p class="mt-4 text-sm text-stone-500 dark:text-stone-400">Stand: Februar 2026</p>
|
||||
</section>
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">14 · Ihre Rechte</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Sie haben gemäß DSGVO folgende Rechte bezüglich Ihrer personenbezogenen Daten:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-2 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Auskunft</strong> (Art. 15 DSGVO) – Sie können Auskunft
|
||||
über Ihre gespeicherten Daten verlangen.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Berichtigung</strong> (Art. 16 DSGVO) – Sie können die Berichtigung
|
||||
unrichtiger Daten verlangen.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Löschung</strong> (Art. 17 DSGVO) – Sie können die Löschung
|
||||
Ihrer Daten verlangen.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Einschränkung</strong> (Art. 18 DSGVO) – Sie können die
|
||||
Einschränkung der Verarbeitung verlangen.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Datenübertragbarkeit</strong> (Art. 20 DSGVO) – Sie können
|
||||
Ihre Daten in einem maschinenlesbaren Format erhalten.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Widerspruch</strong> (Art. 21 DSGVO) – Sie können der Verarbeitung
|
||||
auf Basis berechtigter Interessen widersprechen.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Widerruf der Einwilligung</strong> (Art. 7 Abs. 3 DSGVO)
|
||||
– Erteilte Einwilligungen können jederzeit widerrufen werden.
|
||||
</li>
|
||||
</ul>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Zur Ausübung Ihrer Rechte wenden Sie sich an:
|
||||
<a href="mailto:contact@marktvogt.de" class="text-accent hover:underline"
|
||||
>contact@marktvogt.de</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">15 · Beschwerderecht</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Sie haben das Recht, sich bei einer Datenschutz-Aufsichtsbehörde über die Verarbeitung Ihrer
|
||||
personenbezogenen Daten zu beschweren. Die für uns zuständige Aufsichtsbehörde ist:
|
||||
</p>
|
||||
<p class="text-ink-soft mt-3 font-serif text-[16px] leading-[1.7]">
|
||||
Bayerisches Landesamt für Datenschutzaufsicht (BayLDA)<br />
|
||||
Promenade 18<br />
|
||||
91522 Ansbach<br />
|
||||
<a
|
||||
href="https://www.lda.bayern.de"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-accent hover:underline">www.lda.bayern.de</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">16 · Datenlöschung und Speicherdauer</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Personenbezogene Daten werden gelöscht, sobald der Zweck der Speicherung entfällt:
|
||||
</p>
|
||||
<ul class="text-ink-soft mt-3 space-y-2 pl-4 font-serif text-[15px] leading-[1.6]">
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Benutzerkonto</strong> – Bei Löschung Ihres Kontos werden
|
||||
Ihre Daten zunächst für 30 Tage zur möglichen Wiederherstellung aufbewahrt und anschließend
|
||||
endgültig gelöscht.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Sitzungsdaten</strong> – Automatische Löschung nach Ablauf
|
||||
(30 Tage).
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Magic-Link-Tokens</strong> – Laufen nach 15 Minuten ab.
|
||||
</li>
|
||||
<li class="before:text-ink-muted before:mr-2 before:content-['·']">
|
||||
<strong class="text-ink font-[600]">Server-Logfiles</strong> – Werden nach den beim Hostinganbieter
|
||||
üblichen Fristen gelöscht.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">17 · Änderungen dieser Datenschutzerklärung</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Wir behalten uns vor, diese Datenschutzerklärung anzupassen, um sie an geänderte Rechtslagen
|
||||
oder Änderungen des Dienstes anzupassen. Die aktuelle Version finden Sie stets auf dieser
|
||||
Seite.
|
||||
</p>
|
||||
<p class="text-ink-muted mt-6 font-mono text-[10px] tracking-[0.15em] uppercase">
|
||||
Stand: Februar 2026
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,118 +1,145 @@
|
||||
<script lang="ts">
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Impressum - Marktvogt</title>
|
||||
<title>Impressum — Marktvogt</title>
|
||||
<meta name="description" content="Impressum und Angaben gemäß § 5 TMG für Marktvogt." />
|
||||
<meta property="og:title" content="Impressum - Marktvogt" />
|
||||
<meta property="og:description" content="Impressum und Angaben gemäß § 5 TMG für Marktvogt." />
|
||||
<meta property="og:title" content="Impressum — Marktvogt" />
|
||||
<meta property="og:type" content="website" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-3xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<h1 class="text-3xl font-bold text-stone-900 dark:text-stone-100">Impressum</h1>
|
||||
<!-- Title block -->
|
||||
<section class="bg-bg px-10 pt-[64px] pb-[48px]">
|
||||
<div class="mx-auto max-w-[760px]">
|
||||
<Caps class="mb-3">Rechtliche Angaben</Caps>
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(36px,5vw,60px)] leading-[0.95] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Impressum
|
||||
</h1>
|
||||
<div class="mt-6">
|
||||
<Rule kind="thin" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Angaben gemäß § 5 TMG</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Christian Nachtigall<br />
|
||||
Karwendelstr. 21<br />
|
||||
82061 Neuried
|
||||
</p>
|
||||
</section>
|
||||
<!-- Content -->
|
||||
<div class="bg-bg px-10 pb-20">
|
||||
<div class="mx-auto max-w-[760px] space-y-10">
|
||||
<section>
|
||||
<Caps class="mb-3">Angaben gemäß § 5 TMG</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Christian Nachtigall<br />
|
||||
Karwendelstr. 21<br />
|
||||
82061 Neuried
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Kontakt</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
E-Mail: <a
|
||||
href="mailto:contact@marktvogt.de"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300"
|
||||
>contact@marktvogt.de</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Christian Nachtigall<br />
|
||||
Karwendelstr. 21<br />
|
||||
82061 Neuried
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<Caps class="mb-3">Kontakt</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
E-Mail: <a href="mailto:contact@marktvogt.de" class="text-accent hover:underline"
|
||||
>contact@marktvogt.de</a
|
||||
>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Haftung für Inhalte</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene Inhalte auf diesen Seiten nach
|
||||
den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter
|
||||
jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen
|
||||
oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den
|
||||
allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst ab
|
||||
dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden von
|
||||
entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.
|
||||
</p>
|
||||
</section>
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
Keine Gewähr für Vollständigkeit und Richtigkeit
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Die auf dieser Plattform bereitgestellten Informationen zu Mittelaltermärkten, Veranstaltungen
|
||||
und Anbietern werden nach bestem Wissen zusammengestellt. Wir übernehmen jedoch keine Gewähr
|
||||
für die Aktualität, Vollständigkeit oder Richtigkeit der dargestellten Daten. Angaben zu
|
||||
Terminen, Orten, Preisen und sonstigen Veranstaltungsdetails können sich kurzfristig ändern.
|
||||
Verbindliche Informationen sind stets direkt beim jeweiligen Veranstalter einzuholen.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<Caps class="mb-3">Verantwortlich für den Inhalt nach § 18 Abs. 2 MStV</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Christian Nachtigall<br />
|
||||
Karwendelstr. 21<br />
|
||||
82061 Neuried
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
Nutzereingereichte Inhalte
|
||||
</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Nutzer können über das Formular „Markt einreichen" Informationen zu Mittelaltermärkten zur
|
||||
Veröffentlichung vorschlagen. Alle Einreichungen werden vor der Veröffentlichung redaktionell
|
||||
geprüft. Für die Richtigkeit der von Nutzern eingesandten Informationen übernehmen wir keine
|
||||
Gewähr.
|
||||
</p>
|
||||
</section>
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Haftung für Links</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen
|
||||
Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen. Für
|
||||
die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der Seiten
|
||||
verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf mögliche
|
||||
Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung nicht
|
||||
erkennbar.
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete
|
||||
Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von Rechtsverletzungen
|
||||
werden wir derartige Links umgehend entfernen.
|
||||
</p>
|
||||
</section>
|
||||
<section>
|
||||
<Caps class="mb-3">Haftung für Inhalte</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Als Diensteanbieter sind wir gemäß § 7 Abs. 1 TMG für eigene Inhalte auf diesen Seiten nach
|
||||
den allgemeinen Gesetzen verantwortlich. Nach §§ 8 bis 10 TMG sind wir als Diensteanbieter
|
||||
jedoch nicht verpflichtet, übermittelte oder gespeicherte fremde Informationen zu überwachen
|
||||
oder nach Umständen zu forschen, die auf eine rechtswidrige Tätigkeit hinweisen.
|
||||
</p>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Verpflichtungen zur Entfernung oder Sperrung der Nutzung von Informationen nach den
|
||||
allgemeinen Gesetzen bleiben hiervon unberührt. Eine diesbezügliche Haftung ist jedoch erst
|
||||
ab dem Zeitpunkt der Kenntnis einer konkreten Rechtsverletzung möglich. Bei Bekanntwerden
|
||||
von entsprechenden Rechtsverletzungen werden wir diese Inhalte umgehend entfernen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Urheberrecht</h2>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem
|
||||
deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der
|
||||
Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung des
|
||||
jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den
|
||||
privaten, nicht kommerziellen Gebrauch gestattet.
|
||||
</p>
|
||||
<p class="mt-2 text-stone-700 dark:text-stone-300">
|
||||
Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die
|
||||
Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche gekennzeichnet.
|
||||
Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden, bitten wir um einen
|
||||
entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden wir derartige Inhalte
|
||||
umgehend entfernen.
|
||||
</p>
|
||||
</section>
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">Keine Gewähr für Vollständigkeit und Richtigkeit</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Die auf dieser Plattform bereitgestellten Informationen zu Mittelaltermärkten,
|
||||
Veranstaltungen und Anbietern werden nach bestem Wissen zusammengestellt. Wir übernehmen
|
||||
jedoch keine Gewähr für die Aktualität, Vollständigkeit oder Richtigkeit der dargestellten
|
||||
Daten. Angaben zu Terminen, Orten, Preisen und sonstigen Veranstaltungsdetails können sich
|
||||
kurzfristig ändern. Verbindliche Informationen sind stets direkt beim jeweiligen
|
||||
Veranstalter einzuholen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">Nutzereingereichte Inhalte</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Nutzer können über das Formular „Markt einreichen" Informationen zu Mittelaltermärkten zur
|
||||
Veröffentlichung vorschlagen. Alle Einreichungen werden vor der Veröffentlichung
|
||||
redaktionell geprüft. Für die Richtigkeit der von Nutzern eingesandten Informationen
|
||||
übernehmen wir keine Gewähr.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">Haftung für Links</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Unser Angebot enthält Links zu externen Websites Dritter, auf deren Inhalte wir keinen
|
||||
Einfluss haben. Deshalb können wir für diese fremden Inhalte auch keine Gewähr übernehmen.
|
||||
Für die Inhalte der verlinkten Seiten ist stets der jeweilige Anbieter oder Betreiber der
|
||||
Seiten verantwortlich. Die verlinkten Seiten wurden zum Zeitpunkt der Verlinkung auf
|
||||
mögliche Rechtsverstöße überprüft. Rechtswidrige Inhalte waren zum Zeitpunkt der Verlinkung
|
||||
nicht erkennbar.
|
||||
</p>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Eine permanente inhaltliche Kontrolle der verlinkten Seiten ist jedoch ohne konkrete
|
||||
Anhaltspunkte einer Rechtsverletzung nicht zumutbar. Bei Bekanntwerden von
|
||||
Rechtsverletzungen werden wir derartige Links umgehend entfernen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" />
|
||||
|
||||
<section>
|
||||
<Caps class="mb-3">Urheberrecht</Caps>
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.7]">
|
||||
Die durch die Seitenbetreiber erstellten Inhalte und Werke auf diesen Seiten unterliegen dem
|
||||
deutschen Urheberrecht. Die Vervielfältigung, Bearbeitung, Verbreitung und jede Art der
|
||||
Verwertung außerhalb der Grenzen des Urheberrechtes bedürfen der schriftlichen Zustimmung
|
||||
des jeweiligen Autors bzw. Erstellers. Downloads und Kopien dieser Seite sind nur für den
|
||||
privaten, nicht kommerziellen Gebrauch gestattet.
|
||||
</p>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[16px] leading-[1.7]">
|
||||
Soweit die Inhalte auf dieser Seite nicht vom Betreiber erstellt wurden, werden die
|
||||
Urheberrechte Dritter beachtet. Insbesondere werden Inhalte Dritter als solche
|
||||
gekennzeichnet. Sollten Sie trotzdem auf eine Urheberrechtsverletzung aufmerksam werden,
|
||||
bitten wir um einen entsprechenden Hinweis. Bei Bekanntwerden von Rechtsverletzungen werden
|
||||
wir derartige Inhalte umgehend entfernen.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { apiFetch } from '$lib/api/client.js';
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
const now = new Date();
|
||||
const year = parseInt(url.searchParams.get('year') ?? String(now.getUTCFullYear()));
|
||||
const month = parseInt(url.searchParams.get('month') ?? String(now.getUTCMonth() + 1));
|
||||
|
||||
const mm = String(month).padStart(2, '0');
|
||||
const from = `${year}-${mm}-01`;
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
const to = `${year}-${mm}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
try {
|
||||
const res = await apiFetch<MarketSummary[]>(
|
||||
`/markets?from=${from}&to=${to}&per_page=200&sort=date`,
|
||||
{ fetch }
|
||||
);
|
||||
return { markets: res.data, year, month };
|
||||
} catch {
|
||||
return { markets: [] as MarketSummary[], year, month };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,288 @@
|
||||
<script lang="ts">
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
const MONTHS_DE = [
|
||||
'Januar',
|
||||
'Februar',
|
||||
'März',
|
||||
'April',
|
||||
'Mai',
|
||||
'Juni',
|
||||
'Juli',
|
||||
'August',
|
||||
'September',
|
||||
'Oktober',
|
||||
'November',
|
||||
'Dezember'
|
||||
];
|
||||
const DAYS_LONG = [
|
||||
'Montag',
|
||||
'Dienstag',
|
||||
'Mittwoch',
|
||||
'Donnerstag',
|
||||
'Freitag',
|
||||
'Samstag',
|
||||
'Sonntag'
|
||||
];
|
||||
const DAYS_SHORT = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
|
||||
|
||||
function prevUrl(): string {
|
||||
let m = data.month - 1,
|
||||
y = data.year;
|
||||
if (m < 1) {
|
||||
m = 12;
|
||||
y--;
|
||||
}
|
||||
return `/kalender?year=${y}&month=${m}`;
|
||||
}
|
||||
function nextUrl(): string {
|
||||
let m = data.month + 1,
|
||||
y = data.year;
|
||||
if (m > 12) {
|
||||
m = 1;
|
||||
y++;
|
||||
}
|
||||
return `/kalender?year=${y}&month=${m}`;
|
||||
}
|
||||
|
||||
const calendarWeeks = $derived(
|
||||
(() => {
|
||||
const first = new Date(Date.UTC(data.year, data.month - 1, 1));
|
||||
const last = new Date(Date.UTC(data.year, data.month, 0));
|
||||
const startOffset = (first.getUTCDay() + 6) % 7; // Mon=0
|
||||
|
||||
const cells: Array<{ day: number | null; date: string | null; colIdx: number }> = [];
|
||||
for (let i = 0; i < startOffset; i++) cells.push({ day: null, date: null, colIdx: i });
|
||||
for (let d = 1; d <= last.getUTCDate(); d++) {
|
||||
const colIdx = (startOffset + d - 1) % 7;
|
||||
const mm = String(data.month).padStart(2, '0');
|
||||
const dd = String(d).padStart(2, '0');
|
||||
cells.push({ day: d, date: `${data.year}-${mm}-${dd}`, colIdx });
|
||||
}
|
||||
while (cells.length % 7 !== 0)
|
||||
cells.push({ day: null, date: null, colIdx: cells.length % 7 });
|
||||
|
||||
const weeks: (typeof cells)[] = [];
|
||||
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7));
|
||||
return weeks;
|
||||
})()
|
||||
);
|
||||
|
||||
// Map: date string → markets active on that day
|
||||
const marketsByDate = $derived(
|
||||
(() => {
|
||||
const map = new Map<string, MarketSummary[]>();
|
||||
for (const m of data.markets) {
|
||||
const start = new Date(m.start_date + 'T00:00:00Z');
|
||||
const end = new Date(m.end_date + 'T00:00:00Z');
|
||||
const cur = new Date(start);
|
||||
while (cur <= end) {
|
||||
const key = cur.toISOString().slice(0, 10);
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(m);
|
||||
cur.setUTCDate(cur.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
return map;
|
||||
})()
|
||||
);
|
||||
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
|
||||
// Deduplicated list for the list view
|
||||
const uniqueMarkets = $derived(
|
||||
data.markets.filter((m, i, arr) => arr.findIndex((x) => x.slug === m.slug) === i)
|
||||
);
|
||||
|
||||
function fmtRange(from: string, to: string): string {
|
||||
const opts: Intl.DateTimeFormatOptions = { day: '2-digit', month: 'short', timeZone: 'UTC' };
|
||||
const f = new Date(from).toLocaleDateString('de-DE', opts);
|
||||
const t = new Date(to).toLocaleDateString('de-DE', { ...opts, year: 'numeric' });
|
||||
return from === to ? f : `${f} – ${t}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Kalender · {MONTHS_DE[data.month - 1]} {data.year} — Marktvogt</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Alle Mittelaltermärkte im {MONTHS_DE[data.month - 1]} {data.year}."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<!-- ── Page header ──────────────────────────────────── -->
|
||||
<section class="bg-bg border-rule-soft border-b px-10 py-8">
|
||||
<div class="mx-auto flex max-w-[1320px] items-end justify-between gap-8">
|
||||
<!-- Month title -->
|
||||
<div>
|
||||
<Caps class="mb-2">Kalender historischer Märkte</Caps>
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(36px,5vw,68px)] leading-[0.93] font-[500] tracking-[-0.02em]"
|
||||
>
|
||||
{MONTHS_DE[data.month - 1]}
|
||||
<span class="text-ink-muted">{data.year}</span>
|
||||
</h1>
|
||||
{#if uniqueMarkets.length > 0}
|
||||
<p class="text-ink-muted mt-2 font-serif text-[14px] italic">
|
||||
{uniqueMarkets.length}
|
||||
{uniqueMarkets.length === 1 ? 'Markt' : 'Märkte'} in diesem Monat
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="border-rule-soft flex items-center gap-0 border" aria-label="Monatsnavigation">
|
||||
<a
|
||||
href={prevUrl()}
|
||||
class="border-rule-soft text-ink-muted hover:bg-surface hover:text-ink flex items-center gap-2 border-r px-5 py-3 font-mono text-[11px] tracking-[0.1em] uppercase"
|
||||
>
|
||||
‹ <span class="hidden sm:inline">{MONTHS_DE[(data.month + 10) % 12]}</span>
|
||||
</a>
|
||||
<a
|
||||
href="/kalender"
|
||||
class="border-rule-soft text-ink-muted hover:bg-surface hover:text-ink border-r px-4 py-3 font-mono text-[11px] tracking-[0.1em] uppercase"
|
||||
title="Aktueller Monat"
|
||||
>
|
||||
Heute
|
||||
</a>
|
||||
<a
|
||||
href={nextUrl()}
|
||||
class="text-ink-muted hover:bg-surface hover:text-ink flex items-center gap-2 px-5 py-3 font-mono text-[11px] tracking-[0.1em] uppercase"
|
||||
>
|
||||
<span class="hidden sm:inline">{MONTHS_DE[data.month % 12]}</span> ›
|
||||
</a>
|
||||
</nav>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ── Calendar grid ────────────────────────────────── -->
|
||||
<div class="bg-bg">
|
||||
<div class="mx-auto max-w-[1320px]">
|
||||
<!-- Day-of-week headers -->
|
||||
<div class="border-rule-soft grid grid-cols-7 border-b">
|
||||
{#each DAYS_SHORT as day, i}
|
||||
<div
|
||||
class="border-rule-soft border-r py-2.5 text-center font-mono text-[10px] tracking-[0.15em] uppercase last:border-r-0 {i >=
|
||||
5
|
||||
? 'text-accent'
|
||||
: 'text-ink-muted'}"
|
||||
>
|
||||
<span class="hidden sm:inline">{DAYS_LONG[i]}</span>
|
||||
<span class="sm:hidden">{day}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Week rows -->
|
||||
{#each calendarWeeks as week}
|
||||
<div class="border-rule-soft grid grid-cols-7 border-b">
|
||||
{#each week as cell}
|
||||
{@const cellMarkets = cell.date ? (marketsByDate.get(cell.date) ?? []) : []}
|
||||
{@const isToday = cell.date === todayIso}
|
||||
{@const isWeekend = cell.colIdx >= 5}
|
||||
{@const hasMarkets = cellMarkets.length > 0}
|
||||
<div
|
||||
class="border-rule-soft relative min-h-[120px] border-r p-3 last:border-r-0
|
||||
{isWeekend && cell.day !== null ? 'bg-surface' : ''}
|
||||
{!cell.day ? 'bg-surface-alt opacity-30' : ''}"
|
||||
>
|
||||
{#if cell.day !== null}
|
||||
<!-- Day number -->
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<span
|
||||
class="font-mono text-[13px] leading-none font-[500]
|
||||
{isToday ? 'text-accent font-[700]' : isWeekend ? 'text-ink-soft' : 'text-ink-muted'}"
|
||||
>
|
||||
{cell.day}
|
||||
</span>
|
||||
{#if hasMarkets}
|
||||
<span
|
||||
class="bg-accent h-1.5 w-1.5 rounded-full"
|
||||
title="{cellMarkets.length} {cellMarkets.length === 1 ? 'Markt' : 'Märkte'}"
|
||||
></span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Markets in this cell -->
|
||||
{#each cellMarkets.slice(0, 3) as market}
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="text-ink hover:text-accent mb-1 block truncate font-serif text-[12px] leading-[1.3] no-underline"
|
||||
title="{market.name} — {market.city}"
|
||||
>
|
||||
{market.name}
|
||||
</a>
|
||||
{/each}
|
||||
{#if cellMarkets.length > 3}
|
||||
<span class="text-ink-muted font-mono text-[9px] tracking-[0.08em] uppercase">
|
||||
+{cellMarkets.length - 3} weitere
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Market list ──────────────────────────────────── -->
|
||||
<div class="bg-bg px-10 py-12">
|
||||
<div class="mx-auto max-w-[1320px]">
|
||||
{#if uniqueMarkets.length === 0}
|
||||
<div class="py-20 text-center">
|
||||
<div class="font-display text-ink-muted text-[56px] italic">∅</div>
|
||||
<p class="text-ink-muted mt-4 font-serif text-[17px] italic">
|
||||
Keine Märkte in diesem Monat.
|
||||
</p>
|
||||
<a
|
||||
href="/maerkte"
|
||||
class="text-accent mt-6 inline-block font-serif text-[15px] no-underline"
|
||||
>
|
||||
Alle Märkte anzeigen ›
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mb-8 flex items-center gap-6">
|
||||
<Caps size={11}>
|
||||
{uniqueMarkets.length}
|
||||
{uniqueMarkets.length === 1 ? 'Markt' : 'Märkte'} im {MONTHS_DE[data.month - 1]}
|
||||
</Caps>
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
</div>
|
||||
|
||||
{#each uniqueMarkets as market}
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="border-rule-soft hover:bg-surface-alt grid items-baseline gap-4 border-t py-4 no-underline transition-colors
|
||||
sm:grid-cols-[120px_1fr_200px]"
|
||||
>
|
||||
<div>
|
||||
<div class="font-display text-accent text-[22px] leading-none font-[500]">
|
||||
{new Date(market.start_date).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: 'UTC'
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<div class="font-display text-ink text-[18px] leading-[1.05] font-[500]">
|
||||
{market.name}
|
||||
</div>
|
||||
<div class="font-display text-ink-soft mt-0.5 text-[13px] italic">
|
||||
{market.city} · {market.state}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-ink-muted hidden text-right font-serif text-[13px] italic sm:block">
|
||||
{fmtRange(market.start_date, market.end_date)}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { apiFetch } from '$lib/api/client.js';
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
try {
|
||||
const res = await apiFetch<MarketSummary[]>(`/markets?from=${today}&per_page=500`, { fetch });
|
||||
return { markets: res.data };
|
||||
} catch {
|
||||
return { markets: [] as MarketSummary[] };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import MarketMap from '$lib/components/market/MarketMap.svelte';
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let selected = $state<MarketSummary | null>(null);
|
||||
let searchQuery = $state('');
|
||||
let containerTop = $state(0);
|
||||
|
||||
const filtered = $derived(
|
||||
searchQuery.trim()
|
||||
? data.markets.filter(
|
||||
(m) =>
|
||||
m.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
m.city.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
m.state.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: data.markets
|
||||
);
|
||||
|
||||
onMount(() => {
|
||||
// Measure actual header height so the container fills exactly the remaining viewport
|
||||
const header = document.querySelector('header');
|
||||
containerTop = header ? header.getBoundingClientRect().bottom : 72;
|
||||
|
||||
// Prevent the page from scrolling behind the map/sidebar
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
});
|
||||
|
||||
function fmtDateShort(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
}
|
||||
|
||||
function fmtDateRange(from: string, to: string): string {
|
||||
const f = fmtDateShort(from);
|
||||
const t = fmtDateShort(to);
|
||||
return from === to ? f : `${f} – ${t}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Karte — Marktvogt</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Alle kommenden Mittelaltermärkte in Deutschland, Österreich und der Schweiz auf der Karte."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<div class="fixed right-0 bottom-0 left-0 flex" style="top: {containerTop}px;">
|
||||
<!-- Sidebar -->
|
||||
<aside class="border-rule-soft bg-bg flex w-[340px] flex-shrink-0 flex-col border-r">
|
||||
<!-- Header -->
|
||||
<div class="border-rule-soft border-b px-5 py-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="font-display text-ink text-[20px] leading-none font-[500]">Karte</h1>
|
||||
<Caps size={9}>{filtered.length} Märkte</Caps>
|
||||
</div>
|
||||
<input
|
||||
type="search"
|
||||
bind:value={searchQuery}
|
||||
placeholder="Markt, Ort oder Region…"
|
||||
class="border-rule-soft bg-surface text-ink placeholder:text-ink-muted focus:border-accent mt-3 w-full border px-3 py-2 font-serif text-[13px] italic focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Market list -->
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
{#if filtered.length === 0}
|
||||
<div class="py-12 text-center">
|
||||
<p class="text-ink-muted font-serif text-[14px] italic">Keine Märkte gefunden.</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#each filtered as market}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selected = market)}
|
||||
class="border-rule-soft w-full border-b px-5 py-3.5 text-left transition-colors {selected?.slug ===
|
||||
market.slug
|
||||
? 'bg-surface-alt'
|
||||
: 'hover:bg-surface'}"
|
||||
>
|
||||
<div class="font-display text-ink text-[15px] leading-[1.1] font-[500]">
|
||||
{market.name}
|
||||
</div>
|
||||
<div class="text-ink-soft mt-0.5 font-serif text-[12px] italic">
|
||||
{market.city} · {market.state}
|
||||
</div>
|
||||
<div class="text-ink-muted mt-1 font-mono text-[10px] tracking-[0.1em] uppercase">
|
||||
{fmtDateRange(market.start_date, market.end_date)}
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Selected market panel -->
|
||||
{#if selected}
|
||||
<div class="border-rule-soft bg-surface-alt border-t px-5 py-4">
|
||||
<div class="mb-1 flex items-start justify-between gap-2">
|
||||
<div class="font-display text-ink text-[16px] leading-[1.1] font-[500]">
|
||||
{selected.name}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (selected = null)}
|
||||
class="text-ink-muted hover:text-ink mt-0.5 flex-shrink-0 font-mono text-[14px] leading-none"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-ink-soft font-serif text-[12px] italic">
|
||||
{selected.city} · {selected.state}
|
||||
</div>
|
||||
<div class="text-ink-muted mt-1 font-mono text-[10px] tracking-[0.1em] uppercase">
|
||||
{fmtDateRange(selected.start_date, selected.end_date)}
|
||||
</div>
|
||||
<a
|
||||
href="/markt/{selected.slug}"
|
||||
class="border-ink bg-ink text-bg mt-4 inline-block border px-4 py-1.5 font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
Details →
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
<!-- Map -->
|
||||
<div class="relative flex-1 overflow-hidden">
|
||||
<MarketMap
|
||||
markets={filtered}
|
||||
{selected}
|
||||
class="h-full w-full"
|
||||
onSelect={(m) => (selected = m)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { apiFetch } from '$lib/api/client.js';
|
||||
import type { LagerlebenArticle, LagerlebenCamp } from '$lib/api/types.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
const [articlesRes, campsRes] = await Promise.all([
|
||||
apiFetch<LagerlebenArticle[]>('/lagerleben/articles', { fetch }).catch(() => ({
|
||||
data: [] as LagerlebenArticle[]
|
||||
})),
|
||||
apiFetch<LagerlebenCamp[]>('/lagerleben/camps', { fetch }).catch(() => ({
|
||||
data: [] as LagerlebenCamp[]
|
||||
}))
|
||||
]);
|
||||
return { articles: articlesRes.data, camps: campsRes.data };
|
||||
};
|
||||
@@ -0,0 +1,156 @@
|
||||
<script lang="ts">
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import Heraldry from '$lib/components/atoms/Heraldry.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Lagerleben — Marktvogt</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Reportagen, Ratgeber und Lagerporträts aus der Welt des lebendigen Mittelalters."
|
||||
/>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Title block -->
|
||||
<section class="bg-bg px-10 pt-[72px] pb-[56px]">
|
||||
<div class="mx-auto max-w-[1320px] text-center">
|
||||
<Caps>Das Magazin für lebendiges Mittelalter</Caps>
|
||||
<h1
|
||||
class="font-display text-ink mt-4 text-[clamp(48px,7vw,88px)] leading-[0.95] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Lagerleben
|
||||
</h1>
|
||||
<div class="mt-5 flex items-center gap-6">
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
<span class="text-ink-muted font-serif text-[15px] italic"
|
||||
>Handwerk · Recherche · Gemeinschaft</span
|
||||
>
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- In-progress notice strip -->
|
||||
<div class="border-rule-soft bg-surface-alt border-y px-10 py-4">
|
||||
<div class="mx-auto flex max-w-[1320px] items-center gap-6">
|
||||
<Caps color="var(--color-accent)">Im Aufbau</Caps>
|
||||
<p class="text-ink-muted font-serif text-[13px] italic">
|
||||
Beispielinhalte — vollständige Redaktion und Einreichung folgen in einer späteren Phase.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Lead article + secondary grid -->
|
||||
<div class="bg-bg px-10 py-12">
|
||||
<div class="mx-auto max-w-[1320px]">
|
||||
{#if data.articles.length > 0}
|
||||
{@const lead = data.articles[0]}
|
||||
<!-- Lead -->
|
||||
<a
|
||||
href="/lagerleben/reportage/{lead.slug}"
|
||||
class="border-rule-soft mb-12 grid border no-underline transition-shadow hover:shadow-md sm:grid-cols-[1fr_420px]"
|
||||
>
|
||||
<div
|
||||
class="border-rule-soft bg-surface-alt flex items-center justify-center border-b sm:border-r sm:border-b-0"
|
||||
style="min-height: 300px;"
|
||||
>
|
||||
<div class="h-[50%] w-[30%]">
|
||||
<Heraldry seed={lead.slug} class="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-10 py-10">
|
||||
<Caps color="var(--color-accent)" class="mb-4">{lead.category}</Caps>
|
||||
<h2 class="font-display text-ink text-[32px] leading-[1.0] font-[500]">{lead.title}</h2>
|
||||
<p class="font-display text-ink-soft mt-3 text-[17px] italic">{lead.subtitle}</p>
|
||||
<Rule kind="thin" class="my-6" />
|
||||
<p class="text-ink-soft font-serif text-[16px] leading-[1.65]">{lead.excerpt}</p>
|
||||
<p class="text-ink-muted mt-6 font-mono text-[10px] tracking-[0.15em] uppercase">
|
||||
{fmtDate(lead.date)}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<!-- Section header -->
|
||||
<div class="mb-6 flex items-center gap-6">
|
||||
<Caps size={11}>Weitere Beiträge</Caps>
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
</div>
|
||||
|
||||
<!-- Secondary grid -->
|
||||
<div class="border-rule-soft bg-rule-soft grid gap-px border sm:grid-cols-3">
|
||||
{#each data.articles.slice(1) as article}
|
||||
<a
|
||||
href="/lagerleben/reportage/{article.slug}"
|
||||
class="bg-surface hover:bg-surface-alt flex flex-col no-underline transition-colors"
|
||||
>
|
||||
<div class="flex flex-1 flex-col p-8">
|
||||
<Caps size={9} color="var(--color-accent)" class="mb-3">{article.category}</Caps>
|
||||
<h3 class="font-display text-ink text-[20px] leading-[1.05] font-[500]">
|
||||
{article.title}
|
||||
</h3>
|
||||
<p class="font-display text-ink-soft mt-2 text-[13px] italic">{article.subtitle}</p>
|
||||
<p class="text-ink-soft mt-4 font-serif text-[14px] leading-[1.6]">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
<p
|
||||
class="text-ink-muted mt-auto pt-6 font-mono text-[9px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
{fmtDate(article.date)}
|
||||
</p>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Camps section -->
|
||||
<div class="border-rule-soft bg-surface-alt border-t px-10 py-12">
|
||||
<div class="mx-auto max-w-[1320px]">
|
||||
<div class="mb-8 flex items-center justify-between gap-6">
|
||||
<div>
|
||||
<Caps class="mb-2">Lagerporträts</Caps>
|
||||
<p class="text-ink-muted font-serif text-[15px] italic">
|
||||
Gruppen und Gemeinschaften vorstellen
|
||||
</p>
|
||||
</div>
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
</div>
|
||||
|
||||
<div class="border-rule-soft grid gap-0 border sm:grid-cols-3">
|
||||
{#each data.camps as camp, i}
|
||||
<a
|
||||
href="/lagerleben/lager/{camp.slug}"
|
||||
class="border-rule-soft bg-surface hover:bg-surface-alt border-b p-8 no-underline transition-colors sm:border-b-0 {i >
|
||||
0
|
||||
? 'sm:border-l'
|
||||
: ''} border-rule-soft"
|
||||
>
|
||||
<div class="mb-4 h-12 w-10">
|
||||
<Heraldry seed={camp.slug} class="h-full w-full" />
|
||||
</div>
|
||||
<Caps size={9} class="mb-2">{camp.period} · {camp.region}</Caps>
|
||||
<h3 class="font-display text-ink text-[20px] leading-[1.05] font-[500]">{camp.name}</h3>
|
||||
<Rule kind="thin" class="my-4" />
|
||||
<p class="text-ink-soft font-serif text-[14px] leading-[1.6]">{camp.excerpt}</p>
|
||||
<p class="text-ink-muted mt-5 font-mono text-[10px] tracking-[0.12em] uppercase">
|
||||
{camp.members} Mitglieder
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { apiFetch, ApiClientError } from '$lib/api/client.js';
|
||||
import type { LagerlebenCamp } from '$lib/api/types.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
try {
|
||||
const res = await apiFetch<LagerlebenCamp>(`/lagerleben/camps/${params.slug}`, { fetch });
|
||||
return { camp: res.data };
|
||||
} catch (err) {
|
||||
if (err instanceof ApiClientError && err.status === 404) {
|
||||
error(404, 'Lager nicht gefunden');
|
||||
}
|
||||
error(500, 'Fehler beim Laden des Lagerporträts');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import Heraldry from '$lib/components/atoms/Heraldry.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const camp = $derived(data.camp);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{camp.name} — Lagerleben — Marktvogt</title>
|
||||
<meta name="description" content={camp.excerpt} />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Hero -->
|
||||
<div
|
||||
class="border-rule-soft bg-surface-alt flex items-center justify-center border-b"
|
||||
style="height: 220px;"
|
||||
>
|
||||
<div class="h-[60%] w-[15%]">
|
||||
<Heraldry seed={camp.slug} class="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-[760px] px-10 py-12">
|
||||
<nav class="mb-8">
|
||||
<ol
|
||||
class="text-ink-muted flex items-center gap-1.5 font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
<li><a href="/lagerleben" class="hover:text-ink">Lagerleben</a></li>
|
||||
<li aria-hidden="true">›</li>
|
||||
<li class="text-ink">Lagerporträt</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<Caps class="mb-4">{camp.period} · {camp.region}</Caps>
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(32px,5vw,52px)] leading-[0.97] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
{camp.name}
|
||||
</h1>
|
||||
|
||||
<div class="mt-5 flex items-center gap-4">
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
<Caps size={9}>{camp.members} Mitglieder</Caps>
|
||||
</div>
|
||||
|
||||
<div class="border-rule-soft bg-surface-alt mt-8 border p-6">
|
||||
<Caps color="var(--color-accent)" class="mb-3">Im Aufbau</Caps>
|
||||
<p class="text-ink-soft font-serif text-[15px] leading-[1.65] italic">
|
||||
Lagerporträts befinden sich noch im Aufbau. Vollständige Profile mit Galerie, Mitgliederliste
|
||||
und Kontaktformular folgen in einer späteren Phase.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p class="text-ink-soft mt-8 font-serif text-[16px] leading-[1.65]">
|
||||
{camp.excerpt}
|
||||
</p>
|
||||
|
||||
<div class="mt-10">
|
||||
<Rule kind="thin" />
|
||||
</div>
|
||||
|
||||
<div class="mt-8">
|
||||
<a
|
||||
href="/lagerleben"
|
||||
class="text-ink-muted hover:text-ink font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
‹ Zurück zum Magazin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { apiFetch } from '$lib/api/client.js';
|
||||
import type { LagerlebenArticle } from '$lib/api/types.js';
|
||||
import { ApiClientError } from '$lib/api/client.js';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, fetch }) => {
|
||||
try {
|
||||
const res = await apiFetch<LagerlebenArticle>(`/lagerleben/articles/${params.slug}`, { fetch });
|
||||
return { article: res.data };
|
||||
} catch (err) {
|
||||
if (err instanceof ApiClientError && err.status === 404) {
|
||||
error(404, 'Beitrag nicht gefunden');
|
||||
}
|
||||
error(500, 'Fehler beim Laden des Beitrags');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,82 @@
|
||||
<script lang="ts">
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import Heraldry from '$lib/components/atoms/Heraldry.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const article = $derived(data.article);
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{article.title} — Lagerleben — Marktvogt</title>
|
||||
<meta name="description" content={article.excerpt} />
|
||||
</svelte:head>
|
||||
|
||||
<!-- Hero -->
|
||||
<div
|
||||
class="border-rule-soft bg-surface-alt flex items-center justify-center border-b"
|
||||
style="height: 280px;"
|
||||
>
|
||||
<div class="h-[55%] w-[18%]">
|
||||
<Heraldry seed={article.slug} class="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto max-w-[760px] px-10 py-12">
|
||||
<nav class="mb-8">
|
||||
<ol
|
||||
class="text-ink-muted flex items-center gap-1.5 font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
<li><a href="/lagerleben" class="hover:text-ink">Lagerleben</a></li>
|
||||
<li aria-hidden="true">›</li>
|
||||
<li class="text-ink">{article.title}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<Caps color="var(--color-accent)" class="mb-4">{article.category}</Caps>
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(32px,5vw,52px)] leading-[0.97] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
{article.title}
|
||||
</h1>
|
||||
<p class="font-display text-ink-soft mt-3 text-[18px] italic">{article.subtitle}</p>
|
||||
|
||||
<div class="mt-5 flex items-center gap-4">
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
<Caps size={9}>{fmtDate(article.date)}</Caps>
|
||||
</div>
|
||||
|
||||
<div class="border-rule-soft bg-surface-alt mt-8 border p-6">
|
||||
<Caps color="var(--color-accent)" class="mb-3">Im Aufbau</Caps>
|
||||
<p class="text-ink-soft font-serif text-[15px] leading-[1.65] italic">
|
||||
Dieser Beitrag ist ein Beispielinhalt. Die vollständigen Inhalte des Lagerleben-Magazins
|
||||
folgen in einer späteren Entwicklungsphase.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
<Rule kind="thin" />
|
||||
</div>
|
||||
|
||||
<p class="text-ink-soft mt-8 font-serif text-[15px] leading-[1.65]">
|
||||
{article.excerpt}
|
||||
</p>
|
||||
|
||||
<div class="mt-10">
|
||||
<a
|
||||
href="/lagerleben"
|
||||
class="text-ink-muted hover:text-ink font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
‹ Zurück zum Magazin
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,43 +1,81 @@
|
||||
import type { PageServerLoad } from './$types.js';
|
||||
import { apiFetch } from '$lib/api/client.js';
|
||||
import type { MarketSummary } from '$lib/api/types.js';
|
||||
import { stateToSlug, STATE_SLUGS } from '$lib/utils/slug.js';
|
||||
import { apiFetch, buildSearchQuery } from '$lib/api/client.js';
|
||||
import type { MarketSummary, PaginationMeta } from '$lib/api/types.js';
|
||||
|
||||
export interface StateInfo {
|
||||
slug: string;
|
||||
name: string;
|
||||
count: number;
|
||||
type Coords = { lat?: string; lon?: string };
|
||||
|
||||
async function resolveCoords(
|
||||
plz: string | null,
|
||||
urlLat: string | null,
|
||||
urlLon: string | null,
|
||||
fetch: typeof globalThis.fetch
|
||||
): Promise<Coords> {
|
||||
if (urlLat && urlLon) return { lat: urlLat, lon: urlLon };
|
||||
if (!plz) return {};
|
||||
|
||||
try {
|
||||
const res = await apiFetch<{ latitude: number | null; longitude: number | null }>('/geocode', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ city: '', zip: plz, country: 'DE' }),
|
||||
fetch
|
||||
});
|
||||
const { latitude, longitude } = res.data;
|
||||
if (latitude == null || longitude == null) return {};
|
||||
return { lat: String(latitude), lon: String(longitude) };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export const load: PageServerLoad = async ({ fetch }) => {
|
||||
let markets: MarketSummary[] = [];
|
||||
export const load: PageServerLoad = async ({ url, fetch }) => {
|
||||
const q = url.searchParams.get('q');
|
||||
const plz = url.searchParams.get('plz');
|
||||
const lat = url.searchParams.get('lat');
|
||||
const lon = url.searchParams.get('lon');
|
||||
const radius = url.searchParams.get('radius');
|
||||
const from = url.searchParams.get('from');
|
||||
const to = url.searchParams.get('to');
|
||||
const sort = url.searchParams.get('sort');
|
||||
const page = url.searchParams.get('page');
|
||||
|
||||
const coords = await resolveCoords(plz, lat, lon, fetch);
|
||||
|
||||
const params: Record<string, string> = {};
|
||||
if (q) params.q = q;
|
||||
if (coords.lat) params.lat = coords.lat;
|
||||
if (coords.lon) params.lon = coords.lon;
|
||||
if (radius) params.radius = radius;
|
||||
if (from) params.from = from;
|
||||
if (to) params.to = to;
|
||||
if (sort) params.sort = sort;
|
||||
if (page) params.page = page;
|
||||
|
||||
const query = buildSearchQuery(params);
|
||||
const path = `/markets${query ? `?${query}` : ''}`;
|
||||
|
||||
const searchParams = {
|
||||
q: q ?? '',
|
||||
plz: plz ?? '',
|
||||
lat: lat ? Number(lat) : undefined,
|
||||
lon: lon ? Number(lon) : undefined,
|
||||
radius: radius ? Number(radius) : 25,
|
||||
from: from ?? '',
|
||||
to: to ?? '',
|
||||
sort: sort ?? ''
|
||||
};
|
||||
|
||||
try {
|
||||
const res = await apiFetch<MarketSummary[]>('/markets?per_page=1000', { fetch });
|
||||
markets = res.data;
|
||||
const res = await apiFetch<MarketSummary[]>(path, { fetch });
|
||||
return {
|
||||
markets: res.data,
|
||||
meta: res.meta as PaginationMeta,
|
||||
searchParams
|
||||
};
|
||||
} catch {
|
||||
// Backend unreachable
|
||||
return {
|
||||
markets: [] as MarketSummary[],
|
||||
meta: { page: 1, per_page: 20, total: 0, total_pages: 0 } as PaginationMeta,
|
||||
searchParams
|
||||
};
|
||||
}
|
||||
|
||||
const countByState = new Map<string, number>();
|
||||
for (const m of markets) {
|
||||
countByState.set(m.state, (countByState.get(m.state) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const states: StateInfo[] = Object.entries(STATE_SLUGS)
|
||||
.map(([slug, name]) => ({
|
||||
slug,
|
||||
name,
|
||||
count: countByState.get(name) ?? 0
|
||||
}))
|
||||
.filter((s) => s.count > 0)
|
||||
.sort((a, b) => a.name.localeCompare(b.name, 'de'));
|
||||
|
||||
// Also include any states from data not in the canonical list
|
||||
for (const [state, count] of countByState) {
|
||||
if (!states.some((s) => s.name === state)) {
|
||||
states.push({ slug: stateToSlug(state), name: state, count });
|
||||
}
|
||||
}
|
||||
|
||||
return { states };
|
||||
};
|
||||
|
||||
@@ -1,85 +1,245 @@
|
||||
<script lang="ts">
|
||||
import type { StateInfo } from './+page.server.js';
|
||||
import Heraldry from '$lib/components/atoms/Heraldry.svelte';
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import MarketMap from '$lib/components/market/MarketMap.svelte';
|
||||
import Pagination from '$lib/components/market/Pagination.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
const states: StateInfo[] = $derived(data.states);
|
||||
|
||||
const jsonLdHtml =
|
||||
'<script type="application/ld+json">' +
|
||||
JSON.stringify({
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BreadcrumbList',
|
||||
itemListElement: [
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 1,
|
||||
name: 'Startseite',
|
||||
item: 'https://marktvogt.de/'
|
||||
},
|
||||
{
|
||||
'@type': 'ListItem',
|
||||
position: 2,
|
||||
name: 'Märkte nach Bundesland',
|
||||
item: 'https://marktvogt.de/maerkte/'
|
||||
}
|
||||
]
|
||||
}) +
|
||||
'</' +
|
||||
'script>';
|
||||
let view = $state<'cards' | 'rows' | 'map'>('cards');
|
||||
|
||||
function fmtDay(iso: string): string {
|
||||
return String(new Date(iso).getUTCDate());
|
||||
}
|
||||
function fmtMonthShort(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString('de-DE', { month: 'short', timeZone: 'UTC' });
|
||||
}
|
||||
function fmtDateRange(from: string, to: string): string {
|
||||
const fDay = new Date(from).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
const tDay = new Date(to).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
return `${fDay} – ${tDay}`;
|
||||
}
|
||||
function padNum(n: number): string {
|
||||
return String(n + 1).padStart(3, '0');
|
||||
}
|
||||
|
||||
const searchUrl = $derived(() => {
|
||||
const params = Object.entries(data.searchParams)
|
||||
.filter(([, v]) => v !== undefined && v !== '')
|
||||
.map(([k, v]) => [k, String(v)]);
|
||||
return params.length ? `/maerkte?${new URLSearchParams(params).toString()}` : '/maerkte';
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mittelaltermärkte nach Bundesland - Marktvogt</title>
|
||||
<title>Alle {data.meta.total} Märkte — Marktvogt</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Finde Mittelaltermärkte in allen 16 Bundesländern. Durchstöbere Mittelaltermärkte, Ritterturniere und historische Feste nach Region."
|
||||
content="Das vollständige Verzeichnis historischer Märkte, Mittelalterspektakel und Lagerleben in Deutschland, Österreich und der Schweiz."
|
||||
/>
|
||||
<meta property="og:title" content="Mittelaltermärkte nach Bundesland - Marktvogt" />
|
||||
<meta property="og:description" content="Finde Mittelaltermärkte in allen 16 Bundesländern." />
|
||||
<meta property="og:title" content="Alle Märkte — Marktvogt" />
|
||||
<meta property="og:type" content="website" />
|
||||
{@html jsonLdHtml}
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<nav class="mb-6 text-sm text-stone-500 dark:text-stone-400" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center gap-1.5">
|
||||
<li><a href="/" class="hover:text-stone-700 dark:hover:text-stone-200">Startseite</a></li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li class="text-stone-900 dark:text-stone-100">Märkte nach Bundesland</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
<h1 class="text-3xl font-bold text-stone-900 sm:text-4xl dark:text-stone-100">
|
||||
Mittelaltermärkte nach Bundesland
|
||||
</h1>
|
||||
<p class="mt-2 text-lg text-stone-600 dark:text-stone-300">
|
||||
Entdecke Mittelaltermärkte, Ritterturniere und historische Feste in ganz Deutschland.
|
||||
</p>
|
||||
|
||||
{#if states.length > 0}
|
||||
<div class="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each states as state (state.slug)}
|
||||
<a
|
||||
href="/maerkte/{state.slug}/"
|
||||
class="group bg-vellum rounded-lg border border-stone-200 p-5 shadow-sm transition-shadow hover:shadow-md dark:border-stone-700"
|
||||
>
|
||||
<h2
|
||||
class="group-hover:text-primary-600 dark:group-hover:text-primary-400 text-lg font-semibold text-stone-900 dark:text-stone-100"
|
||||
>
|
||||
{state.name}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-stone-500 dark:text-stone-400">
|
||||
{state.count}
|
||||
{state.count === 1 ? 'Markt' : 'Märkte'}
|
||||
</p>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="bg-vellum mt-8 rounded-lg border border-stone-200 py-16 text-center dark:border-stone-700"
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
TITLE BLOCK
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<section class="bg-bg px-10 pt-[72px] pb-[56px]">
|
||||
<div class="mx-auto max-w-[1320px] text-center">
|
||||
<Caps>Verzeichnis historischer Märkte</Caps>
|
||||
<h1
|
||||
class="font-display text-ink mt-4 text-[clamp(48px,7vw,88px)] leading-[0.95] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
<p class="text-stone-500 dark:text-stone-400">Aktuell keine Märkte verfügbar.</p>
|
||||
Alle {data.meta.total} Märkte
|
||||
</h1>
|
||||
<div class="mt-5">
|
||||
<Rule kind="ornament" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
FILTER BAR
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="border-rule-soft bg-bg/95 sticky top-0 z-20 border-y px-10 py-3 backdrop-blur-sm">
|
||||
<div class="mx-auto flex max-w-[1320px] items-center justify-between gap-4">
|
||||
<form method="get" action="/maerkte" class="flex flex-1 items-center gap-3">
|
||||
<!-- Text search -->
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value={data.searchParams.q}
|
||||
placeholder="Markt, Ort oder Region…"
|
||||
class="border-rule-soft bg-surface text-ink placeholder:text-ink-muted focus:border-accent flex-1 border px-4 py-2 font-serif text-[14px] italic focus:outline-none"
|
||||
/>
|
||||
<!-- Sort -->
|
||||
<select
|
||||
name="sort"
|
||||
class="border-rule-soft bg-surface text-ink focus:border-accent border px-3 py-2 font-mono text-[11px] tracking-[0.12em] uppercase focus:outline-none"
|
||||
>
|
||||
<option
|
||||
value="date"
|
||||
selected={data.searchParams.sort === '' || data.searchParams.sort === 'date'}
|
||||
>Datum</option
|
||||
>
|
||||
<option value="name" selected={data.searchParams.sort === 'name'}>Name</option>
|
||||
</select>
|
||||
<button
|
||||
type="submit"
|
||||
class="border-ink bg-ink text-bg border px-5 py-2 font-mono text-[11px] tracking-[0.14em] uppercase"
|
||||
>Suchen</button
|
||||
>
|
||||
{#if data.searchParams.q || data.searchParams.from || data.searchParams.to}
|
||||
<a
|
||||
href="/maerkte"
|
||||
class="text-ink-muted hover:text-accent font-serif text-[13px] no-underline">× Filter</a
|
||||
>
|
||||
{/if}
|
||||
</form>
|
||||
|
||||
<!-- Result count + view toggle -->
|
||||
<div class="flex flex-shrink-0 items-center gap-4">
|
||||
<Caps size={10}>{data.meta.total} Märkte</Caps>
|
||||
<span class="border-rule-soft flex border">
|
||||
{#each [['cards', '▦'], ['rows', '☰'], ['map', '⊕']] as [v, icon]}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (view = v as 'cards' | 'rows' | 'map')}
|
||||
class="px-3 py-1.5 font-mono text-[12px] transition-colors {view === v
|
||||
? 'bg-ink text-bg'
|
||||
: 'bg-bg text-ink-muted hover:text-ink'}"
|
||||
aria-label={v}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
{/each}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══════════════════════════════════════════════════════
|
||||
RESULTS
|
||||
═══════════════════════════════════════════════════════ -->
|
||||
<div class="bg-bg px-10 py-10">
|
||||
<div class="mx-auto max-w-[1320px]">
|
||||
{#if data.markets.length === 0}
|
||||
<div class="py-24 text-center">
|
||||
<div class="font-display text-ink-muted text-[48px] italic">∅</div>
|
||||
<p class="text-ink-muted mt-4 font-serif text-[17px] italic">
|
||||
Keine Märkte gefunden. Andere Suchkriterien versuchen?
|
||||
</p>
|
||||
<a href="/maerkte" class="text-accent mt-6 inline-block font-serif text-[15px] no-underline"
|
||||
>Alle Märkte anzeigen ›</a
|
||||
>
|
||||
</div>
|
||||
{:else if view === 'map'}
|
||||
<MarketMap markets={data.markets} class="border-rule-soft h-[600px] border" />
|
||||
{:else if view === 'rows'}
|
||||
<!-- Row list grouped by month -->
|
||||
{@const grouped = (() => {
|
||||
const map = new Map<string, typeof data.markets>();
|
||||
for (const m of data.markets) {
|
||||
const key = new Date(m.start_date).toLocaleDateString('de-DE', {
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
timeZone: 'UTC'
|
||||
});
|
||||
if (!map.has(key)) map.set(key, []);
|
||||
map.get(key)!.push(m);
|
||||
}
|
||||
return [...map.entries()];
|
||||
})()}
|
||||
{#each grouped as [month, markets]}
|
||||
<div class="mb-8">
|
||||
<div class="mb-3 flex items-center gap-4">
|
||||
<div class="font-display text-ink text-[28px] leading-none italic">{month}</div>
|
||||
<Rule kind="thin" class="flex-1" />
|
||||
</div>
|
||||
{#each markets as market}
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="border-rule-soft hover:bg-surface-alt flex items-baseline gap-6 border-t py-3 no-underline transition-colors"
|
||||
>
|
||||
<Caps size={9} class="w-12 flex-shrink-0 text-right"
|
||||
>{padNum(data.markets.indexOf(market))}</Caps
|
||||
>
|
||||
<div class="w-24 flex-shrink-0 text-right">
|
||||
<div class="font-display text-accent text-[20px] leading-none font-[500]">
|
||||
{fmtDay(market.start_date)}
|
||||
</div>
|
||||
<Caps size={9}>{fmtMonthShort(market.start_date)}</Caps>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="font-display text-ink text-[18px] font-[500]">{market.name}</div>
|
||||
<div class="font-display text-ink-soft text-[13px] italic">
|
||||
{market.city} · {market.state}
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-ink-muted hidden font-serif text-[13px] italic md:block">
|
||||
{fmtDateRange(market.start_date, market.end_date)}
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<!-- Card grid -->
|
||||
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{#each data.markets as market, i}
|
||||
<a
|
||||
href="/markt/{market.slug}"
|
||||
class="border-rule-soft bg-surface block border no-underline transition-shadow hover:shadow-md"
|
||||
>
|
||||
<div
|
||||
class="border-rule-soft bg-surface-alt relative border-b"
|
||||
style="aspect-ratio: 1.3 / 1"
|
||||
>
|
||||
<span class="absolute inset-0 flex items-center justify-center" aria-hidden="true">
|
||||
<span class="h-[75%] w-[55%]">
|
||||
<Heraldry seed={market.slug} class="h-full w-full" />
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
class="border-rule-soft bg-bg absolute top-3 left-3 border px-2.5 py-1.5 text-center"
|
||||
>
|
||||
<div class="font-display text-accent text-[22px] leading-none font-[500]">
|
||||
{fmtDay(market.start_date)}
|
||||
</div>
|
||||
<Caps size={9}>{fmtMonthShort(market.start_date)}</Caps>
|
||||
</span>
|
||||
<Caps size={9} class="absolute top-3.5 right-3">№ {padNum(i)}</Caps>
|
||||
</div>
|
||||
<div class="px-5 pt-[18px] pb-[22px]">
|
||||
<div class="font-display text-ink text-[20px] leading-[1.1] font-[500]">
|
||||
{market.name}
|
||||
</div>
|
||||
<div class="font-display text-ink-soft mt-1 text-[13px] italic">
|
||||
{market.city} · {market.state}
|
||||
</div>
|
||||
<div class="text-ink-muted mt-2 font-serif text-[12px] italic">
|
||||
{fmtDateRange(market.start_date, market.end_date)}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if view !== 'map' && data.meta.total_pages > 1}
|
||||
<div class="mt-12 flex justify-center">
|
||||
<Pagination meta={data.meta} baseUrl={searchUrl()} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import MarketMap from '$lib/components/market/MarketMap.svelte';
|
||||
import MarketFeedbackDialog from '$lib/components/market/MarketFeedbackDialog.svelte';
|
||||
import Heraldry from '$lib/components/atoms/Heraldry.svelte';
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import type {
|
||||
MarketDetail,
|
||||
OpeningHoursEntry,
|
||||
@@ -239,301 +242,254 @@
|
||||
{@html jsonLdEventHtml}
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-4xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<nav class="mb-6 text-sm text-stone-500 dark:text-stone-400" aria-label="Breadcrumb">
|
||||
<ol class="flex items-center gap-1.5">
|
||||
<li><a href="/maerkte/" class="hover:text-stone-700 dark:hover:text-stone-200">Märkte</a></li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li>
|
||||
<a href="/maerkte/{stateSlug}/" class="hover:text-stone-700 dark:hover:text-stone-200"
|
||||
>{market.state}</a
|
||||
>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li>
|
||||
<a
|
||||
href="/maerkte/{stateSlug}/{citySlug}/"
|
||||
class="hover:text-stone-700 dark:hover:text-stone-200">{market.city}</a
|
||||
>
|
||||
</li>
|
||||
<li aria-hidden="true">/</li>
|
||||
<li class="truncate text-stone-900 dark:text-stone-100">{market.name}</li>
|
||||
<!-- ── Hero ─────────────────────────────────────────────── -->
|
||||
<div
|
||||
class="bg-surface-alt border-rule-soft border-b"
|
||||
style="aspect-ratio: 2.8 / 1; max-height: 340px; overflow: hidden;"
|
||||
>
|
||||
{#if market.image_url}
|
||||
<img
|
||||
src={market.image_url}
|
||||
alt={market.name}
|
||||
class="h-full w-full object-cover"
|
||||
onerror={(e) => {
|
||||
const wrap = e.currentTarget.parentElement;
|
||||
if (wrap) wrap.classList.add('heraldry-fallback');
|
||||
e.currentTarget.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<div class="h-[55%] w-[20%]">
|
||||
<Heraldry seed={market.slug} class="h-full w-full" />
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- ── Main content ───────────────────────────────────── -->
|
||||
<div class="mx-auto max-w-[1320px] px-10 pt-10 pb-20">
|
||||
<!-- Breadcrumb -->
|
||||
<nav aria-label="Breadcrumb" class="mb-6">
|
||||
<ol
|
||||
class="text-ink-muted flex items-center gap-1.5 font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
<li><a href="/maerkte" class="hover:text-ink">Märkte</a></li>
|
||||
<li aria-hidden="true">›</li>
|
||||
<li><a href="/maerkte?state={market.state}" class="hover:text-ink">{market.state}</a></li>
|
||||
<li aria-hidden="true">›</li>
|
||||
<li class="text-ink">{market.name}</li>
|
||||
</ol>
|
||||
</nav>
|
||||
|
||||
{#if market.image_url}
|
||||
<div class="mb-8 overflow-hidden rounded-lg" style="max-height: 250px;">
|
||||
<img
|
||||
src={market.image_url}
|
||||
alt={market.name}
|
||||
class="w-full object-cover"
|
||||
onerror={(e) => {
|
||||
const wrap = e.currentTarget.parentElement;
|
||||
if (wrap) wrap.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{:else if market.logo_url}
|
||||
<div class="mb-8 rounded-lg">
|
||||
<img
|
||||
src={market.logo_url}
|
||||
alt={market.name}
|
||||
class="w-full rounded-lg"
|
||||
style="object-fit: contain; max-height: 150px;"
|
||||
onerror={(e) => {
|
||||
const wrap = e.currentTarget.parentElement;
|
||||
if (wrap) wrap.style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Title block -->
|
||||
<div class="mb-2">
|
||||
<Caps>{market.city} · {market.state}</Caps>
|
||||
</div>
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(36px,5vw,64px)] leading-[0.95] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
{market.name}
|
||||
</h1>
|
||||
|
||||
<h1 class="text-3xl font-bold text-stone-900 dark:text-stone-100">{market.name}</h1>
|
||||
|
||||
<div class="mt-4 flex flex-wrap gap-4 text-sm text-stone-600 dark:text-stone-300">
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0v-7.5A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5"
|
||||
/>
|
||||
</svg>
|
||||
{formatDate(market.start_date)} – {formatDate(market.end_date)}
|
||||
</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M15 10.5a3 3 0 11-6 0 3 3 0 016 0z"
|
||||
/>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M19.5 10.5c0 7.142-7.5 11.25-7.5 11.25S4.5 17.642 4.5 10.5a7.5 7.5 0 1115 0z"
|
||||
/>
|
||||
</svg>
|
||||
{market.street}, {market.zip}
|
||||
{market.city}
|
||||
</span>
|
||||
<!-- Date + address meta row -->
|
||||
<div class="text-ink-soft mt-4 flex flex-wrap gap-x-6 gap-y-1 font-serif text-[15px] italic">
|
||||
<span>{formatDate(market.start_date)} – {formatDate(market.end_date)}</span>
|
||||
{#if market.street}
|
||||
<span>{market.street}, {market.zip} {market.city}</span>
|
||||
{:else}
|
||||
<span>{market.zip} {market.city}</span>
|
||||
{/if}
|
||||
{#if market.organizer_name}
|
||||
<span>Veranstalter: {market.organizer_name}</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Edition switcher -->
|
||||
{#if hasMultipleEditions}
|
||||
<div class="mt-4 flex items-center gap-2">
|
||||
<span class="text-sm text-stone-500 dark:text-stone-400">Ausgabe:</span>
|
||||
<div class="flex gap-1">
|
||||
<div class="mt-5 flex items-center gap-3">
|
||||
<Caps size={9}>Ausgabe</Caps>
|
||||
<span class="border-rule-soft flex border">
|
||||
{#each editions as edition}
|
||||
{@const isActive = edition.year === currentYear}
|
||||
<a
|
||||
href="/markt/{market.slug}{isActive ? '' : `?year=${edition.year}`}"
|
||||
class="rounded-md px-2.5 py-1 text-sm font-medium transition-colors
|
||||
{isActive
|
||||
? 'bg-primary-600 dark:bg-primary-500 text-white'
|
||||
: 'bg-stone-100 text-stone-600 hover:bg-stone-200 dark:bg-stone-800 dark:text-stone-400 dark:hover:bg-stone-700'}"
|
||||
class="px-3 py-1.5 font-mono text-[11px] tracking-[0.08em] transition-colors {isActive
|
||||
? 'bg-ink text-bg'
|
||||
: 'text-ink-muted hover:text-ink'}"
|
||||
>
|
||||
{edition.year}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if market.organizer_name}
|
||||
<p class="mt-2 text-sm text-stone-500 dark:text-stone-400">
|
||||
Veranstalter: {market.organizer_name}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if market.description}
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Beschreibung</h2>
|
||||
<p class="mt-2 whitespace-pre-line text-stone-700 dark:text-stone-300">
|
||||
{market.description}
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-8 grid gap-8 sm:grid-cols-2">
|
||||
{#if openingHours.length > 0}
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Öffnungszeiten</h2>
|
||||
<table class="mt-3 w-full text-sm">
|
||||
<tbody>
|
||||
{#each openingHours as entry}
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">{entry.day}</td>
|
||||
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
|
||||
>{entry.open} – {entry.close}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if admission}
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-stone-900 dark:text-stone-100">Eintrittspreise</h2>
|
||||
<table class="mt-3 w-full text-sm">
|
||||
<tbody>
|
||||
{#if parsedNotes.groups.length > 0}
|
||||
{#each parsedNotes.groups as group, i}
|
||||
<tr class="border-b border-stone-200 dark:border-stone-600">
|
||||
<td
|
||||
colspan="2"
|
||||
class="{i > 0
|
||||
? 'pt-3'
|
||||
: 'pt-1'} pb-1 font-semibold text-stone-800 dark:text-stone-200"
|
||||
>{group.label}</td
|
||||
>
|
||||
</tr>
|
||||
{#each group.entries as entry}
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 pl-3 font-medium text-stone-700 dark:text-stone-200"
|
||||
>{entry.category}</td
|
||||
>
|
||||
<td class="py-2 text-right text-stone-600 dark:text-stone-300">{entry.price}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
{/each}
|
||||
{:else}
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">Erwachsene</td>
|
||||
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
|
||||
>{centsToEuro(admission.adult_cents)}</td
|
||||
>
|
||||
</tr>
|
||||
{#if admission.reduced_cents > 0}
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">Ermäßigt</td>
|
||||
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
|
||||
>{centsToEuro(admission.reduced_cents)}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if admission.child_cents > 0}
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">Kinder</td>
|
||||
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
|
||||
>{centsToEuro(admission.child_cents)}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if admission.free_under_age > 0}
|
||||
<tr class="border-b border-stone-100 dark:border-stone-700">
|
||||
<td class="py-2 font-medium text-stone-700 dark:text-stone-200">Frei unter</td>
|
||||
<td class="py-2 text-right text-stone-600 dark:text-stone-300"
|
||||
>{admission.free_under_age} Jahre</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if parsedNotes.groups.length > 0 && parsedNotes.remaining}
|
||||
<p class="mt-2 text-sm text-stone-500 dark:text-stone-400">{parsedNotes.remaining}</p>
|
||||
{:else if parsedNotes.groups.length === 0 && admission.notes}
|
||||
<p class="mt-2 text-sm text-stone-500 dark:text-stone-400">{admission.notes}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if market.website}
|
||||
<div class="mt-8">
|
||||
<!-- Action row -->
|
||||
<div class="mt-6 flex flex-wrap items-center gap-4">
|
||||
{#if market.website}
|
||||
<a
|
||||
href={market.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 inline-flex items-center gap-1 text-sm font-medium"
|
||||
class="border-ink bg-ink text-bg border px-5 py-2 font-mono text-[11px] tracking-[0.14em] uppercase"
|
||||
>
|
||||
Website besuchen
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M13.5 6H5.25A2.25 2.25 0 003 8.25v10.5A2.25 2.25 0 005.25 21h10.5A2.25 2.25 0 0018 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
|
||||
/>
|
||||
</svg>
|
||||
Website →
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="mt-8">
|
||||
<h2 class="mb-3 text-lg font-semibold text-stone-900 dark:text-stone-100">Standort</h2>
|
||||
<MarketMap
|
||||
markets={[
|
||||
{
|
||||
id: market.id,
|
||||
slug: market.slug,
|
||||
name: market.name,
|
||||
city: market.city,
|
||||
state: market.state,
|
||||
zip: market.zip,
|
||||
country: market.country,
|
||||
latitude: market.latitude,
|
||||
longitude: market.longitude,
|
||||
start_date: market.start_date,
|
||||
end_date: market.end_date,
|
||||
image_url: market.image_url,
|
||||
logo_url: market.logo_url,
|
||||
organizer_name: market.organizer_name
|
||||
}
|
||||
]}
|
||||
class="h-[300px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mt-8 flex flex-wrap items-center justify-end gap-3 border-t border-stone-200 pt-6 dark:border-stone-700"
|
||||
>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (feedbackOpen = true)}
|
||||
class="inline-flex items-center gap-1.5 text-sm font-medium text-stone-600 hover:text-stone-900 dark:text-stone-400 dark:hover:text-stone-100"
|
||||
class="text-ink-muted hover:text-accent font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
Falsche oder fehlende Angaben melden
|
||||
Angaben melden
|
||||
</button>
|
||||
|
||||
{#if isAdmin}
|
||||
<a
|
||||
href="/admin/maerkte/{market.id}/bearbeiten"
|
||||
class="inline-flex items-center gap-1.5 rounded-md border border-amber-400/60 bg-amber-50 px-2.5 py-1 text-sm font-medium text-amber-800 hover:bg-amber-100 dark:border-amber-500/40 dark:bg-amber-900/30 dark:text-amber-200 dark:hover:bg-amber-900/50"
|
||||
title="Nur Admins sehen diesen Link"
|
||||
class="border-accent text-accent border px-3 py-1.5 font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
<svg
|
||||
class="h-4 w-4"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L10.582 16.07a4.5 4.5 0 01-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 011.13-1.897l8.932-8.931z"
|
||||
/>
|
||||
</svg>
|
||||
Bearbeiten
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
<Rule kind="thin" />
|
||||
</div>
|
||||
|
||||
<!-- Content grid -->
|
||||
<div class="mt-10 grid gap-12 lg:grid-cols-[1fr_320px]">
|
||||
<!-- Left column -->
|
||||
<div class="space-y-10">
|
||||
{#if market.description}
|
||||
<section>
|
||||
<Caps class="mb-4">Beschreibung</Caps>
|
||||
<p class="text-ink-soft font-serif text-[17px] leading-[1.65] whitespace-pre-line">
|
||||
{market.description}
|
||||
</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if openingHours.length > 0}
|
||||
<section>
|
||||
<Caps class="mb-4">Öffnungszeiten</Caps>
|
||||
<table class="w-full">
|
||||
<tbody>
|
||||
{#each openingHours as entry}
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink py-2.5 font-serif text-[15px]">{entry.day}</td>
|
||||
<td
|
||||
class="text-ink-soft py-2.5 text-right font-mono text-[12px] tracking-[0.05em]"
|
||||
>{entry.open} – {entry.close}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if admission}
|
||||
<section>
|
||||
<Caps class="mb-4">Eintrittspreise</Caps>
|
||||
<table class="w-full">
|
||||
<tbody>
|
||||
{#if parsedNotes.groups.length > 0}
|
||||
{#each parsedNotes.groups as group, i}
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td
|
||||
colspan="2"
|
||||
class="{i > 0
|
||||
? 'pt-4'
|
||||
: 'pt-1'} text-ink-muted pb-1 font-serif text-[13px] tracking-[0.08em] uppercase"
|
||||
>{group.label}</td
|
||||
>
|
||||
</tr>
|
||||
{#each group.entries as entry}
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink py-2.5 pl-3 font-serif text-[15px]">{entry.category}</td>
|
||||
<td class="text-ink-soft py-2.5 text-right font-mono text-[13px]"
|
||||
>{entry.price}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
{/each}
|
||||
{:else}
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink py-2.5 font-serif text-[15px]">Erwachsene</td>
|
||||
<td class="text-ink-soft py-2.5 text-right font-mono text-[13px]"
|
||||
>{centsToEuro(admission.adult_cents)}</td
|
||||
>
|
||||
</tr>
|
||||
{#if admission.reduced_cents > 0}
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink py-2.5 font-serif text-[15px]">Ermäßigt</td>
|
||||
<td class="text-ink-soft py-2.5 text-right font-mono text-[13px]"
|
||||
>{centsToEuro(admission.reduced_cents)}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{#if admission.child_cents > 0}
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink py-2.5 font-serif text-[15px]">Kinder</td>
|
||||
<td class="text-ink-soft py-2.5 text-right font-mono text-[13px]"
|
||||
>{centsToEuro(admission.child_cents)}</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if admission.free_under_age > 0}
|
||||
<tr class="border-rule-soft border-b">
|
||||
<td class="text-ink py-2.5 font-serif text-[15px]">Frei unter</td>
|
||||
<td class="text-ink-soft py-2.5 text-right font-mono text-[13px]"
|
||||
>{admission.free_under_age} Jahre</td
|
||||
>
|
||||
</tr>
|
||||
{/if}
|
||||
</tbody>
|
||||
</table>
|
||||
{#if parsedNotes.remaining || (parsedNotes.groups.length === 0 && admission.notes)}
|
||||
<p class="text-ink-muted mt-3 font-serif text-[13px] italic">
|
||||
{parsedNotes.remaining || admission.notes}
|
||||
</p>
|
||||
{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Right column — map -->
|
||||
<aside>
|
||||
<Caps class="mb-4">Standort</Caps>
|
||||
<MarketMap
|
||||
markets={[
|
||||
{
|
||||
id: market.id,
|
||||
slug: market.slug,
|
||||
name: market.name,
|
||||
city: market.city,
|
||||
state: market.state,
|
||||
zip: market.zip,
|
||||
country: market.country,
|
||||
latitude: market.latitude,
|
||||
longitude: market.longitude,
|
||||
start_date: market.start_date,
|
||||
end_date: market.end_date,
|
||||
image_url: market.image_url,
|
||||
logo_url: market.logo_url,
|
||||
organizer_name: market.organizer_name
|
||||
}
|
||||
]}
|
||||
class="border-rule-soft h-[280px] border"
|
||||
/>
|
||||
{#if market.street}
|
||||
<p class="text-ink-muted mt-3 font-serif text-[13px] italic">
|
||||
{market.street}<br />{market.zip}
|
||||
{market.city}
|
||||
</p>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MarketFeedbackDialog
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<h1>Markt einreichen</h1>
|
||||
<p class="mt-2 text-stone-600 dark:text-stone-400">
|
||||
<p class="text-ink-muted mt-2 font-serif">
|
||||
Kennst du einen Mittelaltermarkt, der noch nicht bei Marktvogt gelistet ist? Reiche ihn hier ein
|
||||
und wir prüfen die Angaben.
|
||||
</p>
|
||||
@@ -47,10 +47,8 @@
|
||||
<MarketForm {loading} error={form?.error} mode="public">
|
||||
{#snippet extraFields()}
|
||||
<fieldset class="space-y-4">
|
||||
<legend class="text-lg font-semibold text-stone-800 dark:text-stone-100">
|
||||
Deine Kontaktdaten
|
||||
</legend>
|
||||
<p class="text-sm text-stone-500 dark:text-stone-400">
|
||||
<legend class="text-ink font-serif text-[18px] font-[500]"> Deine Kontaktdaten </legend>
|
||||
<p class="text-ink-muted font-serif text-sm">
|
||||
Werden nicht veröffentlicht. Nur für Rückfragen.
|
||||
</p>
|
||||
|
||||
@@ -58,7 +56,7 @@
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="submitter_name"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Dein Name *
|
||||
</label>
|
||||
@@ -68,15 +66,12 @@
|
||||
type="text"
|
||||
required
|
||||
value={form?.submitterName ?? ''}
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="submitter_email"
|
||||
class="block text-sm font-medium text-stone-700 dark:text-stone-200"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Deine E-Mail *
|
||||
</label>
|
||||
@@ -86,9 +81,6 @@
|
||||
type="email"
|
||||
required
|
||||
value={form?.submitterEmail ?? ''}
|
||||
class="bg-vellum focus:border-primary-500 focus:ring-primary-500 w-full rounded-lg border border-stone-300 px-3 py-2
|
||||
text-sm shadow-sm focus:ring-2 focus:outline-none
|
||||
dark:border-stone-600 dark:bg-stone-800"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Input from '$lib/components/ui/Input.svelte';
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { data, form } = $props();
|
||||
@@ -13,143 +14,223 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profil - Marktvogt</title>
|
||||
<title>Profil — Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6">
|
||||
<h1 class="mb-8 text-2xl font-bold text-stone-900 dark:text-stone-100">Profil</h1>
|
||||
<div class="mx-auto max-w-[760px] px-10 py-12">
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(28px,4vw,44px)] leading-[0.95] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Profil
|
||||
</h1>
|
||||
|
||||
<div class="space-y-8">
|
||||
<!-- Profile info -->
|
||||
<div class="bg-vellum rounded-lg border border-stone-200 p-6 shadow-sm dark:border-stone-700">
|
||||
<h2 class="mb-4 text-lg font-semibold text-stone-900 dark:text-stone-100">
|
||||
Kontoinformationen
|
||||
</h2>
|
||||
<Rule kind="thin" class="my-8" />
|
||||
|
||||
{#if form?.success}
|
||||
<Alert variant="success">{form.success}</Alert>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<Alert variant="error">{form.error}</Alert>
|
||||
{/if}
|
||||
<!-- Kontoinformationen -->
|
||||
<section class="border-rule-soft bg-surface border p-8">
|
||||
<Caps class="mb-6">Kontoinformationen</Caps>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
updateLoading = true;
|
||||
return async ({ update }) => {
|
||||
updateLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="mt-4 space-y-4"
|
||||
{#if form?.success}
|
||||
<div class="mb-6"><Alert variant="success">{form.success}</Alert></div>
|
||||
{/if}
|
||||
{#if form?.error}
|
||||
<div class="mb-6"><Alert variant="error">{form.error}</Alert></div>
|
||||
{/if}
|
||||
|
||||
<div class="mb-6">
|
||||
<span class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>E-Mail</span
|
||||
>
|
||||
<div class="text-sm text-stone-500 dark:text-stone-400">
|
||||
<span class="font-medium text-stone-700 dark:text-stone-200">E-Mail:</span>
|
||||
{data.profile.email}
|
||||
</div>
|
||||
<span class="text-ink mt-1 block font-serif text-[16px]">{data.profile.email}</span>
|
||||
</div>
|
||||
|
||||
<Input name="display_name" label="Anzeigename" value={data.profile.display_name} required />
|
||||
<form
|
||||
method="POST"
|
||||
action="?/update"
|
||||
use:enhance={() => {
|
||||
updateLoading = true;
|
||||
return async ({ update }) => {
|
||||
updateLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-5"
|
||||
>
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="display_name"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Anzeigename
|
||||
</label>
|
||||
<input
|
||||
id="display_name"
|
||||
name="display_name"
|
||||
type="text"
|
||||
required
|
||||
value={data.profile.display_name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="avatar_url"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Avatar-URL
|
||||
</label>
|
||||
<input
|
||||
id="avatar_url"
|
||||
name="avatar_url"
|
||||
label="Avatar-URL"
|
||||
type="url"
|
||||
value={data.profile.avatar_url}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" loading={updateLoading}>Speichern</Button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="border-rule-soft flex items-center gap-3 border-t pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={updateLoading}
|
||||
class="border-ink bg-ink text-bg flex items-center gap-2 px-5 py-2.5 font-serif text-[14px] font-[500] disabled:opacity-50"
|
||||
>
|
||||
{#if updateLoading}<Spinner size={14} />{/if}
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<!-- Security -->
|
||||
<div class="bg-vellum rounded-lg border border-stone-200 p-6 shadow-sm dark:border-stone-700">
|
||||
<h2 class="mb-4 text-lg font-semibold text-stone-900 dark:text-stone-100">Sicherheit</h2>
|
||||
<Rule kind="thin" class="my-6" />
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<h3 class="mb-3 text-sm font-semibold text-stone-800 dark:text-stone-200">
|
||||
{data.profile.has_password ? 'Passwort ändern' : 'Passwort festlegen'}
|
||||
</h3>
|
||||
<!-- Sicherheit -->
|
||||
<section class="border-rule-soft bg-surface border p-8">
|
||||
<Caps class="mb-6">Sicherheit</Caps>
|
||||
|
||||
<!-- Password -->
|
||||
<h3 class="text-ink mb-4 font-serif text-[16px] font-[500]">
|
||||
{data.profile.has_password ? 'Passwort ändern' : 'Passwort festlegen'}
|
||||
</h3>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action="?/password"
|
||||
use:enhance={() => {
|
||||
passwordLoading = true;
|
||||
return async ({ update }) => {
|
||||
passwordLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-5"
|
||||
>
|
||||
{#if data.profile.has_password}
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="current_password"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Aktuelles Passwort
|
||||
</label>
|
||||
<input id="current_password" name="current_password" type="password" required />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="new_password"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Neues Passwort
|
||||
</label>
|
||||
<input id="new_password" name="new_password" type="password" required />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1">
|
||||
<label
|
||||
for="confirm_password"
|
||||
class="text-ink-muted block font-mono text-[10px] tracking-[0.15em] uppercase"
|
||||
>
|
||||
Passwort bestätigen
|
||||
</label>
|
||||
<input id="confirm_password" name="confirm_password" type="password" required />
|
||||
</div>
|
||||
|
||||
<div class="border-rule-soft flex items-center gap-3 border-t pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={passwordLoading}
|
||||
class="border-ink bg-ink text-bg flex items-center gap-2 px-5 py-2.5 font-serif text-[14px] font-[500] disabled:opacity-50"
|
||||
>
|
||||
{#if passwordLoading}<Spinner size={14} />{/if}
|
||||
{data.profile.has_password ? 'Passwort ändern' : 'Passwort festlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<Rule kind="thin" class="my-6" />
|
||||
|
||||
<!-- 2FA link -->
|
||||
<a
|
||||
href="/profile/security"
|
||||
class="text-accent font-mono text-[10px] tracking-[0.15em] uppercase hover:underline"
|
||||
>
|
||||
Zwei-Faktor-Authentifizierung verwalten →
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<Rule kind="thin" class="my-6" />
|
||||
|
||||
<!-- Danger zone -->
|
||||
<section class="border-rule-soft border-l-accent bg-surface border border-l-2 p-8">
|
||||
<Caps color="var(--color-accent)" class="mb-4">Konto löschen</Caps>
|
||||
<p class="text-ink-soft mb-6 font-serif text-[15px] leading-[1.65]">
|
||||
Dein Konto wird deaktiviert und nach 30 Tagen endgültig gelöscht.
|
||||
</p>
|
||||
|
||||
{#if !showDeleteConfirm}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDeleteConfirm = true)}
|
||||
class="border-accent text-accent border px-5 py-2.5 font-serif text-[14px] font-[500]"
|
||||
>
|
||||
Konto löschen
|
||||
</button>
|
||||
{:else}
|
||||
<div class="border-rule-soft bg-surface-alt border p-6">
|
||||
<p class="text-ink mb-5 font-serif text-[15px] font-[500]">
|
||||
Bist du sicher? Diese Aktion kann innerhalb von 30 Tagen rückgängig gemacht werden.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/password"
|
||||
action="?/delete"
|
||||
use:enhance={() => {
|
||||
passwordLoading = true;
|
||||
deleteLoading = true;
|
||||
return async ({ update }) => {
|
||||
passwordLoading = false;
|
||||
deleteLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
{#if data.profile.has_password}
|
||||
<Input name="current_password" label="Aktuelles Passwort" type="password" required />
|
||||
{/if}
|
||||
|
||||
<Input name="new_password" label="Neues Passwort" type="password" required />
|
||||
|
||||
<Input name="confirm_password" label="Passwort bestätigen" type="password" required />
|
||||
|
||||
<Button type="submit" loading={passwordLoading}>
|
||||
{data.profile.has_password ? 'Passwort ändern' : 'Passwort festlegen'}
|
||||
</Button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={deleteLoading}
|
||||
class="border-accent bg-accent text-on-accent flex items-center gap-2 border px-5 py-2.5 font-serif text-[14px] font-[500] disabled:opacity-50"
|
||||
>
|
||||
{#if deleteLoading}<Spinner size={14} />{/if}
|
||||
Endgültig löschen
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 2FA -->
|
||||
<div class="border-t border-stone-200 pt-4 dark:border-stone-700">
|
||||
<a
|
||||
href="/profile/security"
|
||||
class="text-primary-600 hover:text-primary-700 dark:text-primary-400 dark:hover:text-primary-300 text-sm font-medium"
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDeleteConfirm = false)}
|
||||
class="border-rule-soft text-ink-muted hover:text-ink border px-5 py-2.5 font-serif text-[14px]"
|
||||
>
|
||||
Zwei-Faktor-Authentifizierung verwalten
|
||||
</a>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Danger zone -->
|
||||
<div class="border-danger-200 bg-vellum dark:border-danger-800 rounded-lg border p-6 shadow-sm">
|
||||
<h2 class="text-danger-600 dark:text-danger-400 mb-4 text-lg font-semibold">Konto löschen</h2>
|
||||
<p class="mb-4 text-sm text-stone-600 dark:text-stone-300">
|
||||
Dein Konto wird deaktiviert und nach 30 Tagen endgültig gelöscht.
|
||||
</p>
|
||||
|
||||
{#if !showDeleteConfirm}
|
||||
<Button variant="danger" onclick={() => (showDeleteConfirm = true)}>Konto löschen</Button>
|
||||
{:else}
|
||||
<div
|
||||
class="border-danger-200 bg-danger-50 dark:border-danger-800 dark:bg-danger-950 rounded-lg border p-4"
|
||||
>
|
||||
<p class="text-danger-800 dark:text-danger-200 mb-4 text-sm font-medium">
|
||||
Bist du sicher? Diese Aktion kann innerhalb von 30 Tagen rückgängig gemacht werden.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/delete"
|
||||
use:enhance={() => {
|
||||
deleteLoading = true;
|
||||
return async ({ update }) => {
|
||||
deleteLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" variant="danger" loading={deleteLoading}>
|
||||
Endgültig löschen
|
||||
</Button>
|
||||
</form>
|
||||
<Button variant="secondary" onclick={() => (showDeleteConfirm = false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script lang="ts">
|
||||
import Button from '$lib/components/ui/Button.svelte';
|
||||
import Caps from '$lib/components/atoms/Caps.svelte';
|
||||
import Rule from '$lib/components/atoms/Rule.svelte';
|
||||
import Alert from '$lib/components/ui/Alert.svelte';
|
||||
import Spinner from '$lib/components/ui/Spinner.svelte';
|
||||
import TOTPSetup from '$lib/components/auth/TOTPSetup.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
@@ -12,92 +14,113 @@
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Sicherheit - Marktvogt</title>
|
||||
<title>Sicherheit — Marktvogt</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mx-auto max-w-2xl px-4 py-8 sm:px-6">
|
||||
<div class="mb-6">
|
||||
<div class="mx-auto max-w-[760px] px-10 py-12">
|
||||
<nav class="mb-8">
|
||||
<a
|
||||
href="/profile"
|
||||
class="text-sm text-stone-500 hover:text-stone-700 dark:text-stone-400 dark:hover:text-stone-200"
|
||||
class="text-ink-muted hover:text-ink font-mono text-[10px] tracking-[0.12em] uppercase"
|
||||
>
|
||||
← Zurück zum Profil
|
||||
‹ Zurück zum Profil
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<h1 class="mb-8 text-2xl font-bold text-stone-900 dark:text-stone-100">
|
||||
<h1
|
||||
class="font-display text-ink text-[clamp(28px,4vw,44px)] leading-[0.95] font-[500] tracking-[-0.01em]"
|
||||
>
|
||||
Zwei-Faktor-Authentifizierung
|
||||
</h1>
|
||||
|
||||
<div class="bg-vellum rounded-lg border border-stone-200 p-6 shadow-sm dark:border-stone-700">
|
||||
<Rule kind="thin" class="my-8" />
|
||||
|
||||
<section class="border-rule-soft bg-surface border p-8">
|
||||
{#if form?.success}
|
||||
<Alert variant="success">{form.success}</Alert>
|
||||
<div class="mb-6"><Alert variant="success">{form.success}</Alert></div>
|
||||
{/if}
|
||||
|
||||
{#if form?.totpSecret}
|
||||
<TOTPSetup secret={form.totpSecret} url={form.totpUrl} error={form?.error} />
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-stone-600 dark:text-stone-300">
|
||||
Schütze dein Konto mit einer Authenticator-App (z.B. Google Authenticator, Authy).
|
||||
</p>
|
||||
<Caps class="mb-6">Authenticator-App</Caps>
|
||||
<p class="text-ink-soft mb-6 font-serif text-[15px] leading-[1.65]">
|
||||
Schütze dein Konto mit einer Authenticator-App (z.B. Google Authenticator, Authy).
|
||||
</p>
|
||||
|
||||
<div class="flex gap-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setup"
|
||||
use:enhance={() => {
|
||||
setupLoading = true;
|
||||
return async ({ update }) => {
|
||||
setupLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/setup"
|
||||
use:enhance={() => {
|
||||
setupLoading = true;
|
||||
return async ({ update }) => {
|
||||
setupLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={setupLoading}
|
||||
class="border-ink bg-ink text-bg flex items-center gap-2 px-5 py-2.5 font-serif text-[14px] font-[500] disabled:opacity-50"
|
||||
>
|
||||
<Button type="submit" loading={setupLoading}>2FA einrichten</Button>
|
||||
</form>
|
||||
{#if setupLoading}<Spinner size={14} />{/if}
|
||||
2FA einrichten
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{#if !showDisableConfirm}
|
||||
<Button variant="secondary" onclick={() => (showDisableConfirm = true)}>
|
||||
2FA deaktivieren
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showDisableConfirm}
|
||||
<div
|
||||
class="border-danger-200 bg-danger-50 dark:border-danger-800 dark:bg-danger-950 rounded-lg border p-4"
|
||||
{#if !showDisableConfirm}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDisableConfirm = true)}
|
||||
class="border-rule-soft text-ink-muted hover:text-ink border px-5 py-2.5 font-serif text-[14px]"
|
||||
>
|
||||
<p class="text-danger-800 dark:text-danger-200 mb-3 text-sm">
|
||||
Bist du sicher? Dein Konto wird weniger sicher sein.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/disable"
|
||||
use:enhance={() => {
|
||||
disableLoading = true;
|
||||
return async ({ update }) => {
|
||||
disableLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<Button type="submit" variant="danger" loading={disableLoading}>
|
||||
Deaktivieren
|
||||
</Button>
|
||||
</form>
|
||||
<Button variant="secondary" onclick={() => (showDisableConfirm = false)}>
|
||||
Abbrechen
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error && !form?.totpSecret}
|
||||
<Alert variant="error">{form.error}</Alert>
|
||||
2FA deaktivieren
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showDisableConfirm}
|
||||
<div class="border-rule-soft bg-surface-alt mt-6 border p-6">
|
||||
<p class="text-ink mb-5 font-serif text-[15px]">
|
||||
Bist du sicher? Dein Konto wird weniger sicher sein.
|
||||
</p>
|
||||
<div class="flex gap-3">
|
||||
<form
|
||||
method="POST"
|
||||
action="?/disable"
|
||||
use:enhance={() => {
|
||||
disableLoading = true;
|
||||
return async ({ update }) => {
|
||||
disableLoading = false;
|
||||
await update();
|
||||
};
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={disableLoading}
|
||||
class="border-accent bg-accent text-on-accent flex items-center gap-2 border px-5 py-2.5 font-serif text-[14px] font-[500] disabled:opacity-50"
|
||||
>
|
||||
{#if disableLoading}<Spinner size={14} />{/if}
|
||||
Deaktivieren
|
||||
</button>
|
||||
</form>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (showDisableConfirm = false)}
|
||||
class="border-rule-soft text-ink-muted hover:text-ink border px-5 py-2.5 font-serif text-[14px]"
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form?.error && !form?.totpSecret}
|
||||
<div class="mt-4"><Alert variant="error">{form.error}</Alert></div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
|
Before Width: | Height: | Size: 7.1 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 3.7 KiB |
@@ -1,10 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<circle cx="16" cy="16" r="14.5" fill="#1a3d24" stroke="#c4952e" stroke-width="2"/>
|
||||
<g transform="translate(16,15) scale(0.21)" fill="#d4a63a">
|
||||
<ellipse cx="0" cy="-24" rx="6.5" ry="25"/>
|
||||
<ellipse cx="19" cy="-13" rx="6" ry="20" transform="rotate(42, 19, -13)"/>
|
||||
<ellipse cx="-19" cy="-13" rx="6" ry="20" transform="rotate(-42, -19, -13)"/>
|
||||
<rect x="-24" y="-3" width="48" height="8" rx="2"/>
|
||||
<path d="M-8,5 L-9,46 C-9,56 9,56 9,46 L8,5 Z"/>
|
||||
</g>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="400" height="460" viewBox="0 0 40 46">
|
||||
<path d="M2 2 L38 2 L38 26 C38 36 30 42 20 44 C10 42 2 36 2 26 Z" fill="none" stroke="#9a1e2c" stroke-width="2" stroke-linejoin="round"/>
|
||||
<path d="M9 32 L9 14 L14 14 L20 24 L26 14 L31 14 L31 32 L27 32 L27 22 L22 30 L18 30 L13 22 L13 32 Z" fill="#9a1e2c"/>
|
||||
<circle cx="20" cy="9" r="1.5" fill="#9a1e2c"/>
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 541 B After Width: | Height: | Size: 403 B |