diff --git a/backend/.golangci.yml b/backend/.golangci.yml index 057e721..c61f9b6 100644 --- a/backend/.golangci.yml +++ b/backend/.golangci.yml @@ -17,6 +17,7 @@ linters: - nolintlint - prealloc - rowserrcheck + - sloglint - sqlclosecheck - staticcheck - tparallel @@ -24,6 +25,11 @@ linters: - unparam - whitespace - wrapcheck + settings: + sloglint: + no-mixed-args: true + context: scope + static-msg: true exclusions: rules: - text: '(slog|log)\.\w+' diff --git a/backend/cmd/api/main.go b/backend/cmd/api/main.go index c2db585..44e98e1 100644 --- a/backend/cmd/api/main.go +++ b/backend/cmd/api/main.go @@ -3,12 +3,17 @@ package main import ( "context" "encoding/json" + "errors" "log/slog" "net/http" "os" + "os/signal" "strings" + "syscall" "time" + "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" + "github.com/umekikazuya/me/internal/app/eventhandler" "github.com/umekikazuya/me/internal/app/identity" appme "github.com/umekikazuya/me/internal/app/me" @@ -18,27 +23,65 @@ import ( infraevent "github.com/umekikazuya/me/internal/infra/event" "github.com/umekikazuya/me/internal/infra/token" "github.com/umekikazuya/me/pkg/middleware" - "github.com/umekikazuya/me/pkg/slogx" + "github.com/umekikazuya/me/pkg/obs" ) +// shutdownTimeout は SIGINT/SIGTERM 受信後にインフライトリクエストを捌き切る猶予。 +// ALB / API Gateway のドレイン時間との整合を意識して設定する。 +const shutdownTimeout = 30 * time.Second + func main() { - ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) - defer cancel() + // run に集約するのは os.Exit が defer をスキップするため。 + // 直接 main で os.Exit すると obs の shutdown が走らず traces/metrics が flush されない。 + os.Exit(run()) +} - // ロガー初期化 - slog.SetDefault(slogx.New(os.Stdout)) +func run() int { + ctx := context.Background() + + // 観測性基盤初期化: logs / traces / metrics を stdout に出す。 + // アクセスログはインフラ層 (API Gateway/ALB 等) の責務とし、アプリでは出さない。 + prov, shutdown, err := obs.Bootstrap(ctx, obs.Config{ + ServiceName: "api", + Level: obs.ParseLevel(os.Getenv("LOG_LEVEL")), + SensitiveKeys: []string{"password", "password_hash", "authorization", "cookie", "set-cookie", "token", "refresh_token"}, + AddSource: true, + EnableTraces: true, + EnableMetrics: true, + }) + if err != nil { + slog.Error("観測性基盤の初期化に失敗しました", "error", err) + return 1 + } + // shutdown は長寿命な ctx に縛られないよう、呼び出し時に bounded な context を渡す。 + // ListenAndServe から戻った時点では元 ctx が cancel 済みの可能性があり、 + // その場合 tracer/meter の flush が即座に諦められてしまう。 + // signal 受信時に作成した共有 shutdownCtx を使うことで、HTTP と observability の + // shutdown が同一タイムアウト予算を共有し、合計で shutdownTimeout 以内に収まる。 + var shutdownCtx context.Context + var shutdownCancel context.CancelFunc + defer func() { + if shutdownCtx == nil { + shutdownCtx, shutdownCancel = context.WithTimeout(context.Background(), 5*time.Second) + } + _ = shutdown(shutdownCtx) + if shutdownCancel != nil { + shutdownCancel() + } + }() + slog.SetDefault(prov.Logger) // 具像実装の初期化 meRepo, identityRepo, sessionRepo, articleInteractor, err := setupRepo(ctx) if err != nil { slog.Error("インフラの初期化に失敗しました", "error", err) - os.Exit(1) + return 1 } articleHandler := handlerarticle.NewHandler(articleInteractor) jwtSecret := strings.TrimSpace(os.Getenv("JWT_SECRET")) if jwtSecret == "" { slog.Error("JWT_SECRET が未設定です") - os.Exit(1) + return 1 } tokenSrv := token.NewJWTTokenService( jwtSecret, @@ -129,19 +172,68 @@ func main() { ), )) - slog.Info("サーバーを起動します") - // サーバー起動 + // middleware chain (外側 → 内側): + // RequestID (obs.WithRequestID で context に積む) + // → otelhttp (root span を作成、trace_id を context に載せる) + // → Recover (panic → 500 ProblemDetail + ERROR ログ、trace_id が自動で付く) + // → router srv := &http.Server{ - Addr: ":8080", - Handler: middleware.RequestID(middleware.Logging(r)), + Addr: ":8080", + Handler: middleware.RequestID( + otelhttp.NewHandler( + middleware.Recover(r), + "api", + ), + ), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 10 * time.Second, WriteTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, } - if err := srv.ListenAndServe(); err != nil { + + // signal 受信準備を ListenAndServe の goroutine 起動より前に行う。 + // 先に goroutine を起動すると、早期の SIGINT/SIGTERM がデフォルトハンドラで + // 処理され、graceful shutdown が実行されないリスクがある。 + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(sigCh) + + // ListenAndServe は別 goroutine で動かし、main は signal 受信を待つ。 + // 起動直後に失敗 (port 競合など) した場合は srvErrCh から即座に戻る。 + srvErrCh := make(chan error, 1) + go func() { + srvErrCh <- srv.ListenAndServe() + }() + + slog.Info("サーバーを起動します") + + select { + case err := <-srvErrCh: + // SIGINT/SIGTERM を受ける前に ListenAndServe が返った = 起動失敗。 + // Shutdown 経由の終了ではないので ErrServerClosed は来ない想定だが、念のため除外。 + if err != nil && !errors.Is(err, http.ErrServerClosed) { + slog.Error("起動エラー", "error", err) + return 1 + } + return 0 + case sig := <-sigCh: + slog.Info("シャットダウン開始", "signal", sig.String()) + } + + // HTTP shutdown と observability shutdown の両方で共有する単一の context を作成。 + // 合計で shutdownTimeout 以内に両方の shutdown を完了させる。 + shutdownCtx, shutdownCancel = context.WithTimeout(context.Background(), shutdownTimeout) + if err := srv.Shutdown(shutdownCtx); err != nil { + // Shutdown が猶予内に完了できなかった = インフライトが残っている。 + // それでも ListenAndServe は Close 済みで戻るので、goroutine は解放される。 + slog.Error("サーバーシャットダウン失敗", "error", err) + } + // Shutdown 後は ListenAndServe が ErrServerClosed で戻る。goroutine のクローズ待ち。 + if err := <-srvErrCh; err != nil && !errors.Is(err, http.ErrServerClosed) { slog.Error("起動エラー", "error", err) - os.Exit(1) // TODO: SIGINT/SIGTERM グレースフルシャットダウン + return 1 } + slog.Info("サーバーを停止しました") + return 0 } diff --git a/backend/cmd/batch/local.go b/backend/cmd/batch/local.go index 2aefc92..24fc90b 100644 --- a/backend/cmd/batch/local.go +++ b/backend/cmd/batch/local.go @@ -4,6 +4,7 @@ import ( "context" "log/slog" "os" + "time" "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" @@ -14,14 +15,37 @@ import ( "github.com/umekikazuya/me/internal/infra/db" "github.com/umekikazuya/me/internal/infra/fetcher" "github.com/umekikazuya/me/internal/infra/tokenizer" + "github.com/umekikazuya/me/pkg/obs" ) var targetPlatforms = []string{"qiita", "zenn"} func main() { - slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil))) ctx := context.Background() + prov, shutdown, err := obs.Bootstrap(ctx, obs.Config{ + ServiceName: "batch", + Level: obs.ParseLevel(os.Getenv("LOG_LEVEL")), + AddSource: true, + EnableTraces: true, + EnableMetrics: true, + }) + if err != nil { + slog.Error("観測性基盤の初期化に失敗しました", "error", err) + os.Exit(1) + } + // shutdown は bounded な context で呼ぶ。呼び出し時点で ctx が cancel + // されていると tracer/meter の flush が即座に諦められてしまう。 + defer func() { + shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + _ = shutdown(shutdownCtx) + }() + slog.SetDefault(prov.Logger) + // RecoverProcess はログ出力後に re-panic するので、batch のプロセス終了は + // 非ゼロ exit になる (runtime のデフォルト panic ハンドラが exit 2 を返す)。 + defer obs.RecoverProcess(ctx, "batch.main") + // TODO: パラメータを注入する機構を考える(実行環境も) endpoint := os.Getenv("DYNAMODB_ENDPOINT") tableName := os.Getenv("DYNAMODB_TABLE_NAME") @@ -93,3 +117,4 @@ func main() { os.Exit(1) } } + diff --git a/backend/go.mod b/backend/go.mod index 5d39f3f..3b62295 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -30,12 +30,25 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.20 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 // indirect github.com/aws/smithy-go v1.25.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/ikawaha/kagome-dict v1.1.7 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/stretchr/testify v1.9.0 // indirect + github.com/stretchr/testify v1.11.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/sdk v1.43.0 // indirect + go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 79228da..eaac6ac 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -34,10 +34,19 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.42.0 h1:ks8KBcZPh3PYISr5dAiXCM5/Thcu github.com/aws/aws-sdk-go-v2/service/sts v1.42.0/go.mod h1:pFw33T0WLvXU3rw1WBkpMlkgIn54eCB5FYLhjDc9Foo= github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U= github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= @@ -60,8 +69,26 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= diff --git a/backend/internal/app/article/interactor.go b/backend/internal/app/article/interactor.go index 679c04b..1fc6207 100644 --- a/backend/internal/app/article/interactor.go +++ b/backend/internal/app/article/interactor.go @@ -59,7 +59,7 @@ func (i *interactor) Search(ctx context.Context, input InputSearchDto) (*OutputS } result, err := i.repo.FindAll(ctx, criteria) if err != nil { - return nil, errs.WrapInternal(ctx, "article.repo.FindAll", err) + return nil, errs.WrapInternal("article.repo.FindAll", err) } items := make([]OutputArticleItemDto, 0, len(result.Articles)) @@ -84,7 +84,7 @@ func (i *interactor) Search(ctx context.Context, input InputSearchDto) (*OutputS func (i *interactor) GetTagsAll(ctx context.Context) (*OutputTagAllDto, error) { tags, err := i.repo.AllTags(ctx) if err != nil { - return nil, errs.WrapInternal(ctx, "article.repo.AllTags", err) + return nil, errs.WrapInternal("article.repo.AllTags", err) } items := make([]OutputTagItemDto, 0, len(tags)) @@ -97,11 +97,11 @@ func (i *interactor) GetTagsAll(ctx context.Context) (*OutputTagAllDto, error) { func (i *interactor) GetSuggests(ctx context.Context, input InputGetSuggestDto) (*OutputGetSuggestAllDto, error) { tags, err := i.repo.AllTags(ctx) if err != nil { - return nil, errs.WrapInternal(ctx, "article.repo.AllTags", err) + return nil, errs.WrapInternal("article.repo.AllTags", err) } tokens, err := i.repo.AllTokens(ctx) if err != nil { - return nil, errs.WrapInternal(ctx, "article.repo.AllTokens", err) + return nil, errs.WrapInternal("article.repo.AllTokens", err) } var suggestions []OutputGetSuggestItemDto @@ -135,7 +135,7 @@ func (i *interactor) GetSuggests(ctx context.Context, input InputGetSuggestDto) func (i *interactor) Register(ctx context.Context, input InputRegisterDto) error { existing, err := i.repo.FindByExternalID(ctx, input.ExternalID) if err != nil { - return errs.WrapInternal(ctx, "article.repo.FindByExternalID", err) + return errs.WrapInternal("article.repo.FindByExternalID", err) } if existing != nil { return fmt.Errorf("article already exists: %s: %w", input.ExternalID, errs.ErrConflict) @@ -157,7 +157,7 @@ func (i *interactor) Register(ctx context.Context, input InputRegisterDto) error return fmt.Errorf("%s: %w", err.Error(), errs.ErrUnprocessable) } if err := i.repo.Save(ctx, article); err != nil { - return errs.WrapInternal(ctx, "article.repo.Save", err) + return errs.WrapInternal("article.repo.Save", err) } return nil } @@ -165,7 +165,7 @@ func (i *interactor) Register(ctx context.Context, input InputRegisterDto) error func (i *interactor) Update(ctx context.Context, input InputUpdateDto) error { article, err := i.repo.FindByExternalID(ctx, input.ExternalID) if err != nil { - return errs.WrapInternal(ctx, "article.repo.FindByExternalID", err) + return errs.WrapInternal("article.repo.FindByExternalID", err) } if article == nil { return fmt.Errorf("article not found: %s: %w", input.ExternalID, errs.ErrNotFound) @@ -186,7 +186,7 @@ func (i *interactor) Update(ctx context.Context, input InputUpdateDto) error { return fmt.Errorf("%s: %w", err.Error(), errs.ErrUnprocessable) } if err := i.repo.Save(ctx, article); err != nil { - return errs.WrapInternal(ctx, "article.repo.Save", err) + return errs.WrapInternal("article.repo.Save", err) } return nil } @@ -194,7 +194,7 @@ func (i *interactor) Update(ctx context.Context, input InputUpdateDto) error { func (i *interactor) Remove(ctx context.Context, input InputRemoveDto) error { article, err := i.repo.FindByExternalID(ctx, input.ExternalID) if err != nil { - return errs.WrapInternal(ctx, "article.repo.FindByExternalID", err) + return errs.WrapInternal("article.repo.FindByExternalID", err) } if article == nil { return fmt.Errorf("article not found: %s: %w", input.ExternalID, errs.ErrNotFound) @@ -204,7 +204,7 @@ func (i *interactor) Remove(ctx context.Context, input InputRemoveDto) error { return fmt.Errorf("%s: %w", err.Error(), errs.ErrUnprocessable) } if err := i.repo.Save(ctx, article); err != nil { - return errs.WrapInternal(ctx, "article.repo.Save", err) + return errs.WrapInternal("article.repo.Save", err) } return nil } diff --git a/backend/internal/app/identity/interactor.go b/backend/internal/app/identity/interactor.go index cab69c3..800df25 100644 --- a/backend/internal/app/identity/interactor.go +++ b/backend/internal/app/identity/interactor.go @@ -53,7 +53,7 @@ func NewInteractor( func (i *interactor) ChangeEmail(ctx context.Context, input InputChangeEmailDto) error { idn, err := i.identityRepo.FindByID(ctx, input.ID) if err != nil { - return errs.WrapInternal(ctx, "identity.identityRepo.FindByID", err) + return errs.WrapInternal("identity.identityRepo.FindByID", err) } if idn == nil { return fmt.Errorf("ChangeEmail: %w", errs.ErrNotFound) @@ -65,7 +65,7 @@ func (i *interactor) ChangeEmail(ctx context.Context, input InputChangeEmailDto) } exists, err := i.identityRepo.FindByEmail(ctx, newEmail.Value()) if err != nil { - return errs.WrapInternal(ctx, "identity.identityRepo.FindByEmail", err) + return errs.WrapInternal("identity.identityRepo.FindByEmail", err) } if exists != nil { return fmt.Errorf("ChangeEmail: %w", errs.ErrConflict) @@ -76,10 +76,10 @@ func (i *interactor) ChangeEmail(ctx context.Context, input InputChangeEmailDto) } err = i.identityRepo.Save(ctx, idn) if err != nil { - return errs.WrapInternal(ctx, "identity.identityRepo.Save", err) + return errs.WrapInternal("identity.identityRepo.Save", err) } if err = i.dispatcher.Dispatch(ctx, idn.Events()); err != nil { - return errs.WrapInternal(ctx, "identity.dispatcher.Dispatch", err) + return errs.WrapInternal("identity.dispatcher.Dispatch", err) } idn.ClearEvents() return nil @@ -94,7 +94,7 @@ func (i *interactor) Login(ctx context.Context, input InputLoginDto) (*OutputLog // 入力されたメールアドレスでアカウントを検索 idn, err := i.identityRepo.FindByEmail(ctx, email.Value()) if err != nil { - return nil, errs.WrapInternal(ctx, "identity.identityRepo.FindByEmail", err) + return nil, errs.WrapInternal("identity.identityRepo.FindByEmail", err) } if idn == nil { return nil, fmt.Errorf("Login: %w", errs.ErrNotFound) @@ -107,15 +107,15 @@ func (i *interactor) Login(ctx context.Context, input InputLoginDto) (*OutputLog at, err := i.tokenSrv.GenerateAT(ctx, *idn) if err != nil { - return nil, errs.WrapInternal(ctx, "identity.tokenSrv.GenerateAT", err) + return nil, errs.WrapInternal("identity.tokenSrv.GenerateAT", err) } rt, err := i.tokenSrv.GenerateRT(ctx) if err != nil { - return nil, errs.WrapInternal(ctx, "identity.tokenSrv.GenerateRT", err) + return nil, errs.WrapInternal("identity.tokenSrv.GenerateRT", err) } hashedRT, err := i.tokenSrv.Hash(ctx, rt) if err != nil { - return nil, errs.WrapInternal(ctx, "identity.tokenSrv.Hash", err) + return nil, errs.WrapInternal("identity.tokenSrv.Hash", err) } ses, err := idn.CreateSession(hashedRT) if err != nil { @@ -123,10 +123,10 @@ func (i *interactor) Login(ctx context.Context, input InputLoginDto) (*OutputLog } err = i.sessionRepo.Save(ctx, ses) // TODO: アクティブセッション数の制限制御 if err != nil { - return nil, errs.WrapInternal(ctx, "identity.sessionRepo.Save", err) + return nil, errs.WrapInternal("identity.sessionRepo.Save", err) } if err = i.dispatcher.Dispatch(ctx, idn.Events()); err != nil { // TODO: 原子性の対応 - return nil, errs.WrapInternal(ctx, "identity.dispatcher.Dispatch", err) + return nil, errs.WrapInternal("identity.dispatcher.Dispatch", err) } idn.ClearEvents() @@ -139,18 +139,18 @@ func (i *interactor) Login(ctx context.Context, input InputLoginDto) (*OutputLog func (i *interactor) Logout(ctx context.Context, input InputLogoutDto) error { idn, err := i.identityRepo.FindByID(ctx, input.IdentityID) if err != nil { - return errs.WrapInternal(ctx, "identity.identityRepo.FindByID", err) + return errs.WrapInternal("identity.identityRepo.FindByID", err) } if idn == nil { return fmt.Errorf("Logout: %w", errs.ErrNotFound) } hashedRT, err := i.tokenSrv.Hash(ctx, input.RT) if err != nil { - return errs.WrapInternal(ctx, "identity.tokenSrv.Hash", err) + return errs.WrapInternal("identity.tokenSrv.Hash", err) } ses, err := i.sessionRepo.FindByIdentityIdAndTokenHash(ctx, idn.ID(), hashedRT) if err != nil { - return errs.WrapInternal(ctx, "identity.sessionRepo.FindByIdentityIdAndTokenHash", err) + return errs.WrapInternal("identity.sessionRepo.FindByIdentityIdAndTokenHash", err) } if ses == nil { return fmt.Errorf("Logout %w", errs.ErrNotFound) @@ -162,10 +162,10 @@ func (i *interactor) Logout(ctx context.Context, input InputLogoutDto) error { } err = i.sessionRepo.Save(ctx, ses) if err != nil { - return errs.WrapInternal(ctx, "identity.sessionRepo.Save", err) + return errs.WrapInternal("identity.sessionRepo.Save", err) } if err = i.dispatcher.Dispatch(ctx, ses.Events()); err != nil { - return errs.WrapInternal(ctx, "identity.dispatcher.Dispatch", err) + return errs.WrapInternal("identity.dispatcher.Dispatch", err) } ses.ClearEvents() return nil @@ -174,7 +174,7 @@ func (i *interactor) Logout(ctx context.Context, input InputLogoutDto) error { func (i *interactor) ResetPassword(ctx context.Context, input InputResetPasswordDto) error { idn, err := i.identityRepo.FindByID(ctx, input.ID) if err != nil { - return errs.WrapInternal(ctx, "identity.identityRepo.FindByID", err) + return errs.WrapInternal("identity.identityRepo.FindByID", err) } if idn == nil { return fmt.Errorf("ResetPassword: %w", errs.ErrNotFound) @@ -185,14 +185,14 @@ func (i *interactor) ResetPassword(ctx context.Context, input InputResetPassword } err = i.identityRepo.Save(ctx, idn) if err != nil { - return errs.WrapInternal(ctx, "identity.identityRepo.Save", err) + return errs.WrapInternal("identity.identityRepo.Save", err) } err = i.sessionRepo.RevokeAll(ctx, idn.ID()) if err != nil { - return errs.WrapInternal(ctx, "identity.sessionRepo.RevokeAll", err) + return errs.WrapInternal("identity.sessionRepo.RevokeAll", err) } if err = i.dispatcher.Dispatch(ctx, idn.Events()); err != nil { - return errs.WrapInternal(ctx, "identity.dispatcher.Dispatch", err) + return errs.WrapInternal("identity.dispatcher.Dispatch", err) } idn.ClearEvents() return nil @@ -201,39 +201,37 @@ func (i *interactor) ResetPassword(ctx context.Context, input InputResetPassword func (i *interactor) RefreshTokens(ctx context.Context, input InputRefreshTokensDto) (*OutputRefreshTokensDto, error) { idn, err := i.identityRepo.FindByID(ctx, input.IdentityID) if err != nil { - return nil, errs.WrapInternal(ctx, "identity.identityRepo.FindByID", err) + return nil, errs.WrapInternal("identity.identityRepo.FindByID", err) } if idn == nil { return nil, fmt.Errorf("RefreshTokens: %w", errs.ErrNotFound) } hashedRT, err := i.tokenSrv.Hash(ctx, input.RT) if err != nil { - return nil, errs.WrapInternal(ctx, "identity.tokenSrv.Hash", err) + return nil, errs.WrapInternal("identity.tokenSrv.Hash", err) } ses, err := i.sessionRepo.FindByIdentityIdAndTokenHash(ctx, idn.ID(), hashedRT) if err != nil { - return nil, errs.WrapInternal(ctx, "identity.sessionRepo.FindByIdentityIdAndTokenHash", err) + return nil, errs.WrapInternal("identity.sessionRepo.FindByIdentityIdAndTokenHash", err) } if ses == nil { return nil, fmt.Errorf("RefreshTokens: sessionが存在しません %w", errs.ErrNotFound) } - // TODO: IsActive or IsRevoke を実装する - // TODO: return e.Status() == "active" && time.Now().Before(e.ExpiresAt()) - if ses.Status() != "active" { + if !ses.IsActive() { return nil, fmt.Errorf("RefreshTokens: RTが失効済みです %w", errs.ErrUnprocessable) } newAT, err := i.tokenSrv.GenerateAT(ctx, *idn) if err != nil { - return nil, errs.WrapInternal(ctx, "identity.tokenSrv.GenerateAT", err) + return nil, errs.WrapInternal("identity.tokenSrv.GenerateAT", err) } newRT, err := i.tokenSrv.GenerateRT(ctx) if err != nil { - return nil, errs.WrapInternal(ctx, "identity.tokenSrv.GenerateRT", err) + return nil, errs.WrapInternal("identity.tokenSrv.GenerateRT", err) } newHashedRT, err := i.tokenSrv.Hash(ctx, newRT) if err != nil { - return nil, errs.WrapInternal(ctx, "identity.tokenSrv.Hash", err) + return nil, errs.WrapInternal("identity.tokenSrv.Hash", err) } newSes, err := ses.Rotate(newHashedRT) if err != nil { @@ -241,14 +239,14 @@ func (i *interactor) RefreshTokens(ctx context.Context, input InputRefreshTokens } err = i.sessionRepo.Save(ctx, ses) if err != nil { - return nil, errs.WrapInternal(ctx, "identity.sessionRepo.Save", err) + return nil, errs.WrapInternal("identity.sessionRepo.Save", err) } err = i.sessionRepo.Save(ctx, newSes) if err != nil { - return nil, errs.WrapInternal(ctx, "identity.sessionRepo.Save", err) + return nil, errs.WrapInternal("identity.sessionRepo.Save", err) } if err = i.dispatcher.Dispatch(ctx, ses.Events()); err != nil { - return nil, errs.WrapInternal(ctx, "identity.dispatcher.Dispatch", err) + return nil, errs.WrapInternal("identity.dispatcher.Dispatch", err) } ses.ClearEvents() @@ -266,7 +264,7 @@ func (i *interactor) Register(ctx context.Context, input InputRegisterDto) error } exists, err := i.identityRepo.FindByEmail(ctx, email.Value()) if err != nil { - return errs.WrapInternal(ctx, "identity.identityRepo.FindByEmail", err) + return errs.WrapInternal("identity.identityRepo.FindByEmail", err) } if exists != nil { return fmt.Errorf("Register: %w", errs.ErrConflict) @@ -280,10 +278,10 @@ func (i *interactor) Register(ctx context.Context, input InputRegisterDto) error } err = i.identityRepo.Save(ctx, e) if err != nil { - return errs.WrapInternal(ctx, "identity.identityRepo.Save", err) + return errs.WrapInternal("identity.identityRepo.Save", err) } if err = i.dispatcher.Dispatch(ctx, e.Events()); err != nil { - return errs.WrapInternal(ctx, "identity.dispatcher.Dispatch", err) + return errs.WrapInternal("identity.dispatcher.Dispatch", err) } e.ClearEvents() return nil @@ -292,14 +290,14 @@ func (i *interactor) Register(ctx context.Context, input InputRegisterDto) error func (i *interactor) RevokeAllSessions(ctx context.Context, input InputRevokeAllSessionsDto) error { idn, err := i.identityRepo.FindByID(ctx, input.IdentityID) if err != nil { - return errs.WrapInternal(ctx, "identity.identityRepo.FindByID", err) + return errs.WrapInternal("identity.identityRepo.FindByID", err) } if idn == nil { return fmt.Errorf("RevokeAllSessions: %w", errs.ErrNotFound) } err = i.sessionRepo.RevokeAll(ctx, idn.ID()) if err != nil { - return errs.WrapInternal(ctx, "identity.sessionRepo.RevokeAll", err) + return errs.WrapInternal("identity.sessionRepo.RevokeAll", err) } return nil } diff --git a/backend/internal/app/me/interactor.go b/backend/internal/app/me/interactor.go index 55bc6ba..1c5452b 100644 --- a/backend/internal/app/me/interactor.go +++ b/backend/internal/app/me/interactor.go @@ -32,7 +32,7 @@ func NewInteractor( func (i *interactor) Create(ctx context.Context, input InputDto) (*OutputDto, error) { exists, err := i.repo.Exists(ctx, input.ID) if err != nil { - return nil, errs.WrapInternal(ctx, "me.repo.Exists", err) + return nil, errs.WrapInternal("me.repo.Exists", err) } if exists { return nil, fmt.Errorf("create me: %w", errs.ErrConflict) @@ -84,7 +84,7 @@ func (i *interactor) Create(ctx context.Context, input InputDto) (*OutputDto, er err = i.repo.Save(ctx, e) if err != nil { - return nil, errs.WrapInternal(ctx, "me.repo.Save", err) + return nil, errs.WrapInternal("me.repo.Save", err) } return toOutputDto(*e), nil @@ -128,7 +128,7 @@ func (i *interactor) Update(ctx context.Context, input InputDto) (*OutputDto, er } e, err := i.repo.FindByID(ctx, input.ID) if err != nil { - return nil, errs.WrapInternal(ctx, "me.repo.FindByID", err) + return nil, errs.WrapInternal("me.repo.FindByID", err) } if e == nil { return nil, fmt.Errorf("update me: %w", errs.ErrNotFound) @@ -140,7 +140,7 @@ func (i *interactor) Update(ctx context.Context, input InputDto) (*OutputDto, er err = i.repo.Save(ctx, e) if err != nil { - return nil, errs.WrapInternal(ctx, "me.repo.Save", err) + return nil, errs.WrapInternal("me.repo.Save", err) } return toOutputDto(*e), nil @@ -149,7 +149,7 @@ func (i *interactor) Update(ctx context.Context, input InputDto) (*OutputDto, er func (i *interactor) Get(ctx context.Context, id string) (*OutputDto, error) { e, err := i.repo.FindByID(ctx, id) if err != nil { - return nil, errs.WrapInternal(ctx, "me.repo.FindByID", err) + return nil, errs.WrapInternal("me.repo.FindByID", err) } if e == nil { return nil, fmt.Errorf("get me: %w", errs.ErrNotFound) diff --git a/backend/internal/domain/identity/entity.go b/backend/internal/domain/identity/entity.go index d9ef829..9069c6d 100644 --- a/backend/internal/domain/identity/entity.go +++ b/backend/internal/domain/identity/entity.go @@ -196,6 +196,18 @@ func (e *Session) ExpiresAt() time.Time { return e.expiresAt } +// IsActive はセッションが「未失効 かつ 未期限切れ」なら true を返す。 +// 呼び出し側は Status() == "active" を直接見ず、必ずこのメソッドで判定する +// (status が active のまま expiresAt を過ぎているケースを取りこぼさないため)。 +func (e *Session) IsActive() bool { + return e.status.Value() == statusActive.Value() && time.Now().Before(e.expiresAt) +} + +// IsRevoked はセッションが revoked 状態なら true を返す。 +func (e *Session) IsRevoked() bool { + return e.status.Value() == statusRevoked.Value() +} + // --- 振る舞い--- // Register は認証プロファイルの発行を行う diff --git a/backend/internal/domain/identity/entity_test.go b/backend/internal/domain/identity/entity_test.go index 17e7a82..58c06ce 100644 --- a/backend/internal/domain/identity/entity_test.go +++ b/backend/internal/domain/identity/entity_test.go @@ -464,6 +464,58 @@ func TestSession_Revoke(t *testing.T) { }) } +// --- Session.IsActive / IsRevoked --- + +func TestSession_IsActive(t *testing.T) { + t.Parallel() + + t.Run("新規 session は active", func(t *testing.T) { + t.Parallel() + s := mustNewSession(t, someIdentityID()) + if !s.IsActive() { + t.Error("IsActive() = false, want true") + } + if s.IsRevoked() { + t.Error("IsRevoked() = true, want false") + } + }) + + t.Run("Revoke 後は非 active / IsRevoked=true", func(t *testing.T) { + t.Parallel() + s := mustNewSession(t, someIdentityID()) + if err := s.Revoke(); err != nil { + t.Fatalf("Revoke() error = %v", err) + } + if s.IsActive() { + t.Error("IsActive() = true after Revoke, want false") + } + if !s.IsRevoked() { + t.Error("IsRevoked() = false after Revoke, want true") + } + }) + + t.Run("status=active でも expiresAt を過ぎていれば IsActive=false", func(t *testing.T) { + t.Parallel() + past := time.Now().Add(-1 * time.Hour) + s, err := ReconstructSession(ReconstructSessionInput{ + TokenHash: "a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1", + IdentityID: someIdentityID().Value(), + Status: statusActive.Value(), + IssuedAt: past.Add(-30 * 24 * time.Hour), + ExpiresAt: past, + }) + if err != nil { + t.Fatalf("ReconstructSession: %v", err) + } + if s.IsActive() { + t.Error("IsActive() = true for expired session, want false") + } + if s.IsRevoked() { + t.Error("IsRevoked() = true for expired (but not revoked) session, want false") + } + }) +} + // --- Session.Rotate --- // ドキュメント: rotate(newHash) — 前提: status=Active // 副作用: status=Revoked、新 Session を作成 / SessionRotated イベント diff --git a/backend/internal/handler/article/handler.go b/backend/internal/handler/article/handler.go index c1422ee..95a48e3 100644 --- a/backend/internal/handler/article/handler.go +++ b/backend/internal/handler/article/handler.go @@ -8,6 +8,7 @@ import ( app "github.com/umekikazuya/me/internal/app/article" "github.com/umekikazuya/me/pkg/errs" "github.com/umekikazuya/me/pkg/httpx" + "github.com/umekikazuya/me/pkg/obs" ) type Handler struct { @@ -53,6 +54,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { out, err := h.interactor.Search(r.Context(), input) if err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } @@ -63,6 +65,7 @@ func (h *Handler) Search(w http.ResponseWriter, r *http.Request) { func (h *Handler) GetTagsAll(w http.ResponseWriter, r *http.Request) { out, err := h.interactor.GetTagsAll(r.Context()) if err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } @@ -78,6 +81,7 @@ func (h *Handler) GetSuggests(w http.ResponseWriter, r *http.Request) { } out, err := h.interactor.GetSuggests(r.Context(), app.InputGetSuggestDto{Q: q}) if err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } @@ -92,6 +96,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { return } if err := h.interactor.Register(r.Context(), input); err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } @@ -111,6 +116,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { return } if err := h.interactor.Update(r.Context(), input); err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } @@ -125,6 +131,7 @@ func (h *Handler) Remove(w http.ResponseWriter, r *http.Request) { return } if err := h.interactor.Remove(r.Context(), app.InputRemoveDto{ExternalID: externalID}); err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } diff --git a/backend/internal/handler/identity/handler.go b/backend/internal/handler/identity/handler.go index 6335efc..2dc9294 100644 --- a/backend/internal/handler/identity/handler.go +++ b/backend/internal/handler/identity/handler.go @@ -6,6 +6,7 @@ import ( app "github.com/umekikazuya/me/internal/app/identity" "github.com/umekikazuya/me/pkg/errs" "github.com/umekikazuya/me/pkg/httpx" + "github.com/umekikazuya/me/pkg/obs" ) type Handler struct { @@ -29,6 +30,7 @@ func (h *Handler) Login(w http.ResponseWriter, r *http.Request) { input, ) if err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } @@ -55,6 +57,7 @@ func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) { }, ) if err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } @@ -75,6 +78,7 @@ func (h *Handler) RevokeSessions(w http.ResponseWriter, r *http.Request) { input, ) if err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } @@ -102,6 +106,7 @@ func (h *Handler) RefreshToken(w http.ResponseWriter, r *http.Request) { input, ) if err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } @@ -121,6 +126,7 @@ func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { input, ) if err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } @@ -145,6 +151,7 @@ func (h *Handler) ResetPassword(w http.ResponseWriter, r *http.Request) { input, ) if err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } @@ -170,6 +177,7 @@ func (h *Handler) ChangeEmailAddress(w http.ResponseWriter, r *http.Request) { input, ) if err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } diff --git a/backend/internal/handler/me/handler.go b/backend/internal/handler/me/handler.go index eca95cc..f1f64e4 100644 --- a/backend/internal/handler/me/handler.go +++ b/backend/internal/handler/me/handler.go @@ -8,6 +8,7 @@ import ( app "github.com/umekikazuya/me/internal/app/me" "github.com/umekikazuya/me/pkg/errs" "github.com/umekikazuya/me/pkg/httpx" + "github.com/umekikazuya/me/pkg/obs" ) type Handler struct { @@ -26,6 +27,7 @@ func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { } out, err := h.me.Get(r.Context(), meID) if err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } @@ -46,6 +48,7 @@ func (h *Handler) Update(w http.ResponseWriter, r *http.Request) { input.ID = meID out, err := h.me.Update(r.Context(), input) if err != nil { + obs.LogIfInternal(r.Context(), err) errs.WriteProblem(w, r, err) return } diff --git a/backend/pkg/errs/http.go b/backend/pkg/errs/http.go index af4550b..4c4e6e7 100644 --- a/backend/pkg/errs/http.go +++ b/backend/pkg/errs/http.go @@ -6,10 +6,6 @@ import ( "net/http" ) -type errorRecorder interface { - RecordError(error) -} - // ProblemDetail は RFC 9457 (Problem Details for HTTP APIs) 準拠のエラーレスポンス。 type ProblemDetail struct { Type string `json:"type"` @@ -55,10 +51,6 @@ func WriteProblem(w http.ResponseWriter, r *http.Request, err error) { return } - if recorder, ok := w.(errorRecorder); ok { - recorder.RecordError(err) - } - // 422: ドメインエラーは独自 shape if errors.Is(err, ErrUnprocessable) { dp := toDomainProblem(err) diff --git a/backend/pkg/errs/internal.go b/backend/pkg/errs/internal.go index 1b99913..333d6a8 100644 --- a/backend/pkg/errs/internal.go +++ b/backend/pkg/errs/internal.go @@ -1,15 +1,12 @@ package errs -import ( - "context" - "fmt" - "log/slog" -) +import "fmt" -// WrapInternal は infra 由来のエラーをログに出力し、500 として扱える ErrInternal でラップして返す。 +// WrapInternal は infra 由来のエラーを 500 として扱える ErrInternal でラップして返す。 // 元のエラーも連鎖として保持するため errors.Is で元エラーにマッチする (デバッグ・テスト用)。 -// クライアントへ漏らしたくない内部詳細はログにのみ残り、レスポンスは空の ProblemDetail になる。 -func WrapInternal(ctx context.Context, op string, err error) error { - slog.ErrorContext(ctx, op, "error", err) +// +// 本関数はログを出さない — ログは handler 境界で `obs.LogIfInternal(ctx, err)` が 1 回だけ出す。 +// ログ/エラーの責務を分けることで、app 層 (interactor) を logger から完全に切り離す。 +func WrapInternal(op string, err error) error { return fmt.Errorf("%s: %w: %w", op, ErrInternal, err) } diff --git a/backend/pkg/middleware/logging.go b/backend/pkg/middleware/logging.go deleted file mode 100644 index bef4eea..0000000 --- a/backend/pkg/middleware/logging.go +++ /dev/null @@ -1,55 +0,0 @@ -package middleware - -import ( - "log/slog" - "net/http" - "time" -) - -type responseWriter struct { - http.ResponseWriter - status int - err error -} - -func (rw *responseWriter) WriteHeader(status int) { - rw.status = status - rw.ResponseWriter.WriteHeader(status) -} - -func (rw *responseWriter) RecordError(err error) { - rw.err = err -} - -func logLevel(status int) slog.Level { - switch { - case status >= http.StatusInternalServerError: - return slog.LevelError - case status >= http.StatusBadRequest: - return slog.LevelWarn - default: - return slog.LevelInfo - } -} - -func Logging(next http.Handler) http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - start := time.Now() - rw := &responseWriter{ResponseWriter: w, status: http.StatusOK} - next.ServeHTTP(rw, r) - - args := []any{ - "method", r.Method, - "path", r.URL.Path, - "status", rw.status, - "duration", time.Since(start), - "remoteAddr", r.RemoteAddr, - "userAgent", r.UserAgent(), - } - if rw.err != nil { - args = append(args, "error", rw.err.Error()) - } - - slog.Log(r.Context(), logLevel(rw.status), "request", args...) - }) -} diff --git a/backend/pkg/middleware/logging_test.go b/backend/pkg/middleware/logging_test.go deleted file mode 100644 index 51454e6..0000000 --- a/backend/pkg/middleware/logging_test.go +++ /dev/null @@ -1,102 +0,0 @@ -package middleware - -import ( - "encoding/json" - "errors" - "fmt" - "log/slog" - "net/http" - "net/http/httptest" - "testing" - - "github.com/umekikazuya/me/pkg/errs" -) - -func TestLogging(t *testing.T) { - tests := []struct { - name string - handler http.Handler - wantLevel string - wantStatus int - wantError string - }{ - { - name: "2xx は info で出る", - handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusOK) - }), - wantLevel: "INFO", - wantStatus: http.StatusOK, - }, - { - name: "errs ベースの 4xx は warn と error を出す", - handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - errs.WriteProblem(w, r, fmt.Errorf("decode request body: %w", errs.ErrBadRequest)) - }), - wantLevel: "WARN", - wantStatus: http.StatusBadRequest, - wantError: "decode request body: bad request", - }, - { - name: "未知エラーの 5xx は error と元エラーを出す", - handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - errs.WriteProblem(w, r, errors.New("database unavailable")) - }), - wantLevel: "ERROR", - wantStatus: http.StatusInternalServerError, - wantError: "database unavailable", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - var got map[string]any - logger := slog.New(slog.NewJSONHandler(testWriter(func(p []byte) (int, error) { - return len(p), json.Unmarshal(p, &got) - }), nil)) - prev := slog.Default() - slog.SetDefault(logger) - t.Cleanup(func() { - slog.SetDefault(prev) - }) - - req := httptest.NewRequest(http.MethodGet, "/test", nil) - rec := httptest.NewRecorder() - - Logging(tt.handler).ServeHTTP(rec, req) - - if got["level"] != tt.wantLevel { - t.Fatalf("level = %v, want %q", got["level"], tt.wantLevel) - } - if int(got["status"].(float64)) != tt.wantStatus { - t.Fatalf("status = %v, want %d", got["status"], tt.wantStatus) - } - if got["method"] != http.MethodGet { - t.Fatalf("method = %v, want %q", got["method"], http.MethodGet) - } - if got["path"] != "/test" { - t.Fatalf("path = %v, want %q", got["path"], "/test") - } - - gotError, ok := got["error"] - if tt.wantError == "" { - if ok { - t.Fatalf("unexpected error field = %v", gotError) - } - return - } - if !ok { - t.Fatal("expected error field to be present") - } - if gotError != tt.wantError { - t.Fatalf("error = %v, want %q", gotError, tt.wantError) - } - }) - } -} - -type testWriter func([]byte) (int, error) - -func (w testWriter) Write(p []byte) (int, error) { - return w(p) -} diff --git a/backend/pkg/middleware/recover.go b/backend/pkg/middleware/recover.go new file mode 100644 index 0000000..3628dac --- /dev/null +++ b/backend/pkg/middleware/recover.go @@ -0,0 +1,61 @@ +package middleware + +import ( + "errors" + "fmt" + "log/slog" + "net/http" + "runtime/debug" + + "github.com/umekikazuya/me/pkg/errs" + "github.com/umekikazuya/me/pkg/obs" +) + +// RecoverOption は Recover の振る舞いを差し替える関数型オプション。 +type RecoverOption func(*recoverConfig) + +type recoverConfig struct { + logger *slog.Logger +} + +// WithLogger はテスト等で slog.Default() 以外の Logger を注入する。 +func WithLogger(l *slog.Logger) RecoverOption { + return func(c *recoverConfig) { c.logger = l } +} + +// Recover は panic を捕捉し、500 ProblemDetails を返しつつ ERROR ログを残す。 +// アクセスログではなく「プロセス保護 + 未処理エラーの可視化」のための middleware。 +// +// 属性名は OpenTelemetry Semantic Conventions に準拠する (pkg/obs/attr.go)。 +// Logger は opts で明示注入できる。未指定時は slog.Default()。 +func Recover(next http.Handler, opts ...RecoverOption) http.Handler { + cfg := recoverConfig{} + for _, o := range opts { + o(&cfg) + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + rec := recover() + if rec == nil { + return + } + err, ok := rec.(error) + if !ok { + err = fmt.Errorf("panic: %v", rec) + } + logger := cfg.logger + if logger == nil { + logger = slog.Default() + } + logger.ErrorContext(r.Context(), "unhandled panic", + obs.AttrExceptionMessage, err.Error(), + obs.AttrExceptionType, fmt.Sprintf("%T", err), + obs.AttrExceptionStack, string(debug.Stack()), + obs.AttrHTTPMethod, r.Method, + obs.AttrURLPath, r.URL.Path, + ) + errs.WriteProblem(w, r, errors.Join(errs.ErrInternal, err)) + }() + next.ServeHTTP(w, r) + }) +} diff --git a/backend/pkg/middleware/recover_test.go b/backend/pkg/middleware/recover_test.go new file mode 100644 index 0000000..5ef4b86 --- /dev/null +++ b/backend/pkg/middleware/recover_test.go @@ -0,0 +1,102 @@ +package middleware + +import ( + "encoding/json" + "errors" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/umekikazuya/me/pkg/errs" + "github.com/umekikazuya/me/pkg/obs" + "github.com/umekikazuya/me/pkg/obs/obstest" +) + +func TestRecover(t *testing.T) { + tests := []struct { + name string + handler http.Handler + wantLogged bool + wantStack bool + }{ + { + name: "正常系は何もログしない", + handler: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + }), + wantLogged: false, + }, + { + name: "error panic は 500 + ERROR ログ", + handler: http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + panic(errors.New("boom")) + }), + wantLogged: true, + wantStack: true, + }, + { + name: "非 error panic も 500 + ERROR ログ", + handler: http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) { + panic("boom") + }), + wantLogged: true, + wantStack: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cap := obstest.NewCapture(t) + logger := slog.New(cap.Handler()) + + req := httptest.NewRequest(http.MethodGet, "/test", nil) + rec := httptest.NewRecorder() + + Recover(tt.handler, WithLogger(logger)).ServeHTTP(rec, req) + + records := cap.Records() + if !tt.wantLogged { + if len(records) > 0 { + t.Fatalf("想定外のログ: %v", records) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + return + } + + if len(records) == 0 { + t.Fatal("ログが出ていない") + } + got := records[0] + if got["level"] != "ERROR" { + t.Fatalf("level = %v, want ERROR", got["level"]) + } + if got["msg"] != "unhandled panic" { + t.Fatalf("msg = %v, want 'unhandled panic'", got["msg"]) + } + if tt.wantStack { + stack, _ := got[obs.AttrExceptionStack].(string) + if stack == "" || !strings.Contains(stack, "goroutine") { + t.Fatalf("exception.stacktrace が想定通りでない: %q", stack) + } + } + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500", rec.Code) + } + if !strings.Contains(rec.Header().Get("Content-Type"), "problem+json") { + t.Fatalf("Content-Type = %q, want problem+json", rec.Header().Get("Content-Type")) + } + + var body errs.ProblemDetail + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("body decode: %v", err) + } + if body.Status != http.StatusInternalServerError { + t.Fatalf("body.Status = %d, want 500", body.Status) + } + }) + } +} diff --git a/backend/pkg/middleware/requestid.go b/backend/pkg/middleware/requestid.go index 46a4408..181c34a 100644 --- a/backend/pkg/middleware/requestid.go +++ b/backend/pkg/middleware/requestid.go @@ -4,7 +4,7 @@ import ( "net/http" "github.com/google/uuid" - "github.com/umekikazuya/me/pkg/reqctx" + "github.com/umekikazuya/me/pkg/obs" ) const requestIDHeader = "X-Request-ID" @@ -18,6 +18,6 @@ func RequestID(next http.Handler) http.Handler { id = uuid.NewString() } w.Header().Set(requestIDHeader, id) - next.ServeHTTP(w, r.WithContext(reqctx.WithRequestID(r.Context(), id))) + next.ServeHTTP(w, r.WithContext(obs.WithRequestID(r.Context(), id))) }) } diff --git a/backend/pkg/obs/attr.go b/backend/pkg/obs/attr.go new file mode 100644 index 0000000..ff70505 --- /dev/null +++ b/backend/pkg/obs/attr.go @@ -0,0 +1,21 @@ +package obs + +// Attribute key constants. +// +// 値は OpenTelemetry Semantic Conventions (v1.26.0) に準拠する。 +// 新規コードでは定数を経由して書き、リテラルの揺れを避ける。 +// +// `request.id` のみ sem-conv 範囲外の独自拡張である。 +const ( + AttrServiceName = "service.name" + AttrServiceVersion = "service.version" + AttrExceptionMessage = "exception.message" + AttrExceptionType = "exception.type" + AttrExceptionStack = "exception.stacktrace" + AttrHTTPMethod = "http.request.method" + AttrURLPath = "url.path" + AttrTraceID = "trace_id" + AttrSpanID = "span_id" + AttrRequestID = "request.id" + AttrOp = "code.function" +) diff --git a/backend/pkg/reqctx/reqctx.go b/backend/pkg/obs/context.go similarity index 65% rename from backend/pkg/reqctx/reqctx.go rename to backend/pkg/obs/context.go index 88ecd38..7bf8ac8 100644 --- a/backend/pkg/reqctx/reqctx.go +++ b/backend/pkg/obs/context.go @@ -1,6 +1,4 @@ -// Package reqctx はリクエストスコープで context に載せる値の key / accessor を提供する。 -// middleware 層と logger 層の双方から依存されるため、依存方向を片方向に保つ目的でここに集約する。 -package reqctx +package obs import "context" diff --git a/backend/pkg/obs/log.go b/backend/pkg/obs/log.go new file mode 100644 index 0000000..a03ec7a --- /dev/null +++ b/backend/pkg/obs/log.go @@ -0,0 +1,23 @@ +package obs + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/umekikazuya/me/pkg/errs" +) + +// LogIfInternal は err が errs.ErrInternal を含むときのみ ERROR を 1 件出す。 +// 通常は handler 境界 (WriteProblem の直前) で使う。 +// client fault (ErrBadRequest / ErrNotFound 等) のときは何もしない。 +func LogIfInternal(ctx context.Context, err error) { + if err == nil || !errors.Is(err, errs.ErrInternal) { + return + } + slog.ErrorContext(ctx, "internal error", + AttrExceptionMessage, err.Error(), + AttrExceptionType, fmt.Sprintf("%T", err), + ) +} diff --git a/backend/pkg/obs/logger.go b/backend/pkg/obs/logger.go new file mode 100644 index 0000000..a21c57d --- /dev/null +++ b/backend/pkg/obs/logger.go @@ -0,0 +1,84 @@ +package obs + +import ( + "context" + "fmt" + "log/slog" + "runtime" + "strings" + + "go.opentelemetry.io/otel/trace" +) + +// ParseLevel は LOG_LEVEL 文字列を slog.Level に変換する純関数。 +// 既定値は Info。main 境界から cfg.Level に詰める用途。 +func ParseLevel(s string) slog.Level { + switch strings.ToLower(strings.TrimSpace(s)) { + case "debug": + return slog.LevelDebug + case "warn", "warning": + return slog.LevelWarn + case "error": + return slog.LevelError + default: + return slog.LevelInfo + } +} + +func buildLogger(cfg Config) *slog.Logger { + var h slog.Handler = slog.NewJSONHandler(cfg.Writer, &slog.HandlerOptions{ + Level: cfg.Level, + }) + h = &traceHandler{Handler: h, addSource: cfg.AddSource} + if len(cfg.SensitiveKeys) > 0 { + h = newRedactHandler(h, cfg.SensitiveKeys) + } + logger := slog.New(h).With(AttrServiceName, cfg.ServiceName) + if cfg.ServiceVersion != "" { + logger = logger.With(AttrServiceVersion, cfg.ServiceVersion) + } + return logger +} + +// traceHandler は context 由来の属性 (request.id / trace_id / span_id) を自動で注入し、 +// ERROR 以上のときのみ source を付与する slog.Handler デコレータ。 +type traceHandler struct { + slog.Handler + addSource bool +} + +func (h *traceHandler) Handle(ctx context.Context, r slog.Record) error { + if id := RequestIDFromContext(ctx); id != "" { + r.AddAttrs(slog.String(AttrRequestID, id)) + } + if span := trace.SpanFromContext(ctx); span.SpanContext().IsValid() { + sc := span.SpanContext() + r.AddAttrs( + slog.String(AttrTraceID, sc.TraceID().String()), + slog.String(AttrSpanID, sc.SpanID().String()), + ) + } + if h.addSource && r.Level >= slog.LevelError && r.PC != 0 { + fs := runtime.CallersFrames([]uintptr{r.PC}) + f, _ := fs.Next() + if f.File != "" { + r.AddAttrs(slog.Any(slog.SourceKey, &slog.Source{ + Function: f.Function, + File: f.File, + Line: f.Line, + })) + } + } + if err := h.Handler.Handle(ctx, r); err != nil { + return fmt.Errorf("obs handler: %w", err) + } + return nil +} + +func (h *traceHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &traceHandler{Handler: h.Handler.WithAttrs(attrs), addSource: h.addSource} +} + +func (h *traceHandler) WithGroup(name string) slog.Handler { + return &traceHandler{Handler: h.Handler.WithGroup(name), addSource: h.addSource} +} diff --git a/backend/pkg/obs/meter.go b/backend/pkg/obs/meter.go new file mode 100644 index 0000000..b8cb311 --- /dev/null +++ b/backend/pkg/obs/meter.go @@ -0,0 +1,20 @@ +package obs + +import ( + "fmt" + + "go.opentelemetry.io/otel/exporters/stdout/stdoutmetric" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" +) + +func newMeterProvider(cfg Config, res *resource.Resource) (*sdkmetric.MeterProvider, error) { + exp, err := stdoutmetric.New(stdoutmetric.WithWriter(cfg.Writer)) + if err != nil { + return nil, fmt.Errorf("stdoutmetric: %w", err) + } + return sdkmetric.NewMeterProvider( + sdkmetric.WithReader(sdkmetric.NewPeriodicReader(exp)), + sdkmetric.WithResource(res), + ), nil +} diff --git a/backend/pkg/obs/obs.go b/backend/pkg/obs/obs.go new file mode 100644 index 0000000..f9d2433 --- /dev/null +++ b/backend/pkg/obs/obs.go @@ -0,0 +1,223 @@ +// Package obs はアプリの観測性基盤 (logs / traces / metrics) を提供する。 +// +// 設計の position は v1 (2026-04 時点、2 ヶ月後見直し前提) で、 +// `docs/developments/observability.md` に明文化されている。 +// +// 原則: +// - アクセスログはインフラ層 (API Gateway / ALB / CloudFront) の責務、アプリでは出さない。 +// - 3 本柱は全て stdout に出力する (OTLP は v1 範囲外)。 +// - 属性名は OpenTelemetry Semantic Conventions に準拠する (定数は attr.go)。 +// +// 推奨される使い方: +// +// // プロセス起動時 +// prov, shutdown, err := obs.Bootstrap(ctx, obs.Config{ +// ServiceName: "api", +// Level: obs.ParseLevel(os.Getenv("LOG_LEVEL")), +// EnableTraces: true, +// EnableMetrics: true, +// }) +// if err != nil { ... } +// // shutdown は bounded な fresh ctx で呼ぶ。呼び出し時点で元 ctx が +// // cancel 済みだと traces/metrics の flush が即諦められるため。 +// defer func() { +// shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) +// defer cancel() +// _ = shutdown(shutdownCtx) +// }() +// slog.SetDefault(prov.Logger) +// +// // 業務ログ (ctx 必須) +// slog.ErrorContext(ctx, "internal error", +// obs.AttrExceptionMessage, err.Error(), +// ) +package obs + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "sync" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + metricnoop "go.opentelemetry.io/otel/metric/noop" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.39.0" + "go.opentelemetry.io/otel/trace" + tracenoop "go.opentelemetry.io/otel/trace/noop" +) + +// Config は Bootstrap のパラメータ。 +// ServiceName のみ必須、それ以外は実用的な既定値を持つ。 +type Config struct { + // ServiceName は OTel resource の service.name に直行する (必須)。 + ServiceName string + + // ServiceVersion は optional。空なら付与しない。 + ServiceVersion string + + // Writer は logs 出力先。nil なら os.Stdout。 + Writer io.Writer + + // Level は slog の最低出力レベル。nil なら LevelInfo。 + Level slog.Leveler + + // SensitiveKeys に列挙された attribute key (lowercase 比較) の値は "[REDACTED]" に置換される。 + SensitiveKeys []string + + // AddSource が true のとき、ERROR 以上のログに source (file:line) を付与する。 + AddSource bool + + // EnableTraces が true のとき stdouttrace に span を吐く。false なら NoOp。 + EnableTraces bool + + // EnableMetrics が true のとき stdoutmetric に測定値を吐く。false なら NoOp。 + EnableMetrics bool + + // SyncExport が true のとき tracer を BatchSpanProcessor ではなく SimpleSpanProcessor + // で構成する (span ごとに即 stdout へ出す)。dev / debug 用途で flush 遅延を消す。 + // 本番では false のまま (= BatchSpanProcessor) にする。 + SyncExport bool +} + +// Provider は初期化済みの Logger / Tracer / Meter を保持する。 +// Bootstrap が返した shutdown 関数をプロセス終了時に必ず呼ぶこと。 +type Provider struct { + Logger *slog.Logger + Tracer trace.Tracer + Meter metric.Meter + + tracerProvider *sdktrace.TracerProvider + meterProvider *sdkmetric.MeterProvider +} + +// Bootstrap はログ/トレース/メトリクスを初期化して Provider と shutdown 関数を返す。 +// 返り値の shutdown は defer で必ず呼び出すこと (traces/metrics の flush に必要)。 +// +// ctx は stdout exporter のみを扱う v1 時点では未使用だが、将来の OTLP exporter 移行で +// 接続確立のキャンセル用に使う予定のためシグネチャに残す。 +func Bootstrap(ctx context.Context, cfg Config) (*Provider, func(context.Context) error, error) { + _ = ctx + if cfg.ServiceName == "" { + return nil, nil, errors.New("obs: ServiceName is required") + } + if cfg.Writer == nil { + cfg.Writer = os.Stdout + } + // logs / traces / metrics はそれぞれ内部で直列化するが、互いに非協調で + // 同一 fd に書くとバイト単位で interleave しうる。外側に mutex を被せて + // 3 者の Write を跨って直列化する。 + cfg.Writer = newLockedWriter(cfg.Writer) + if cfg.Level == nil { + cfg.Level = slog.LevelInfo + } + + res, err := newResource(cfg) + if err != nil { + return nil, nil, fmt.Errorf("obs: resource: %w", err) + } + + p := &Provider{Logger: buildLogger(cfg)} + + // tracer → meter の順に組み立てるが、meter 失敗時に tracer の BatchSpanProcessor + // goroutine とグローバル登録が取り残されるのを防ぐため、両方成功してから + // グローバル公開する。途中失敗時は tp を Shutdown で巻き戻す。 + var tp *sdktrace.TracerProvider + if cfg.EnableTraces { + t, err := newTracerProvider(cfg, res) + if err != nil { + return nil, nil, fmt.Errorf("obs: tracer: %w", err) + } + tp = t + } + + var mp *sdkmetric.MeterProvider + if cfg.EnableMetrics { + m, err := newMeterProvider(cfg, res) + if err != nil { + if tp != nil { + _ = tp.Shutdown(context.Background()) + } + return nil, nil, fmt.Errorf("obs: meter: %w", err) + } + mp = m + } + + // Traces: 明示的に NoOp を登録して「disabled = 確実に何も吐かない」を担保する。 + // デフォルトでも NoOp だが、他の初期化箇所で otel.SetTracerProvider が呼ばれる + // と汚染されるため、この Bootstrap が最終権威になるように明示的に上書きする。 + if tp != nil { + otel.SetTracerProvider(tp) + p.tracerProvider = tp + p.Tracer = tp.Tracer(cfg.ServiceName) + } else { + np := tracenoop.NewTracerProvider() + otel.SetTracerProvider(np) + p.Tracer = np.Tracer(cfg.ServiceName) + } + if mp != nil { + otel.SetMeterProvider(mp) + p.meterProvider = mp + p.Meter = mp.Meter(cfg.ServiceName) + } else { + nm := metricnoop.NewMeterProvider() + otel.SetMeterProvider(nm) + p.Meter = nm.Meter(cfg.ServiceName) + } + + return p, p.shutdown, nil +} + +func (p *Provider) shutdown(ctx context.Context) error { + var merr []error + if p.tracerProvider != nil { + if err := p.tracerProvider.Shutdown(ctx); err != nil { + merr = append(merr, fmt.Errorf("tracer shutdown: %w", err)) + } + } + if p.meterProvider != nil { + if err := p.meterProvider.Shutdown(ctx); err != nil { + merr = append(merr, fmt.Errorf("meter shutdown: %w", err)) + } + } + return errors.Join(merr...) +} + +// lockedWriter は io.Writer を sync.Mutex で直列化するデコレータ。 +// logs / traces / metrics の 3 系統が同一 fd に書く際、内部ロックだけでは +// 系統間で Write が interleave しうるため、外側でまとめて直列化する。 +type lockedWriter struct { + mu sync.Mutex + w io.Writer +} + +func newLockedWriter(w io.Writer) *lockedWriter { + return &lockedWriter{w: w} +} + +func (l *lockedWriter) Write(p []byte) (int, error) { + l.mu.Lock() + defer l.mu.Unlock() + return l.w.Write(p) +} + +func newResource(cfg Config) (*resource.Resource, error) { + attrs := []attribute.KeyValue{semconv.ServiceName(cfg.ServiceName)} + if cfg.ServiceVersion != "" { + attrs = append(attrs, semconv.ServiceVersion(cfg.ServiceVersion)) + } + // Schema URL は空にして resource.Default() 側 (SDK が宣言する sem-conv 版) + // を採用させる。semconv パッケージのバージョンと SDK の schema URL は + // ずれることがあり、明示すると resource.Merge が conflicting schema URL で落ちる。 + return resource.Merge( + resource.Default(), + resource.NewWithAttributes("", attrs...), + ) +} diff --git a/backend/pkg/obs/obstest/capture.go b/backend/pkg/obs/obstest/capture.go new file mode 100644 index 0000000..1183fbf --- /dev/null +++ b/backend/pkg/obs/obstest/capture.go @@ -0,0 +1,69 @@ +// Package obstest はテスト用のログ/スパン capture ヘルパを提供する。 +// +// `slog.SetDefault` を差し替えるパターンはテスト間の副作用を生むため避け、 +// Capture を明示的に Logger に渡す DI で書く前提。 +package obstest + +import ( + "bytes" + "encoding/json" + "log/slog" + "sync" + "testing" +) + +// Capture は slog の JSON 出力を in-memory に貯める io.Writer 兼ヘルパ。 +type Capture struct { + mu sync.Mutex + buf *bytes.Buffer +} + +// NewCapture は新しい Capture を返す。t.Cleanup に Reset を登録するので、 +// 同じ t で複数テストが走ってもバッファが持ち越されない。 +func NewCapture(t *testing.T) *Capture { + t.Helper() + c := &Capture{buf: &bytes.Buffer{}} + t.Cleanup(c.Reset) + return c +} + +// Handler は level 指定なし (既定 Info) の JSON Handler を返す。 +func (c *Capture) Handler() slog.Handler { + return slog.NewJSONHandler(c, nil) +} + +// HandlerWithLevel は level 指定版。 +func (c *Capture) HandlerWithLevel(lv slog.Leveler) slog.Handler { + return slog.NewJSONHandler(c, &slog.HandlerOptions{Level: lv}) +} + +// Write は io.Writer 実装。JSON Handler の出力先として使われる。 +func (c *Capture) Write(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + return c.buf.Write(p) +} + +// Records は出力済み各行を JSON decode した結果を返す。 +// decode に失敗した時点で打ち切って返す (テストヘルパ用途の割り切り)。 +func (c *Capture) Records() []map[string]any { + c.mu.Lock() + defer c.mu.Unlock() + out := []map[string]any{} + dec := json.NewDecoder(bytes.NewReader(c.buf.Bytes())) + for dec.More() { + var m map[string]any + if err := dec.Decode(&m); err != nil { + return out + } + out = append(out, m) + } + return out +} + +// Reset は記録済みレコードを破棄する。 +func (c *Capture) Reset() { + c.mu.Lock() + defer c.mu.Unlock() + c.buf.Reset() +} diff --git a/backend/pkg/obs/recover.go b/backend/pkg/obs/recover.go new file mode 100644 index 0000000..f44483e --- /dev/null +++ b/backend/pkg/obs/recover.go @@ -0,0 +1,29 @@ +package obs + +import ( + "context" + "fmt" + "log/slog" + "runtime/debug" +) + +// RecoverProcess は HTTP 外 (batch 等) の panic を ERROR ログに落としてから +// 同じ値で re-panic する。re-panic により runtime の既定ハンドラがプロセスを +// 非ゼロ exit させるため、cron / batch の failure を握り潰さない。 +// usage: defer obs.RecoverProcess(ctx, "batch.main") +func RecoverProcess(ctx context.Context, op string) { + rec := recover() + if rec == nil { + return + } + err, ok := rec.(error) + if !ok { + err = fmt.Errorf("panic: %v", rec) + } + slog.ErrorContext(ctx, "unhandled panic", + AttrOp, op, + AttrExceptionMessage, err.Error(), + AttrExceptionStack, string(debug.Stack()), + ) + panic(rec) +} diff --git a/backend/pkg/obs/redact.go b/backend/pkg/obs/redact.go new file mode 100644 index 0000000..12f5e31 --- /dev/null +++ b/backend/pkg/obs/redact.go @@ -0,0 +1,68 @@ +package obs + +import ( + "context" + "log/slog" + "strings" +) + +const redactedValue = "[REDACTED]" + +// redactHandler は指定 key と完全一致 (lowercase) する attribute の値を [REDACTED] に置換する。 +// ネストした slog.Group 配下の key も辿ってマスクする。 +type redactHandler struct { + slog.Handler + keys map[string]struct{} +} + +func newRedactHandler(inner slog.Handler, keys []string) slog.Handler { + m := make(map[string]struct{}, len(keys)) + for _, k := range keys { + m[strings.ToLower(k)] = struct{}{} + } + return &redactHandler{Handler: inner, keys: m} +} + +func (h *redactHandler) Handle(ctx context.Context, r slog.Record) error { + attrs := make([]slog.Attr, 0, r.NumAttrs()) + r.Attrs(func(a slog.Attr) bool { + attrs = append(attrs, h.redact(a)) + return true + }) + newR := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) + newR.AddAttrs(attrs...) + return h.Handler.Handle(ctx, newR) +} + +func (h *redactHandler) redact(a slog.Attr) slog.Attr { + if _, ok := h.keys[strings.ToLower(a.Key)]; ok { + return slog.String(a.Key, redactedValue) + } + // LogValuer は lazy resolve のため、resolve してから redact を再適用する。 + // Resolve しないと `password=xxx` を LogValue 内で返す型が素通りしてしまう。 + if a.Value.Kind() == slog.KindLogValuer { + resolved := a.Value.Resolve() + return h.redact(slog.Attr{Key: a.Key, Value: resolved}) + } + if a.Value.Kind() == slog.KindGroup { + group := a.Value.Group() + redacted := make([]slog.Attr, len(group)) + for i, g := range group { + redacted[i] = h.redact(g) + } + return slog.Attr{Key: a.Key, Value: slog.GroupValue(redacted...)} + } + return a +} + +func (h *redactHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + redacted := make([]slog.Attr, len(attrs)) + for i, a := range attrs { + redacted[i] = h.redact(a) + } + return &redactHandler{Handler: h.Handler.WithAttrs(redacted), keys: h.keys} +} + +func (h *redactHandler) WithGroup(name string) slog.Handler { + return &redactHandler{Handler: h.Handler.WithGroup(name), keys: h.keys} +} diff --git a/backend/pkg/obs/tracer.go b/backend/pkg/obs/tracer.go new file mode 100644 index 0000000..ef2a08f --- /dev/null +++ b/backend/pkg/obs/tracer.go @@ -0,0 +1,26 @@ +package obs + +import ( + "fmt" + + "go.opentelemetry.io/otel/exporters/stdout/stdouttrace" + "go.opentelemetry.io/otel/sdk/resource" + sdktrace "go.opentelemetry.io/otel/sdk/trace" +) + +func newTracerProvider(cfg Config, res *resource.Resource) (*sdktrace.TracerProvider, error) { + exp, err := stdouttrace.New(stdouttrace.WithWriter(cfg.Writer)) + if err != nil { + return nil, fmt.Errorf("stdouttrace: %w", err) + } + // SyncExport: dev/debug では span を即 stdout に流すため SimpleSpanProcessor を使う。 + // 本番 (false) では既定の BatchSpanProcessor でスループットを確保する。 + spanOpt := sdktrace.WithBatcher(exp) + if cfg.SyncExport { + spanOpt = sdktrace.WithSyncer(exp) + } + return sdktrace.NewTracerProvider( + spanOpt, + sdktrace.WithResource(res), + ), nil +} diff --git a/backend/pkg/slogx/slogx.go b/backend/pkg/slogx/slogx.go deleted file mode 100644 index d6eb632..0000000 --- a/backend/pkg/slogx/slogx.go +++ /dev/null @@ -1,56 +0,0 @@ -package slogx - -import ( - "context" - "fmt" - "io" - "log/slog" - "os" - "strings" - - "github.com/umekikazuya/me/pkg/reqctx" -) - -// New は LOG_LEVEL env (debug/info/warn/error, 既定 info) を反映した JSON slog.Logger を返す。 -// context の RequestID は全ログ entry に requestId フィールドとして自動付与される。 -func New(w io.Writer) *slog.Logger { - if w == nil { - w = os.Stdout - } - level := parseLevel(os.Getenv("LOG_LEVEL")) - base := slog.NewJSONHandler(w, &slog.HandlerOptions{Level: level}) - return slog.New(&contextHandler{Handler: base}) -} - -func parseLevel(s string) slog.Level { - switch strings.ToLower(strings.TrimSpace(s)) { - case "debug": - return slog.LevelDebug - case "warn", "warning": - return slog.LevelWarn - case "error": - return slog.LevelError - default: - return slog.LevelInfo - } -} - -type contextHandler struct{ slog.Handler } - -func (h *contextHandler) Handle(ctx context.Context, r slog.Record) error { - if id := reqctx.RequestIDFromContext(ctx); id != "" { - r.AddAttrs(slog.String("requestId", id)) - } - if err := h.Handler.Handle(ctx, r); err != nil { - return fmt.Errorf("slog handler: %w", err) - } - return nil -} - -func (h *contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { - return &contextHandler{Handler: h.Handler.WithAttrs(attrs)} -} - -func (h *contextHandler) WithGroup(name string) slog.Handler { - return &contextHandler{Handler: h.Handler.WithGroup(name)} -} diff --git a/docs b/docs index af9308d..f275213 160000 --- a/docs +++ b/docs @@ -1 +1 @@ -Subproject commit af9308da50489b478c576703709328b5fdcffafa +Subproject commit f2752131788a183bba737fe0232654da4a34eb6c diff --git a/frontend/biome.json b/frontend/biome.json index 0535fa1..22130fd 100644 --- a/frontend/biome.json +++ b/frontend/biome.json @@ -17,7 +17,28 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "complexity": { + "noExcessiveCognitiveComplexity": { + "level": "error", + "options": { + "maxAllowedComplexity": 12 + } + }, + "noImportantStyles": "error" + }, + "style": { + "noNonNullAssertion": "error", + "noParameterAssign": "error" + }, + "suspicious": { + "noConsole": { + "level": "error", + "options": { + "allow": ["warn", "error"] + } + } + } } }, "javascript": { diff --git a/frontend/index.html b/frontend/index.html index 750dc9a..6cecb6a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -12,6 +12,13 @@ href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@300;400&family=Noto+Serif+JP:wght@200;300&display=swap" rel="stylesheet" /> + diff --git a/frontend/package.json b/frontend/package.json index 52c76e6..993d78f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@lit-labs/router": "^0.1.4", + "@lit/context": "^1.1.6", "lit": "^3.3.2", "urlpattern-polyfill": "^10.1.0" }, diff --git a/frontend/src/admin/admin-form-styles.ts b/frontend/src/admin/admin-form-styles.ts index 8b09a95..72c514f 100644 --- a/frontend/src/admin/admin-form-styles.ts +++ b/frontend/src/admin/admin-form-styles.ts @@ -93,7 +93,7 @@ export const adminFormStyles = css` } .subtle:hover:not(:disabled) { - background: var(--color-surface); + background: var(--color-bg-surface); color: var(--color-text-primary); } diff --git a/frontend/src/components/admin/profile/me-profile-certifications-editor.ts b/frontend/src/components/admin/profile/me-profile-certifications-editor.ts new file mode 100644 index 0000000..30f53e2 --- /dev/null +++ b/frontend/src/components/admin/profile/me-profile-certifications-editor.ts @@ -0,0 +1,167 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { adminFormStyles } from '../../../admin/admin-form-styles.js' +import type { MeCertification } from '../../../admin/types.js' +import '../ui/me-admin-panel.js' +import '../ui/me-admin-section.js' +import '../ui/me-text-input.js' + +@customElement('me-profile-certifications-editor') +export class MeProfileCertificationsEditor extends LitElement { + static formAssociated = true + + @property({ type: Array }) certifications: MeCertification[] = [] + @property() name = '' + + private _internals: ElementInternals + + constructor() { + super() + this._internals = this.attachInternals() + } + + private dispatchChange(next: MeCertification[]) { + this.certifications = next + this._internals.setFormValue(JSON.stringify(next)) + + this.dispatchEvent( + new CustomEvent('change', { + detail: next, + bubbles: true, + composed: true, + }), + ) + } + + updated(changedProperties: Map) { + if (changedProperties.has('certifications')) { + this._internals.setFormValue(JSON.stringify(this.certifications ?? [])) + } + } + + private addItem = () => { + const next = [ + ...this.certifications, + { name: '', issuer: '', year: new Date().getFullYear() }, + ] + this.dispatchChange(next) + } + + private removeItem(index: number) { + const next = this.certifications.filter((_, i) => i !== index) + this.dispatchChange(next) + } + + private updateItem(index: number, patch: Partial) { + const next = [...this.certifications] + next[index] = { ...next[index], ...patch } + this.dispatchChange(next) + } + + render() { + return html` + + + +
+ ${ + this.certifications.length === 0 + ? html`

資格がまだありません。

` + : this.certifications.map( + (cert, index) => html` + + + +
+ + this.updateItem(index, { name: e.detail })} + > + + + this.updateItem(index, { issuer: e.detail })} + > + + + this.updateItem(index, { + year: Number(e.detail || '0'), + })} + > + + + this.updateItem(index, { + month: e.detail ? Number(e.detail) : undefined, + })} + > +
+
+ `, + ) + } +
+
+ ` + } + + static styles = [ + adminFormStyles, + css` + :host { + display: block; + } + + .stack { + display: grid; + gap: 20px; + } + + .grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } + + .empty-text { + color: var(--color-text-tertiary); + font-size: 14px; + font-style: italic; + } + `, + ] +} + +declare global { + interface HTMLElementTagNameMap { + 'me-profile-certifications-editor': MeProfileCertificationsEditor + } +} diff --git a/frontend/src/components/admin/profile/me-profile-experiences-editor.ts b/frontend/src/components/admin/profile/me-profile-experiences-editor.ts new file mode 100644 index 0000000..7914fea --- /dev/null +++ b/frontend/src/components/admin/profile/me-profile-experiences-editor.ts @@ -0,0 +1,168 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { adminFormStyles } from '../../../admin/admin-form-styles.js' +import type { MeExperience } from '../../../admin/types.js' +import '../ui/me-admin-panel.js' +import '../ui/me-admin-section.js' +import '../ui/me-text-input.js' + +@customElement('me-profile-experiences-editor') +export class MeProfileExperiencesEditor extends LitElement { + static formAssociated = true + + @property({ type: Array }) experiences: MeExperience[] = [] + @property() name = '' + + private _internals: ElementInternals + + constructor() { + super() + this._internals = this.attachInternals() + } + + private dispatchChange(next: MeExperience[]) { + this.experiences = next + this._internals.setFormValue(JSON.stringify(next)) + + this.dispatchEvent( + new CustomEvent('change', { + detail: next, + bubbles: true, + composed: true, + }), + ) + } + + updated(changedProperties: Map) { + if (changedProperties.has('experiences')) { + this._internals.setFormValue(JSON.stringify(this.experiences ?? [])) + } + } + + private addItem = () => { + const next = [ + ...this.experiences, + { company: '', url: '', startYear: new Date().getFullYear() }, + ] + this.dispatchChange(next) + } + + private removeItem(index: number) { + const next = this.experiences.filter((_, i) => i !== index) + this.dispatchChange(next) + } + + private updateItem(index: number, patch: Partial) { + const next = [...this.experiences] + next[index] = { ...next[index], ...patch } + this.dispatchChange(next) + } + + render() { + return html` + + + +
+ ${ + this.experiences.length === 0 + ? html`

経歴がまだありません。

` + : this.experiences.map( + (exp, index) => html` + + + +
+ + this.updateItem(index, { company: e.detail })} + > + + + this.updateItem(index, { url: e.detail })} + > + + + this.updateItem(index, { + startYear: Number(e.detail || '0'), + })} + > + + + this.updateItem(index, { + endYear: e.detail ? Number(e.detail) : undefined, + })} + > +
+
+ `, + ) + } +
+
+ ` + } + + static styles = [ + adminFormStyles, + css` + :host { + display: block; + } + + .stack { + display: grid; + gap: 20px; + } + + .grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } + + .empty-text { + color: var(--color-text-tertiary); + font-size: 14px; + font-style: italic; + } + `, + ] +} + +declare global { + interface HTMLElementTagNameMap { + 'me-profile-experiences-editor': MeProfileExperiencesEditor + } +} diff --git a/frontend/src/components/admin/profile/me-profile-links-editor.ts b/frontend/src/components/admin/profile/me-profile-links-editor.ts new file mode 100644 index 0000000..8ad4346 --- /dev/null +++ b/frontend/src/components/admin/profile/me-profile-links-editor.ts @@ -0,0 +1,145 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { adminFormStyles } from '../../../admin/admin-form-styles.js' +import type { MeLink } from '../../../admin/types.js' +import '../ui/me-admin-panel.js' +import '../ui/me-admin-section.js' +import '../ui/me-text-input.js' + +@customElement('me-profile-links-editor') +export class MeProfileLinksEditor extends LitElement { + static formAssociated = true + + @property({ type: Array }) links: MeLink[] = [] + @property() name = '' + + private _internals: ElementInternals + + constructor() { + super() + this._internals = this.attachInternals() + } + + private dispatchChange(next: MeLink[]) { + this.links = next + this._internals.setFormValue(JSON.stringify(next)) + + this.dispatchEvent( + new CustomEvent('change', { + detail: next, + bubbles: true, + composed: true, + }), + ) + } + + updated(changedProperties: Map) { + if (changedProperties.has('links')) { + this._internals.setFormValue(JSON.stringify(this.links ?? [])) + } + } + + private addItem = () => { + const next = [...this.links, { platform: '', url: '' }] + this.dispatchChange(next) + } + + private removeItem(index: number) { + const next = this.links.filter((_, i) => i !== index) + this.dispatchChange(next) + } + + private updateItem(index: number, patch: Partial) { + const next = [...this.links] + next[index] = { ...next[index], ...patch } + this.dispatchChange(next) + } + + render() { + return html` + + + +
+ ${ + this.links.length === 0 + ? html`

リンクがまだありません。

` + : this.links.map( + (link, index) => html` + + + +
+ + this.updateItem(index, { platform: e.detail })} + > + + + this.updateItem(index, { url: e.detail })} + > +
+
+ `, + ) + } +
+
+ ` + } + + static styles = [ + adminFormStyles, + css` + :host { + display: block; + } + + .stack { + display: grid; + gap: 20px; + } + + .grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } + + .empty-text { + color: var(--color-text-tertiary); + font-size: 14px; + font-style: italic; + } + `, + ] +} + +declare global { + interface HTMLElementTagNameMap { + 'me-profile-links-editor': MeProfileLinksEditor + } +} diff --git a/frontend/src/components/admin/profile/me-profile-skills-editor.ts b/frontend/src/components/admin/profile/me-profile-skills-editor.ts new file mode 100644 index 0000000..1bc3855 --- /dev/null +++ b/frontend/src/components/admin/profile/me-profile-skills-editor.ts @@ -0,0 +1,173 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' +import { adminFormStyles } from '../../../admin/admin-form-styles.js' +import type { MeSkillGroup } from '../../../admin/types.js' +import '../ui/me-admin-panel.js' +import '../ui/me-admin-section.js' +import '../ui/me-text-input.js' +import '../ui/me-textarea.js' + +@customElement('me-profile-skills-editor') +export class MeProfileSkillsEditor extends LitElement { + static formAssociated = true + + @property({ type: Array }) skills: MeSkillGroup[] = [] + @property() name = '' + + private _internals: ElementInternals + + constructor() { + super() + this._internals = this.attachInternals() + } + + private dispatchChange(nextSkills: MeSkillGroup[]) { + this.skills = nextSkills + this._internals.setFormValue(JSON.stringify(nextSkills)) + + this.dispatchEvent( + new CustomEvent('change', { + detail: nextSkills, + bubbles: true, + composed: true, + }), + ) + } + + updated(changedProperties: Map) { + if (changedProperties.has('skills')) { + this._internals.setFormValue(JSON.stringify(this.skills ?? [])) + } + } + + private addSkill = () => { + const nextSkills = [ + ...this.skills, + { category: '', items: [], sortOrder: this.skills.length }, + ] + this.dispatchChange(nextSkills) + } + + private removeSkill(index: number) { + const nextSkills = this.skills.filter((_, i) => i !== index) + this.dispatchChange(nextSkills) + } + + private updateSkill(index: number, patch: Partial) { + const nextSkills = [...this.skills] + nextSkills[index] = { ...nextSkills[index], ...patch } + this.dispatchChange(nextSkills) + } + + private splitLines(value: string) { + return value + .split('\n') + .map((item) => item.trim()) + .filter(Boolean) + } + + render() { + return html` + + + +
+ ${ + this.skills.length === 0 + ? html`

まだ skill カテゴリがありません。

` + : this.skills.map( + (skill, index) => html` + + + +
+ + this.updateSkill(index, { category: e.detail })} + > + + + this.updateSkill(index, { + sortOrder: Number(e.detail || '0'), + })} + > + + + this.updateSkill(index, { + items: this.splitLines(e.detail), + })} + > +
+
+ `, + ) + } +
+
+ ` + } + + static styles = [ + adminFormStyles, + css` + :host { + display: block; + } + + .stack { + display: grid; + gap: 20px; + } + + .grid { + display: grid; + gap: 16px; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + } + + .field-wide { + grid-column: 1 / -1; + } + + .empty-text { + color: var(--color-text-tertiary); + font-size: 14px; + font-style: italic; + } + `, + ] +} + +declare global { + interface HTMLElementTagNameMap { + 'me-profile-skills-editor': MeProfileSkillsEditor + } +} diff --git a/frontend/src/components/admin/ui/me-admin-panel.ts b/frontend/src/components/admin/ui/me-admin-panel.ts new file mode 100644 index 0000000..3065123 --- /dev/null +++ b/frontend/src/components/admin/ui/me-admin-panel.ts @@ -0,0 +1,63 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' + +@customElement('me-admin-panel') +export class MeAdminPanel extends LitElement { + @property() title = '' + + render() { + return html` +
+
+

${this.title}

+
+ +
+
+
+ +
+
+ ` + } + + static styles = css` + :host { + display: block; + } + + .panel { + display: grid; + gap: 16px; + border: 1px solid var(--color-border-subtle); + background: var(--color-bg-surface); + padding: 20px; + } + + .header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + flex-wrap: wrap; + } + + .title { + font-size: 14px; + font-weight: 500; + color: var(--color-text-secondary); + margin: 0; + } + + .content { + display: grid; + gap: 16px; + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'me-admin-panel': MeAdminPanel + } +} diff --git a/frontend/src/components/admin/ui/me-admin-section.ts b/frontend/src/components/admin/ui/me-admin-section.ts new file mode 100644 index 0000000..0b6a6dc --- /dev/null +++ b/frontend/src/components/admin/ui/me-admin-section.ts @@ -0,0 +1,83 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' + +@customElement('me-admin-section') +export class MeAdminSection extends LitElement { + @property() title = '' + @property() description = '' + + render() { + return html` +
+
+
+

${this.title}

+ ${ + this.description + ? html`

${this.description}

` + : null + } +
+
+ +
+
+
+ +
+
+ ` + } + + static styles = css` + :host { + display: block; + } + + .section { + display: grid; + gap: 16px; + padding: 24px; + border: 1px solid var(--color-border); + background: var(--color-bg-surface); + } + + .header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: center; + flex-wrap: wrap; + } + + .copy { + display: grid; + gap: 6px; + } + + .title { + font-size: 16px; + font-weight: 500; + color: var(--color-text-primary); + margin: 0; + } + + .description { + color: var(--color-text-tertiary); + font-size: 13px; + line-height: 1.8; + margin: 0; + } + + .content { + display: grid; + gap: 20px; + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'me-admin-section': MeAdminSection + } +} diff --git a/frontend/src/components/admin/ui/me-auth-guard.ts b/frontend/src/components/admin/ui/me-auth-guard.ts new file mode 100644 index 0000000..c16a548 --- /dev/null +++ b/frontend/src/components/admin/ui/me-auth-guard.ts @@ -0,0 +1,76 @@ +import { consume } from '@lit/context' +import { css, html, LitElement, nothing } from 'lit' +import { customElement } from 'lit/decorators.js' +import { authContext } from '../../../contexts/auth-context.js' +import { RepositoryObserver } from '../../../controllers/RepositoryObserver.js' +import type { IAuthRepository } from '../../../domain/AuthRepository.js' + +/** + * A declarative component that protects its content based on authentication status. + */ +@customElement('me-auth-guard') +export class MeAuthGuard extends LitElement { + @consume({ context: authContext, subscribe: true }) + set authRepo(repo: IAuthRepository) { + if (this._authRepo === repo) return + this._authRepo = repo + this._observer?.disconnect() + if (repo) this._observer = new RepositoryObserver(this, repo) + } + get authRepo() { + return this._authRepo + } + private _authRepo!: IAuthRepository + private _observer?: RepositoryObserver + + connectedCallback() { + super.connectedCallback() + this.checkSession() + } + + private async checkSession() { + if (this.authRepo?.status === 'unknown') { + await this.authRepo.refreshSession() + } + } + + render() { + const status = this.authRepo?.status + + if (status === 'checking' || status === 'unknown') { + return html` +
+

認証状態を確認しています...

+
+ ` + } + + if (status === 'authenticated') { + return html`` + } + + // Guest or failed - render nothing, parent orchestrator will handle redirect + return nothing + } + + static styles = css` + :host { + display: contents; + } + + .guard-status { + min-height: 60dvh; + display: grid; + place-items: center; + color: var(--color-text-secondary); + font-size: 15px; + letter-spacing: var(--tracking-wide); + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'me-auth-guard': MeAuthGuard + } +} diff --git a/frontend/src/components/admin/ui/me-select.ts b/frontend/src/components/admin/ui/me-select.ts new file mode 100644 index 0000000..62f9295 --- /dev/null +++ b/frontend/src/components/admin/ui/me-select.ts @@ -0,0 +1,163 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' + +@customElement('me-select') +export class MeSelect extends LitElement { + static formAssociated = true + + @property({ reflect: true }) label = '' + @property({ reflect: true }) name = '' + @property({ reflect: true }) value = '' + @property({ type: Boolean, reflect: true }) disabled = false + @property({ type: Boolean, reflect: true }) required = false + + private _internals: ElementInternals + private _inputId = `me-input-${Math.random().toString(36).slice(2, 9)}` + + constructor() { + super() + this._internals = this.attachInternals() + } + + protected createRenderRoot() { + return this.attachShadow({ mode: 'open', delegatesFocus: true }) + } + + formResetCallback() { + this.value = '' + this._syncInternals() + } + + formDisabledCallback(disabled: boolean) { + this.disabled = disabled + } + + private _onChange(e: Event) { + const select = e.target as HTMLSelectElement + this.value = select.value + this._syncInternals() + + this.dispatchEvent( + new CustomEvent('change', { + detail: select.value, + bubbles: true, + composed: true, + }), + ) + } + + protected updated(changedProperties: Map) { + if (changedProperties.has('value')) { + this._syncInternals() + } + } + + private _syncInternals() { + this._internals.setFormValue(this.value ?? '') + const select = this.shadowRoot?.querySelector('select') + if (select) { + this._internals.setValidity( + select.validity, + select.validationMessage, + select, + ) + this._internals.ariaInvalid = select.checkValidity() ? 'false' : 'true' + this._internals.ariaRequired = this.required ? 'true' : 'false' + } + } + + render() { + return html` +
+ ${ + this.label + ? html`` + : null + } +
+ +
+
+ ` + } + + static styles = css` + :host { + display: block; + } + + .field { + display: grid; + gap: 6px; + } + + .label { + font-size: 13px; + font-weight: 400; + color: var(--color-text-secondary); + cursor: pointer; + } + + .select-wrapper { + position: relative; + display: grid; + } + + select { + width: 100%; + height: 40px; + padding: 0 32px 0 12px; + border: 1px solid var(--color-border); + background: #ffffff; + color: var(--color-text-primary); + font-family: inherit; + font-size: 14px; + border-radius: 4px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + outline: none; + appearance: none; + cursor: pointer; + } + + .select-wrapper::after { + content: ""; + position: absolute; + right: 12px; + top: 50%; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 5px solid var(--color-text-tertiary); + transform: translateY(-50%); + pointer-events: none; + } + + select:focus { + border-color: var(--admin-accent); + box-shadow: 0 0 0 1px var(--admin-accent); + } + + :host([disabled]) select { + background: var(--color-bg-deep); + color: var(--color-text-tertiary); + cursor: not-allowed; + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'me-select': MeSelect + } +} diff --git a/frontend/src/components/admin/ui/me-text-input.ts b/frontend/src/components/admin/ui/me-text-input.ts new file mode 100644 index 0000000..f6bb88b --- /dev/null +++ b/frontend/src/components/admin/ui/me-text-input.ts @@ -0,0 +1,176 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' + +/** + * A standard-compliant, form-associated text input component. + * Fully leverages ElementInternals and delegatesFocus for a native feel. + * Targets WCAG 2.1 AA compliance. + */ +@customElement('me-text-input') +export class MeTextInput extends LitElement { + static formAssociated = true + + @property({ reflect: true }) label = '' + @property({ reflect: true }) name = '' + @property({ reflect: true }) autocomplete = '' + @property({ reflect: true }) value: string | number = '' + @property({ reflect: true }) type: + | 'text' + | 'number' + | 'email' + | 'password' + | 'url' + | 'datetime-local' + | 'search' = 'text' + @property({ type: Boolean, reflect: true }) disabled = false + @property({ type: Boolean, reflect: true }) required = false + @property({ type: Boolean, reflect: true }) readonly = false + @property({ reflect: true }) placeholder = '' + + private _internals: ElementInternals + private _inputId = `me-input-${Math.random().toString(36).slice(2, 9)}` + + constructor() { + super() + this._internals = this.attachInternals() + } + + protected createRenderRoot() { + return this.attachShadow({ mode: 'open', delegatesFocus: true }) + } + + // --- Form Association Callbacks --- + + formResetCallback() { + this.value = '' + this._syncInternals() + } + + formDisabledCallback(disabled: boolean) { + this.disabled = disabled + } + + private _onInput(e: Event) { + const input = e.target as HTMLInputElement + this.value = input.value + this._syncInternals() + + this.dispatchEvent( + new CustomEvent('change', { + detail: input.value, + bubbles: true, + composed: true, + }), + ) + } + + protected updated(changedProperties: Map) { + if (changedProperties.has('value')) { + this._syncInternals() + } + } + + private _syncInternals() { + const val = String(this.value ?? '') + this._internals.setFormValue(val) + + const input = this.shadowRoot?.querySelector('input') + if (input) { + // Synchronize native validation to the component's internal state + const isValid = input.checkValidity() + this._internals.setValidity( + input.validity, + input.validationMessage, + input, + ) + + // Update ARIA attributes via internals + this._internals.ariaInvalid = isValid ? 'false' : 'true' + this._internals.ariaRequired = this.required ? 'true' : 'false' + } + } + + render() { + return html` +
+ ${ + this.label + ? html`` + : null + } + +
+ ` + } + + static styles = css` + :host { + display: block; + } + + .field { + display: grid; + gap: 6px; + } + + .label { + font-size: 13px; + font-weight: 400; + color: var(--color-text-secondary); + cursor: pointer; + } + + input { + width: 100%; + height: 40px; + padding: 0 12px; + border: 1px solid var(--color-border); + background: #ffffff; + color: var(--color-text-primary); + font-family: inherit; + font-size: 14px; + border-radius: 4px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + outline: none; + } + + input:focus { + border-color: var(--admin-accent); + box-shadow: 0 0 0 1px var(--admin-accent); + } + + :host([disabled]) input { + background: var(--color-bg-deep); + color: var(--color-text-tertiary); + cursor: not-allowed; + } + + input[readonly] { + background: var(--color-bg-dim); + border-color: var(--color-border-subtle); + } + + /* Validation styles */ + input:invalid:not(:placeholder-shown) { + border-color: var(--color-danger); + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'me-text-input': MeTextInput + } +} diff --git a/frontend/src/components/admin/ui/me-textarea.ts b/frontend/src/components/admin/ui/me-textarea.ts new file mode 100644 index 0000000..6b027ae --- /dev/null +++ b/frontend/src/components/admin/ui/me-textarea.ts @@ -0,0 +1,150 @@ +import { css, html, LitElement } from 'lit' +import { customElement, property } from 'lit/decorators.js' + +/** + * A standard-compliant, form-associated textarea component. + */ +@customElement('me-textarea') +export class MeTextarea extends LitElement { + static formAssociated = true + + @property({ reflect: true }) label = '' + @property({ reflect: true }) name = '' + @property({ type: Number, reflect: true }) rows = 4 + @property({ reflect: true }) value = '' + @property({ type: Boolean, reflect: true }) disabled = false + @property({ type: Boolean, reflect: true }) required = false + @property({ reflect: true }) placeholder = '' + + private _internals: ElementInternals + private _inputId = `me-input-${Math.random().toString(36).slice(2, 9)}` + + constructor() { + super() + this._internals = this.attachInternals() + } + + protected createRenderRoot() { + return this.attachShadow({ mode: 'open', delegatesFocus: true }) + } + + formResetCallback() { + this.value = '' + this._syncInternals() + } + + formDisabledCallback(disabled: boolean) { + this.disabled = disabled + } + + private _onInput(e: Event) { + const input = e.target as HTMLTextAreaElement + this.value = input.value + this._syncInternals() + + this.dispatchEvent( + new CustomEvent('change', { + detail: input.value, + bubbles: true, + composed: true, + }), + ) + } + + protected updated(changedProperties: Map) { + if (changedProperties.has('value')) { + this._syncInternals() + } + } + + private _syncInternals() { + this._internals.setFormValue(this.value ?? '') + const input = this.shadowRoot?.querySelector('textarea') + if (input) { + this._internals.setValidity( + input.validity, + input.validationMessage, + input, + ) + this._internals.ariaInvalid = input.checkValidity() ? 'false' : 'true' + this._internals.ariaRequired = this.required ? 'true' : 'false' + } + } + + render() { + return html` +
+ ${ + this.label + ? html`` + : null + } + +
+ ` + } + + static styles = css` + :host { + display: block; + } + + .field { + display: grid; + gap: 6px; + } + + .label { + font-size: 13px; + font-weight: 400; + color: var(--color-text-secondary); + cursor: pointer; + } + + textarea { + width: 100%; + padding: 10px 12px; + border: 1px solid var(--color-border); + background: #ffffff; + color: var(--color-text-primary); + font-family: inherit; + font-size: 14px; + line-height: 1.6; + border-radius: 4px; + transition: border-color 0.2s ease, box-shadow 0.2s ease; + outline: none; + resize: vertical; + } + + textarea:focus { + border-color: var(--admin-accent); + box-shadow: 0 0 0 1px var(--admin-accent); + } + + :host([disabled]) textarea { + background: var(--color-bg-deep); + color: var(--color-text-tertiary); + cursor: not-allowed; + } + + textarea:invalid:not(:placeholder-shown) { + border-color: var(--color-danger); + } + ` +} + +declare global { + interface HTMLElementTagNameMap { + 'me-textarea': MeTextarea + } +} diff --git a/frontend/src/components/app-admin-shell.ts b/frontend/src/components/app-admin-shell.ts index 9d95d33..4998b63 100644 --- a/frontend/src/components/app-admin-shell.ts +++ b/frontend/src/components/app-admin-shell.ts @@ -13,7 +13,7 @@ export class AppAdminShell extends LitElement implements RouteShellElement { currentPath = '/admin' @property({ type: Boolean }) - busy = false + isChecking = false render() { return html` @@ -38,7 +38,7 @@ export class AppAdminShell extends LitElement implements RouteShellElement { }
${ - this.busy + this.isChecking ? html`

セッションを確認しています...

` : null } @@ -64,34 +64,8 @@ export class AppAdminShell extends LitElement implements RouteShellElement { routeShellStyles, css` :host { - /* デザイントークンを admin 用に上書き */ - --font-en: system-ui, -apple-system, sans-serif; - --font-jp: system-ui, -apple-system, sans-serif; - --color-bg-top: #f5f5f5; - --color-bg-bottom: #ffffff; - --color-text-primary: #1a1a1a; - --color-text-secondary: #6b6b6b; - --color-text-tertiary: #9a9a9a; - --color-border: #d9d9d9; - --color-border-light: #e8e8e8; - --color-surface: #f0f0f0; - --tracking-wide: 0.02em; - --tracking-wider: 0.04em; - /* admin 専用トークン */ - --admin-accent: #0057b8; - --admin-accent-hover: #004494; - --admin-sidebar-width: 220px; - /* セマンティックカラートークン */ - --color-danger: #c0392b; - --color-danger-bg: rgba(192, 57, 43, 0.06); - --color-success: #3d7a56; - --color-success-bg: rgba(61, 122, 86, 0.06); - --color-notice: #5a6b85; - --color-notice-bg: rgba(90, 107, 133, 0.08); - display: block; - background: var(--color-bg-top); - font-family: var(--font-jp); + min-height: 100dvh; } .layout { @@ -119,14 +93,14 @@ export class AppAdminShell extends LitElement implements RouteShellElement { font-family: var(--font-jp); font-size: 14px; font-weight: 400; - letter-spacing: var(--tracking-wide); + letter-spacing: var(--tracking-tight); color: var(--color-text-secondary); border-radius: 4px; transition: background 0.15s ease, color 0.15s ease; } .sidebar a:hover { - background: var(--color-surface); + background: var(--color-bg-surface); color: var(--color-text-primary); } @@ -165,7 +139,7 @@ export class AppAdminShell extends LitElement implements RouteShellElement { #outlet { padding: 28px 24px 48px; - background: var(--color-bg-top); + background: var(--color-bg-deep); } } `, diff --git a/frontend/src/components/app-root.ts b/frontend/src/components/app-root.ts index a5ccd11..474d2f3 100644 --- a/frontend/src/components/app-root.ts +++ b/frontend/src/components/app-root.ts @@ -1,26 +1,20 @@ +import { provide } from '@lit/context' import { Router, Routes } from '@lit-labs/router' import type { PropertyValues } from 'lit' -import { css, html, LitElement, nothing } from 'lit' +import { css, html, LitElement } from 'lit' import { customElement, state } from 'lit/decorators.js' -import { - changeEmail, - login, - logout, - refreshSession, - revokeAllSessions, -} from '../admin/auth-api.js' -import { getMe, updateMe } from '../admin/me-api.js' -import { - type AdminLoginInput, - ApiError, - type ChangeEmailInput, - createEmptyMeProfile, - describeApiError, - type MeProfile, -} from '../admin/types.js' -import type { RouteShellElement } from './route-shell.js' +import { articleContext } from '../contexts/article-context.js' +import { authContext } from '../contexts/auth-context.js' +import { profileContext } from '../contexts/profile-context.js' +import { RepositoryObserver } from '../controllers/RepositoryObserver.js' +import { ArticleRepository } from '../domain/ArticleRepository.js' +import { AuthRepository } from '../domain/AuthRepository.js' +import { ProfileRepository } from '../domain/ProfileRepository.js' +import { setupCursor } from '../utils/cursor.js' +import { setupBackgroundShift } from '../utils/scroll.js' import './app-admin-shell.js' import './app-public-shell.js' +import '../components/admin/ui/me-auth-guard.js' import '../pages/page-admin-account.js' import '../pages/page-admin-articles.js' import '../pages/page-admin-dashboard.js' @@ -30,154 +24,94 @@ import '../pages/page-about.js' import '../pages/page-articles.js' import '../pages/page-not-found.js' import '../pages/page-top.js' -import { setupCursor } from '../utils/cursor.js' -import { setupBackgroundShift } from '../utils/scroll.js' +import type { RouteShellElement } from './route-shell.js' @customElement('app-root') export class AppRoot extends LitElement { - @state() - private currentPath = window.location.pathname - - @state() - private adminSessionStatus: - | 'unknown' - | 'checking' - | 'authenticated' - | 'guest' = 'unknown' - - @state() - private adminLoginPending = false - - @state() - private adminLoginError = '' - - @state() - private adminLoginNotice = '' - - @state() - private publicProfile: MeProfile | null = null - - @state() - private publicProfileLoading = false - - @state() - private adminProfile = createEmptyMeProfile() - - @state() - private adminProfileLoading = false - - @state() - private adminProfileSaving = false - - @state() - private adminProfileLoaded = false - - @state() - private adminProfileError = '' - - @state() - private adminProfileSuccess = '' + @provide({ context: authContext }) + auth = new AuthRepository() - @state() - private adminProfileDirty = false + @provide({ context: profileContext }) + profile = new ProfileRepository() - @state() - private adminArticlesDirty = false + @provide({ context: articleContext }) + article = new ArticleRepository() @state() - private adminAccountBusyAction = '' - - @state() - private adminAccountError = '' - - @state() - private adminAccountSuccess = '' + private currentPath = window.location.pathname private cleanups: Array<() => void> = [] private router = new Router(this, []) private adminReturnPath = '/admin' - private adminSessionBootstrap?: Promise + private _abortController?: AbortController + + constructor() { + super() + // Adapter logic connecting Domain to Lit + new RepositoryObserver(this, this.auth) + new RepositoryObserver(this, this.profile) + new RepositoryObserver(this, this.article) + } + private onPopState = () => { this.currentPath = window.location.pathname } + private publicRoutes = new Routes(this, [ - { - path: '/', - render: () => - html``, - }, + { path: '/', render: () => html`` }, { path: '/articles', render: () => html`` }, - { - path: '/about', - render: () => - html``, - }, + { path: '/about', render: () => html`` }, { path: '/*', render: () => html`` }, ]) + private adminRoutes = new Routes(this, [ { path: '/admin/login', - render: () => this.renderAdminLogin(), + render: () => html``, }, { path: '/admin', - render: () => - this.renderProtectedAdmin( - html``, - ), + render: () => html` + + + + `, }, { path: '/admin/profile', - render: () => - this.renderProtectedAdmin( - html``, - ), + render: () => html` + + + + `, }, { path: '/admin/articles', - render: () => - this.renderProtectedAdmin( - html``, - ), + render: () => html` + + + + `, }, { path: '/admin/account', - render: () => - this.renderProtectedAdmin( - html``, - ), + render: () => html` + + + + `, }, { path: '/*', render: () => html`` }, ]) render() { - return this.isAdminPath(this.currentPath) + const isAdmin = this.isAdminPath(this.currentPath) + const status = this.auth.status + + return isAdmin ? html`${this.adminRoutes.outlet()}` @@ -187,81 +121,141 @@ export class AppRoot extends LitElement { connectedCallback() { super.connectedCallback() window.addEventListener('popstate', this.onPopState) + + // Initialize memory safety + this._abortController = new AbortController() + const signal = this._abortController.signal + + // Explicit domain subscriptions (Push) for navigation + this.auth.addEventListener( + 'auth:status-change', + () => this.handleAuthChange(), + { signal }, + ) + + this.updateVisualEffects() } - firstUpdated() { - this.cleanups.push(setupBackgroundShift()) - this.cleanups.push(setupCursor()) - this.cleanups.push(this.setupNavigation()) - void this.loadPublicProfile() - if (this.isAdminPath(this.currentPath)) { - void this.syncAdminRouteState() - } + disconnectedCallback() { + super.disconnectedCallback() + window.removeEventListener('popstate', this.onPopState) + this._abortController?.abort() + this.teardownVisualEffects() } protected updated(changedProperties: PropertyValues) { + if (changedProperties.has('currentPath')) { + this.updateVisualEffects() + void this.handleRouteChange() + } + } + + private handleAuthChange() { + const status = this.auth.status + const isLoginPath = this.currentPath === '/admin/login' + const isProtected = this.isProtectedAdminPath(this.currentPath) + + if (status === 'authenticated' && isLoginPath) { + void this.navigateToPath(this.adminReturnPath, true, true) + } else if (status === 'guest' && isProtected) { + this.adminReturnPath = this.currentPath + void this.navigateToPath('/admin/login', true, true) + } + } + + private async handleRouteChange() { + if (this.currentPath === '/admin/login') { + await this.auth.refreshSession() + return + } if ( - changedProperties.has('currentPath') && - this.isAdminPath(this.currentPath) + this.isProtectedAdminPath(this.currentPath) && + this.auth.status === 'unknown' ) { - void this.syncAdminRouteState() + await this.auth.refreshSession() } } + private updateVisualEffects() { + if (typeof window === 'undefined') return + + const theme = this.isAdminPath(this.currentPath) ? 'admin' : 'public' + document.documentElement.setAttribute('data-theme', theme) + + this.teardownVisualEffects() + + if (!this.isAdminPath(this.currentPath)) { + this.cleanups.push(setupBackgroundShift()) + this.cleanups.push(setupCursor()) + } + this.cleanups.push(this.setupNavigation()) + } + + private teardownVisualEffects() { + for (const cleanup of this.cleanups) cleanup() + this.cleanups = [] + } + + firstUpdated() { + void this.profile.loadPublicProfile() + } + private setupNavigation(): () => void { const onClick = async (e: Event) => { - if ( - e.defaultPrevented || - (e instanceof MouseEvent && - (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)) - ) { - return - } - const anchor = (e.composedPath() as Element[]).find( - (el) => (el as HTMLElement).tagName === 'A', - ) as HTMLAnchorElement | undefined - - if ( - !anchor?.href || - (anchor.target && anchor.target !== '_self') || - anchor.hasAttribute('download') - ) - return - const url = new URL(anchor.href) - if (url.origin !== location.origin) return + if (this.shouldPreventNavigation(e)) return + const anchor = this.findAnchor(e) + if (!anchor) return e.preventDefault() - - const reduced = window.matchMedia( - '(prefers-reduced-motion: reduce)', - ).matches - if (reduced) { - await this.navigate(anchor) - return - } - - const shell = this.shadowRoot?.querySelector( - 'app-public-shell, app-admin-shell', - ) as RouteShellElement | null - if (shell) { - const ready = await shell.playLeaveTransition() + if (!this.isReducedMotion()) { + const ready = await this.playTransition() if (!ready) return } - await this.navigate(anchor) } - this.shadowRoot?.addEventListener('click', onClick) return () => this.shadowRoot?.removeEventListener('click', onClick) } + private shouldPreventNavigation(e: Event) { + return ( + e.defaultPrevented || + (e instanceof MouseEvent && + (e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey)) + ) + } + + private findAnchor(e: Event) { + const anchor = (e.composedPath() as Element[]).find( + (el) => (el as HTMLElement).tagName === 'A', + ) as HTMLAnchorElement | undefined + if ( + !anchor?.href || + (anchor.target && anchor.target !== '_self') || + anchor.hasAttribute('download') + ) + return null + const url = new URL(anchor.href) + return url.origin === location.origin ? anchor : null + } + + private isReducedMotion() { + return window.matchMedia('(prefers-reduced-motion: reduce)').matches + } + + private async playTransition() { + const shell = this.shadowRoot?.querySelector( + 'app-public-shell, app-admin-shell', + ) as RouteShellElement | null + return shell ? await shell.playLeaveTransition() : true + } + private isAdminPath(pathname: string) { return pathname === '/admin' || pathname.startsWith('/admin/') } private async navigate(anchor: HTMLAnchorElement) { if (anchor.href === location.href) return - await this.navigateToPath(new URL(anchor.href).pathname) } @@ -271,20 +265,15 @@ export class AppRoot extends LitElement { force = false, ) { if (pathname === this.currentPath) return - if ( !force && this.shouldConfirmAdminNavigation(pathname) && !window.confirm('未保存の変更があります。ページを移動してもよいですか?') - ) { + ) return - } - if (replace) { - window.history.replaceState({}, '', pathname) - } else { - window.history.pushState({}, '', pathname) - } + if (replace) window.history.replaceState({}, '', pathname) + else window.history.pushState({}, '', pathname) this.currentPath = pathname await this.router.goto(pathname) @@ -294,263 +283,18 @@ export class AppRoot extends LitElement { return this.isAdminPath(pathname) && pathname !== '/admin/login' } - private async syncAdminRouteState() { - if (!this.isAdminPath(this.currentPath)) return - - if (this.currentPath === '/admin/login') { - if (this.adminSessionStatus === 'unknown') { - await this.bootstrapAdminSession() - } - if (this.adminSessionStatus === 'authenticated') { - await this.navigateToPath(this.adminReturnPath, true, true) - } - return - } - - if (!this.isProtectedAdminPath(this.currentPath)) return - - if (this.adminSessionStatus !== 'authenticated') { - this.adminReturnPath = this.currentPath - await this.bootstrapAdminSession() - } - - if (this.adminSessionStatus !== 'authenticated') { - if (!this.adminLoginNotice) { - this.adminLoginNotice = 'ログインしてください。' - } - await this.navigateToPath('/admin/login', true, true) - return - } - - if (this.currentPath === '/admin/profile' && !this.adminProfileLoaded) { - await this.loadAdminProfile() - } - } - - private async bootstrapAdminSession() { - if (this.adminSessionBootstrap) { - await this.adminSessionBootstrap - return - } - - this.adminSessionStatus = 'checking' - this.adminSessionBootstrap = (async () => { - try { - await refreshSession() - this.adminSessionStatus = 'authenticated' - this.adminLoginError = '' - this.adminLoginNotice = '' - } catch (error) { - const isUnauthorized = error instanceof ApiError && error.status === 401 - this.adminSessionStatus = 'guest' - if (isUnauthorized && this.currentPath !== '/admin/login') { - this.adminLoginNotice = - 'セッションの有効期限が切れました。再度ログインしてください。' - } - if (this.currentPath === '/admin/login' || isUnauthorized) { - this.adminLoginError = '' - } else { - this.adminLoginError = describeApiError(error) - } - } finally { - this.adminSessionBootstrap = undefined - } - })() - - await this.adminSessionBootstrap - } - - private renderAdminLogin() { - if (this.adminSessionStatus === 'checking') { - return this.renderAdminStatus('セッションを確認しています...') - } - - return html`` - } - - private renderProtectedAdmin(content: unknown) { - if ( - this.adminSessionStatus === 'checking' || - this.adminSessionStatus === 'unknown' - ) { - return this.renderAdminStatus('認証状態を確認しています...') - } - - if (this.adminSessionStatus !== 'authenticated') { - return nothing - } - - return content - } - - private renderAdminStatus(message: string) { - return html`
-

${message}

-
` - } - - private async handleAdminLogin(event: CustomEvent) { - this.adminLoginPending = true - this.adminLoginError = '' - try { - await login(event.detail) - this.adminSessionStatus = 'authenticated' - this.adminLoginNotice = '' - this.adminProfile = createEmptyMeProfile() - this.adminProfileLoaded = false - this.adminProfileDirty = false - this.adminArticlesDirty = false - this.adminProfileError = '' - this.adminProfileSuccess = '' - await this.navigateToPath(this.adminReturnPath) - } catch (error) { - this.adminLoginError = describeApiError(error) - } finally { - this.adminLoginPending = false - } - } - - private async loadPublicProfile() { - this.publicProfileLoading = true - try { - this.publicProfile = await getMe() - } catch { - // API 失敗時は null のまま。各ページ側で static フォールバックを表示する。 - } finally { - this.publicProfileLoading = false - } - } - - private async loadAdminProfile() { - this.adminProfileLoading = true - this.adminProfileError = '' - try { - this.adminProfile = await getMe() - this.adminProfileLoaded = true - this.adminProfileDirty = false - } catch (error) { - this.adminProfileError = describeApiError(error) - } finally { - this.adminProfileLoading = false - } - } - - private async handleAdminProfileSave(event: CustomEvent) { - this.adminProfileSaving = true - this.adminProfileError = '' - this.adminProfileSuccess = '' - - try { - this.adminProfile = await updateMe(event.detail) - this.adminProfileLoaded = true - this.adminProfileDirty = false - this.adminProfileSuccess = 'プロフィールを更新しました。' - } catch (error) { - this.adminProfileError = describeApiError(error) - } finally { - this.adminProfileSaving = false - } - } - - private async handleAdminLogout() { - this.adminAccountBusyAction = 'logout' - this.adminAccountError = '' - this.adminAccountSuccess = '' - try { - await logout() - this.adminSessionStatus = 'guest' - this.adminLoginNotice = 'ログアウトしました。' - this.adminProfileLoaded = false - this.adminProfileDirty = false - this.adminArticlesDirty = false - this.adminAccountSuccess = 'ログアウトしました。' - await this.navigateToPath('/admin/login', false, true) - } catch (error) { - this.adminAccountError = describeApiError(error) - } finally { - this.adminAccountBusyAction = '' - } - } - - private async handleAdminRevokeSessions() { - this.adminAccountBusyAction = 'revoke-sessions' - this.adminAccountError = '' - this.adminAccountSuccess = '' - try { - await revokeAllSessions() - this.adminSessionStatus = 'guest' - this.adminLoginNotice = - '全セッションを終了しました。必要に応じて再度ログインしてください。' - this.adminProfileLoaded = false - this.adminProfileDirty = false - this.adminArticlesDirty = false - this.adminAccountSuccess = '全セッションを失効させました。' - await this.navigateToPath('/admin/login', false, true) - } catch (error) { - this.adminAccountError = describeApiError(error) - } finally { - this.adminAccountBusyAction = '' - } - } - - private async handleAdminChangeEmail(event: CustomEvent) { - this.adminAccountBusyAction = 'change-email' - this.adminAccountError = '' - this.adminAccountSuccess = '' - try { - await changeEmail(event.detail) - this.adminAccountSuccess = 'メールアドレス変更を送信しました。' - } catch (error) { - this.adminAccountError = describeApiError(error) - } finally { - this.adminAccountBusyAction = '' - } - } - - private handleAdminProfileDirtyChange(event: CustomEvent) { - this.adminProfileDirty = event.detail - if (event.detail) { - this.adminProfileSuccess = '' - } - } - - private handleAdminArticlesDirtyChange(event: CustomEvent) { - this.adminArticlesDirty = event.detail - } - private shouldConfirmAdminNavigation(pathname: string) { + const isProfile = this.currentPath === '/admin/profile' + const isArticles = this.currentPath === '/admin/articles' return ( pathname !== this.currentPath && - ((this.adminProfileDirty && this.currentPath === '/admin/profile') || - (this.adminArticlesDirty && this.currentPath === '/admin/articles')) + ((this.profile.adminDirty && isProfile) || + (this.article.adminDirty && isArticles)) ) } - disconnectedCallback() { - super.disconnectedCallback() - window.removeEventListener('popstate', this.onPopState) - for (const cleanup of this.cleanups) cleanup() - this.cleanups = [] - } - static styles = css` - :host { - display: block; - } - - .admin-status { - min-height: 60dvh; - display: grid; - place-items: center; - color: var(--color-text-secondary); - font-size: 15px; - letter-spacing: var(--tracking-wide); - } + :host { display: block; } ` } diff --git a/frontend/src/contexts/article-context.ts b/frontend/src/contexts/article-context.ts new file mode 100644 index 0000000..ef59286 --- /dev/null +++ b/frontend/src/contexts/article-context.ts @@ -0,0 +1,5 @@ +import { createContext } from '@lit/context' +import type { IArticleRepository } from '../domain/ArticleRepository.js' + +export const articleContext = + createContext('article-context') diff --git a/frontend/src/contexts/auth-context.ts b/frontend/src/contexts/auth-context.ts new file mode 100644 index 0000000..7692297 --- /dev/null +++ b/frontend/src/contexts/auth-context.ts @@ -0,0 +1,4 @@ +import { createContext } from '@lit/context' +import type { IAuthRepository } from '../domain/AuthRepository.js' + +export const authContext = createContext('auth-context') diff --git a/frontend/src/contexts/profile-context.ts b/frontend/src/contexts/profile-context.ts new file mode 100644 index 0000000..0f0d6d4 --- /dev/null +++ b/frontend/src/contexts/profile-context.ts @@ -0,0 +1,5 @@ +import { createContext } from '@lit/context' +import type { IProfileRepository } from '../domain/ProfileRepository.js' + +export const profileContext = + createContext('profile-context') diff --git a/frontend/src/controllers/RepositoryObserver.ts b/frontend/src/controllers/RepositoryObserver.ts new file mode 100644 index 0000000..f9f2d72 --- /dev/null +++ b/frontend/src/controllers/RepositoryObserver.ts @@ -0,0 +1,59 @@ +import type { ReactiveController, ReactiveControllerHost } from 'lit' + +/** + * A standard adapter that observes a domain repository. + * Bridges the Domain's EventTarget to the UI's reactive lifecycle. + * Manages memory safety internally via AbortController. + */ +export class RepositoryObserver implements ReactiveController { + private _host: ReactiveControllerHost + private _repository: EventTarget + private _onChange?: () => void + private _abortController?: AbortController + + constructor( + host: ReactiveControllerHost, + repository: EventTarget, + onChange?: () => void, + ) { + this._host = host + this._repository = repository + this._onChange = onChange + host.addController(this) + } + + hostConnected() { + this.connect() + } + + hostDisconnected() { + this.disconnect() + } + + /** + * Starts observing the repository with a fresh AbortController. + */ + connect() { + this.disconnect() // Ensure previous is cleared + this._abortController = new AbortController() + + this._repository.addEventListener('change', this._onRepositoryChange, { + signal: this._abortController.signal, + }) + } + + /** + * Stops all observations. + */ + disconnect() { + this._abortController?.abort() + this._abortController = undefined + } + + private _onRepositoryChange = () => { + this._host.requestUpdate() + if (this._onChange) { + this._onChange() + } + } +} diff --git a/frontend/src/domain/ArticleRepository.ts b/frontend/src/domain/ArticleRepository.ts new file mode 100644 index 0000000..38d2da2 --- /dev/null +++ b/frontend/src/domain/ArticleRepository.ts @@ -0,0 +1,46 @@ +import { Repository } from './Repository.js' + +export interface ArticleEventMap { + 'article:admin-dirty-change': CustomEvent<{ dirty: boolean }> +} + +/** + * The public interface for ArticleRepository. + */ +export interface IArticleRepository extends EventTarget { + readonly adminDirty: boolean + + addEventListener( + type: K, + listener: (e: ArticleEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, + ): void + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void + + setAdminDirty(dirty: boolean): void +} + +export class ArticleRepository + extends Repository + implements IArticleRepository +{ + private _adminDirty = false + + get adminDirty() { + return this._adminDirty + } + + setAdminDirty(dirty: boolean) { + this._adminDirty = dirty + this.dispatchEvent( + new CustomEvent('article:admin-dirty-change', { + detail: { dirty }, + }), + ) + this.notifyChange() + } +} diff --git a/frontend/src/domain/AuthRepository.ts b/frontend/src/domain/AuthRepository.ts new file mode 100644 index 0000000..c540279 --- /dev/null +++ b/frontend/src/domain/AuthRepository.ts @@ -0,0 +1,195 @@ +import { + login as apiLogin, + logout as apiLogout, + refreshSession as apiRefreshSession, + revokeAllSessions as apiRevokeAllSessions, + changeEmail as apiChangeEmail, +} from '../admin/auth-api.js' +import { + ApiError, + type AdminLoginInput, + type ChangeEmailInput, + describeApiError, +} from '../admin/types.js' +import { Repository } from './Repository.js' + +export type AdminSessionStatus = + | 'unknown' + | 'checking' + | 'authenticated' + | 'guest' + +export interface AuthEventMap { + 'auth:status-change': CustomEvent<{ status: AdminSessionStatus }> +} + +/** + * The public interface for AuthRepository. + */ +export interface IAuthRepository extends EventTarget { + readonly status: AdminSessionStatus + readonly loginPending: boolean + readonly loginError: string + readonly loginNotice: string + readonly accountBusyAction: string + readonly accountError: string + readonly accountSuccess: string + + addEventListener( + type: K, + listener: (e: AuthEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, + ): void + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void + + login(input: AdminLoginInput): Promise + logout(): Promise + refreshSession(): Promise + revokeAllSessions(): Promise + changeEmail(input: ChangeEmailInput): Promise + clearLoginNotice(): void +} + +export class AuthRepository extends Repository implements IAuthRepository { + private _status: AdminSessionStatus = 'unknown' + private _loginPending = false + private _loginError = '' + private _loginNotice = '' + private _accountBusyAction = '' + private _accountError = '' + private _accountSuccess = '' + + private sessionBootstrap?: Promise + + get status() { + return this._status + } + get loginPending() { + return this._loginPending + } + get loginError() { + return this._loginError + } + get loginNotice() { + return this._loginNotice + } + get accountBusyAction() { + return this._accountBusyAction + } + get accountError() { + return this._accountError + } + get accountSuccess() { + return this._accountSuccess + } + + private dispatchStatusChange() { + this.dispatchEvent( + new CustomEvent('auth:status-change', { + detail: { status: this._status }, + }), + ) + this.notifyChange() + } + + async login(input: AdminLoginInput) { + this._loginPending = true + this._loginError = '' + this.notifyChange() + try { + await apiLogin(input) + this._status = 'authenticated' + this._loginNotice = '' + this._loginError = '' + this.dispatchStatusChange() + } catch (error) { + this._loginError = describeApiError(error) + this.notifyChange() + } finally { + this._loginPending = false + this.notifyChange() + } + } + + async logout() { + this._accountBusyAction = 'logout' + this._accountError = '' + this._accountSuccess = '' + this.notifyChange() + try { + await apiLogout() + this._status = 'guest' + this._loginNotice = 'ログアウトしました。' + this._accountSuccess = 'ログアウトしました。' + this.dispatchStatusChange() + } catch (error) { + this._accountError = describeApiError(error) + this.notifyChange() + } finally { + this._accountBusyAction = '' + this.notifyChange() + } + } + + async refreshSession() { + if (this.sessionBootstrap) return this.sessionBootstrap + this._status = 'checking' + this.dispatchStatusChange() + this.sessionBootstrap = (async () => { + try { + await apiRefreshSession() + this._status = 'authenticated' + this.dispatchStatusChange() + } catch (error) { + const isUnauthorized = error instanceof ApiError && error.status === 401 + this._status = 'guest' + if (isUnauthorized) this._loginNotice = 'セッションが切れました。' + this.dispatchStatusChange() + } finally { + this.sessionBootstrap = undefined + } + })() + return this.sessionBootstrap + } + + async revokeAllSessions() { + this._accountBusyAction = 'revoke-sessions' + this.notifyChange() + try { + await apiRevokeAllSessions() + this._status = 'guest' + this.dispatchStatusChange() + } catch (error) { + this._accountError = describeApiError(error) + this.notifyChange() + } finally { + this._accountBusyAction = '' + this.notifyChange() + } + } + + async changeEmail(input: ChangeEmailInput) { + this._accountBusyAction = 'change-email' + this.notifyChange() + try { + await apiChangeEmail(input) + this._accountSuccess = 'メールアドレス変更を送信しました。' + this.notifyChange() + } catch (error) { + this._accountError = describeApiError(error) + this.notifyChange() + } finally { + this._accountBusyAction = '' + this.notifyChange() + } + } + + clearLoginNotice() { + this._loginNotice = '' + this.notifyChange() + } +} diff --git a/frontend/src/domain/ProfileRepository.ts b/frontend/src/domain/ProfileRepository.ts new file mode 100644 index 0000000..d623209 --- /dev/null +++ b/frontend/src/domain/ProfileRepository.ts @@ -0,0 +1,183 @@ +import { getMe, updateMe } from '../admin/me-api.js' +import { + createEmptyMeProfile, + describeApiError, + type MeProfile, +} from '../admin/types.js' +import { Repository } from './Repository.js' + +export interface ProfileEventMap { + 'profile:public-change': CustomEvent<{ profile: MeProfile | null }> + 'profile:admin-change': CustomEvent<{ profile: MeProfile }> +} + +/** + * The public interface for ProfileRepository. + */ +export interface IProfileRepository extends EventTarget { + readonly publicProfile: MeProfile | null + readonly publicLoading: boolean + readonly adminProfile: MeProfile + readonly adminLoading: boolean + readonly adminSaving: boolean + readonly adminLoaded: boolean + readonly adminError: string + readonly adminSuccess: string + readonly adminDirty: boolean + + addEventListener( + type: K, + listener: (e: ProfileEventMap[K]) => void, + options?: boolean | AddEventListenerOptions, + ): void + addEventListener( + type: string, + callback: EventListenerOrEventListenerObject | null, + options?: boolean | AddEventListenerOptions, + ): void + + loadPublicProfile(): Promise + loadAdminProfile(): Promise + saveAdminProfile(profile: MeProfile): Promise + setAdminDirty(dirty: boolean): void +} + +export class ProfileRepository + extends Repository + implements IProfileRepository +{ + private _publicProfile: MeProfile | null = null + private _publicLoading = false + private _adminProfile = createEmptyMeProfile() + private _adminLoading = false + private _adminSaving = false + private _adminLoaded = false + private _adminError = '' + private _adminSuccess = '' + private _adminDirty = false + + private _fetchPromise: Promise | null = null + + get publicProfile() { + return this._publicProfile + } + get publicLoading() { + return this._publicLoading + } + get adminProfile() { + return this._adminProfile + } + get adminLoading() { + return this._adminLoading + } + get adminSaving() { + return this._adminSaving + } + get adminLoaded() { + return this._adminLoaded + } + get adminError() { + return this._adminError + } + get adminSuccess() { + return this._adminSuccess + } + get adminDirty() { + return this._adminDirty + } + + private notifyPublicChange() { + this.dispatchEvent( + new CustomEvent('profile:public-change', { + detail: { profile: this._publicProfile }, + }), + ) + this.notifyChange() + } + + private notifyAdminChange() { + this.dispatchEvent( + new CustomEvent('profile:admin-change', { + detail: { profile: this._adminProfile }, + }), + ) + this.notifyChange() + } + + async loadPublicProfile() { + if (this._publicProfile || this._publicLoading) return + this._publicLoading = true + this.notifyChange() + try { + await this._internalFetch() + this.notifyPublicChange() + } catch { + // Fallback handled by components + } finally { + this._publicLoading = false + this.notifyChange() + } + } + + async loadAdminProfile() { + if (this._adminLoaded || this._adminLoading) return + this._adminLoading = true + this._adminError = '' + this.notifyChange() + try { + await this._internalFetch() + this._adminLoaded = true + this._adminDirty = false + this.notifyAdminChange() + } catch (error) { + this._adminError = describeApiError(error) + this.notifyChange() + } finally { + this._adminLoading = false + this.notifyChange() + } + } + + private async _internalFetch() { + if (this._fetchPromise) return this._fetchPromise + this._fetchPromise = getMe() + try { + const p = await this._fetchPromise + this._publicProfile = p + this._adminProfile = p + return p + } finally { + this._fetchPromise = null + } + } + + async saveAdminProfile(profile: MeProfile) { + this._adminSaving = true + this._adminError = '' + this._adminSuccess = '' + this.notifyChange() + try { + this._adminProfile = await updateMe(profile) + this._publicProfile = this._adminProfile + this._adminLoaded = true + this._adminDirty = false + this._adminSuccess = 'プロフィールを更新しました。' + this.notifyAdminChange() + this.notifyPublicChange() + } catch (error) { + this._adminError = describeApiError(error) + this.notifyChange() + } finally { + this._adminSaving = false + this.notifyChange() + } + } + + setAdminDirty(dirty: boolean) { + this._adminDirty = dirty + if (dirty) { + this._adminSuccess = '' + } + this.notifyChange() + } +} diff --git a/frontend/src/domain/Repository.ts b/frontend/src/domain/Repository.ts new file mode 100644 index 0000000..eccb5f3 --- /dev/null +++ b/frontend/src/domain/Repository.ts @@ -0,0 +1,12 @@ +/** + * Base class for all domain repositories. + * Extends EventTarget to allow framework-agnostic state change notifications. + */ +export abstract class Repository extends EventTarget { + /** + * Dispatches a 'change' event to notify observers of a state mutation. + */ + protected notifyChange() { + this.dispatchEvent(new Event('change')) + } +} diff --git a/frontend/src/index.css b/frontend/src/index.css index f1a17f1..53584e6 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,4 +1,6 @@ -@import "./tokens.css"; +@import "./styles/tokens.css"; +@import "./styles/theme-public.css"; +@import "./styles/theme-admin.css"; *, *::before, @@ -14,18 +16,13 @@ html { } body { - background-color: var( - --color-bg-top - ); /* scroll.ts がスクロール量に応じて --color-bg-bottom へ補間 */ - color: var(--color-text-primary); - font-family: var(--font-jp); - font-weight: 300; - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + /* Minimal resets. Actual styles come from theme attributes on */ min-height: 100dvh; overflow-x: hidden; + position: relative; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; } a { diff --git a/frontend/src/pages/page-about.ts b/frontend/src/pages/page-about.ts index c2aff3a..1161035 100644 --- a/frontend/src/pages/page-about.ts +++ b/frontend/src/pages/page-about.ts @@ -1,12 +1,25 @@ +import { consume } from '@lit/context' import { css, html, LitElement } from 'lit' -import { customElement, property } from 'lit/decorators.js' -import type { MeProfile } from '../admin/types.js' +import { customElement } from 'lit/decorators.js' +import { profileContext } from '../contexts/profile-context.js' +import { RepositoryObserver } from '../controllers/RepositoryObserver.js' +import type { IProfileRepository } from '../domain/ProfileRepository.js' import { setupReveal } from '../utils/scroll.js' @customElement('page-about') export class PageAbout extends LitElement { - @property({ attribute: false }) profile: MeProfile | null = null - @property({ type: Boolean }) loading = false + @consume({ context: profileContext, subscribe: true }) + set profileRepo(repo: IProfileRepository) { + if (this._profileRepo === repo) return + this._profileRepo = repo + if (this._observer) this._observer.disconnect() + if (repo) this._observer = new RepositoryObserver(this, repo) + } + get profileRepo() { + return this._profileRepo + } + private _profileRepo!: IProfileRepository + private _observer?: RepositoryObserver private cleanups: Array<() => void> = [] @@ -26,14 +39,14 @@ export class PageAbout extends LitElement { } private get sortedSkills() { - return [...(this.profile?.skills ?? [])].sort( + return [...(this.profileRepo.publicProfile?.skills ?? [])].sort( (a, b) => a.sortOrder - b.sortOrder, ) } render() { - const p = this.profile - const cls = this.loading ? 'is-loading' : '' + const p = this.profileRepo.publicProfile + const cls = this.profileRepo.publicLoading ? 'is-loading' : '' return html`
@@ -158,7 +171,7 @@ export class PageAbout extends LitElement { letter-spacing: 0.04em; color: var(--color-text-primary); padding: 12px 0; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--color-border-subtle); line-height: 1.6; display: flex; flex-direction: column; @@ -166,7 +179,7 @@ export class PageAbout extends LitElement { } .list li:first-child { - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--color-border-subtle); } .skill-category, diff --git a/frontend/src/pages/page-admin-account.ts b/frontend/src/pages/page-admin-account.ts index 2b9f920..338da7d 100644 --- a/frontend/src/pages/page-admin-account.ts +++ b/frontend/src/pages/page-admin-account.ts @@ -1,41 +1,29 @@ +import { consume } from '@lit/context' import { css, html, LitElement } from 'lit' -import { customElement, property, state } from 'lit/decorators.js' +import { customElement } from 'lit/decorators.js' import { adminFormStyles } from '../admin/admin-form-styles.js' -import type { ChangeEmailInput } from '../admin/types.js' +import { authContext } from '../contexts/auth-context.js' +import { RepositoryObserver } from '../controllers/RepositoryObserver.js' +import type { IAuthRepository } from '../domain/AuthRepository.js' +import '../components/admin/ui/me-text-input.js' @customElement('page-admin-account') export class PageAdminAccount extends LitElement { - @property() - busyAction = '' - - @property() - errorMessage = '' - - @property() - successMessage = '' - - @state() - private token = '' - - @state() - private newEmailAddress = '' - - @state() - private lastSubmittedAction = '' - - protected updated(changedProperties: Map) { - if ( - changedProperties.has('successMessage') && - this.successMessage && - this.lastSubmittedAction === 'change-email' - ) { - this.token = '' - this.newEmailAddress = '' - this.lastSubmittedAction = '' - } + @consume({ context: authContext, subscribe: true }) + set authRepo(repo: IAuthRepository) { + if (this._authRepo === repo) return + this._authRepo = repo + if (this._observer) this._observer.disconnect() + if (repo) this._observer = new RepositoryObserver(this, repo) } + get authRepo() { + return this._authRepo + } + private _authRepo!: IAuthRepository + private _observer?: RepositoryObserver render() { + const a = this.authRepo return html`
@@ -46,10 +34,10 @@ export class PageAdminAccount extends LitElement {

- ${this.errorMessage ? html`

${this.errorMessage}

` : null} + ${a.accountError ? html`

${a.accountError}

` : null} ${ - this.successMessage - ? html`

${this.successMessage}

` + a.accountSuccess + ? html`

${a.accountSuccess}

` : null } @@ -61,10 +49,10 @@ export class PageAdminAccount extends LitElement {
@@ -79,11 +67,11 @@ export class PageAdminAccount extends LitElement { @@ -134,19 +114,14 @@ export class PageAdminAccount extends LitElement { ` } - private handleLogout = () => { + private handleLogout = async () => { if (!window.confirm('現在の端末からログアウトします。よろしいですか?')) return - this.dispatchEvent( - new CustomEvent('admin-logout', { - bubbles: true, - composed: true, - }), - ) + await this.authRepo.logout() } - private handleRevokeAllSessions = () => { + private handleRevokeAllSessions = async () => { if ( !window.confirm( 'すべてのセッションを終了します。現在の端末も再ログインが必要になります。実行しますか?', @@ -155,30 +130,22 @@ export class PageAdminAccount extends LitElement { return } - this.dispatchEvent( - new CustomEvent('admin-revoke-sessions', { - bubbles: true, - composed: true, - }), - ) + await this.authRepo.revokeAllSessions() } - private handleChangeEmail(event: Event) { + private async handleChangeEmail(event: Event) { event.preventDefault() - this.lastSubmittedAction = 'change-email' + const form = event.target as HTMLFormElement + const formData = new FormData(form) - const detail: ChangeEmailInput = { - token: this.token.trim(), - newEmailAddress: this.newEmailAddress.trim(), - } + await this.authRepo.changeEmail({ + token: (formData.get('token') as string).trim(), + newEmailAddress: (formData.get('newEmailAddress') as string).trim(), + }) - this.dispatchEvent( - new CustomEvent('admin-change-email', { - detail, - bubbles: true, - composed: true, - }), - ) + if (!this.authRepo.accountError) { + form.reset() + } } static styles = [ diff --git a/frontend/src/pages/page-admin-articles.ts b/frontend/src/pages/page-admin-articles.ts index e7f07aa..702b989 100644 --- a/frontend/src/pages/page-admin-articles.ts +++ b/frontend/src/pages/page-admin-articles.ts @@ -1,3 +1,4 @@ +import { consume } from '@lit/context' import { css, html, LitElement } from 'lit' import { customElement, state } from 'lit/decorators.js' import { adminFormStyles } from '../admin/admin-form-styles.js' @@ -19,6 +20,15 @@ import { createEmptyArticleDraft, } from '../admin/article-types.js' import { describeApiError } from '../admin/types.js' +import { articleContext } from '../contexts/article-context.js' +import { RepositoryObserver } from '../controllers/RepositoryObserver.js' +import type { IArticleRepository } from '../domain/ArticleRepository.js' + +// Import encapsulated components +import '../components/admin/ui/me-admin-section.js' +import '../components/admin/ui/me-text-input.js' +import '../components/admin/ui/me-textarea.js' +import '../components/admin/ui/me-select.js' interface SearchFormState { q: string @@ -36,6 +46,19 @@ const createSearchFormState = (): SearchFormState => ({ @customElement('page-admin-articles') export class PageAdminArticles extends LitElement { + @consume({ context: articleContext, subscribe: true }) + set articleRepo(repo: IArticleRepository) { + if (this._articleRepo === repo) return + this._articleRepo = repo + this._observer?.disconnect() + if (repo) this._observer = new RepositoryObserver(this, repo) + } + get articleRepo() { + return this._articleRepo + } + private _articleRepo!: IArticleRepository + private _observer?: RepositoryObserver + @state() private articles: ArticleItem[] = [] @@ -69,17 +92,14 @@ export class PageAdminArticles extends LitElement { @state() private editorMode: 'create' | 'edit' = 'create' - @state() - private form: ArticleDraft = createEmptyArticleDraft() - @state() private baseline: ArticleDraft = createEmptyArticleDraft() @state() - private isDirty = false + private localDirty = false private onBeforeUnload = (event: BeforeUnloadEvent) => { - if (!this.isDirty) return + if (!this.articleRepo.adminDirty) return event.preventDefault() event.returnValue = '' } @@ -101,466 +121,314 @@ export class PageAdminArticles extends LitElement { render() { return html`
-
+ ` + } + + private renderPageHeader() { + return html` + + ` + } + + private renderFilterSection() { + return html` + +
+ + + + + + + ${articlePlatforms.map( + (p) => html``, + )} + + +
+ + +
+
+ + ${this.tagOptions.length > 0 ? this.renderTagFilter() : null} +
+ ` + } + + private renderTagFilter() { + return html` +
+

タグ

+
+ ${this.tagOptions.map( + (tag) => html` + + `, + )} +
+
+ ` + } + + private renderListSection() { + const showMore = !!this.nextCursor && !this.loading + return html` + + + + ${this.loading ? html`

記事を読み込み中...

` : this.renderArticleCards()} + + ${ + showMore + ? html` - - - ${this.errorMessage ? html`

${this.errorMessage}

` : null} - ${ - this.successMessage - ? html`

${this.successMessage}

` + ` : null } +
+ ` + } -
-
-
-

検索と絞り込み

-

- キーワード、年、プラットフォーム、タグで記事一覧を絞り込みます。 -

-
-
+ private renderArticleCards() { + if (this.articles.length === 0) { + return html` +
+

条件に一致する記事がありません。

+
+ ` + } -
- - - - - - -
- - -
-
+ return html` +
+ ${this.articles.map((article) => this.renderArticleCard(article))} +
+ ` + } + private renderArticleCard(article: ArticleItem) { + const isSelected = + this.baseline.externalId === article.externalId && + this.editorMode === 'edit' + return html` +
+
+
+

${this.platformLabel(article.platform)}

+

${article.title}

+ ${article.url} +
+ +
+ +
- -
-
-
-
-

記事一覧

-

- 一覧から記事を選ぶと右側のフォームで編集できます。 -

-
- -
- - ${ - this.loading - ? html`

記事を読み込み中...

` - : this.articles.length === 0 - ? html` -
-

条件に一致する記事がありません。

-
- ` - : html` -
- ${this.articles.map( - (article) => html` -
-
-
-

- ${this.platformLabel(article.platform)} -

-

${article.title}

- - ${article.url} - -
- -
- - -
- `, - )} -
- ` - } - - ${ - this.nextCursor - ? html` - - ` - : null - } -
- -
-
-
-

${this.editorMode === 'edit' ? '記事を編集' : '記事を登録'}

-

- ${ - this.editorMode === 'edit' - ? 'manual 登録した記事を更新します。externalId と platform は変更できません。' - : '管理画面から手動追加する記事を登録します。' - } -

-
-
- - ${ - this.editorMode === 'edit' - ? html` -

- 一覧 API では articleUpdatedAt を取得できないため、必要なら再入力してください。 - 空のまま保存するとクリアされます。 -

- ` - : null - } - -
-
- - - - - - - - - - - - - -
- -
-
-

- ${this.isDirty ? '未保存の変更があります。' : '保存済みの内容です。'} -

-
+ article.tags.length > 0 + ? article.tags.map( + (tag) => html` - ${ - this.editorMode === 'edit' - ? html` - - ` - : null - } - -
-
-
+ `, + ) + : html`タグなし` + }
- + ` } - private async loadInitialData() { - this.loading = true - this.errorMessage = '' + private renderEditorSection() { + const isEdit = this.editorMode === 'edit' + return html` + + ${isEdit ? html`

一覧 API では articleUpdatedAt を取得できないため、必要なら再入力してください。

` : null} + +
+ ${this.renderEditorFormFields()} + ${this.renderEditorActions()} +
+
+ ` + } - const [articlesResult, tagsResult] = await Promise.allSettled([ - listArticles({ limit: 50 }), - listArticleTags(), - ]) + private renderEditorFormFields() { + const isEdit = this.editorMode === 'edit' + return html` +
+ + + ${articlePlatforms.map((p) => html``)} + + + + + + +
+ ` + } - if (articlesResult.status === 'fulfilled') { - this.articles = articlesResult.value.articles - this.nextCursor = articlesResult.value.nextCursor - } else { - this.errorMessage = describeApiError(articlesResult.reason) - } + private renderEditorActions() { + const isEdit = this.editorMode === 'edit' + const isDirty = this.articleRepo.adminDirty || this.localDirty + const isBusy = this.saving || this.deleting + return html` +
+
+

+ ${isDirty ? '未保存の変更があります。' : '保存済みの内容です。'} +

+
+ + ${isEdit ? html`` : null} + +
+ ` + } - if (tagsResult.status === 'fulfilled') { - this.tagOptions = tagsResult.value - } else if (!this.errorMessage) { - this.errorMessage = describeApiError(tagsResult.reason) - } + private handleInput() { + this.articleRepo.setAdminDirty(true) + } - this.loading = false + private async loadInitialData() { + this.loading = true + this.errorMessage = '' + try { + const [articlesResult, tagsResult] = await Promise.allSettled([ + listArticles({ limit: 50 }), + listArticleTags(), + ]) + if (articlesResult.status === 'fulfilled') { + this.articles = articlesResult.value.articles + this.nextCursor = articlesResult.value.nextCursor + } else { + this.errorMessage = describeApiError(articlesResult.reason) + } + if (tagsResult.status === 'fulfilled') { + this.tagOptions = tagsResult.value + } else if (!this.errorMessage) { + this.errorMessage = describeApiError(tagsResult.reason) + } + } finally { + this.loading = false + } } private async reloadArticles(cursor?: string, append = false) { - if (append) { - this.loadingMore = true - } else { + if (append) this.loadingMore = true + else { this.loading = true this.errorMessage = '' } - try { const result = await listArticles({ q: this.filters.q.trim() || undefined, @@ -570,7 +438,6 @@ export class PageAdminArticles extends LitElement { limit: 50, cursor, }) - this.articles = append ? [...this.articles, ...result.articles] : result.articles @@ -587,26 +454,28 @@ export class PageAdminArticles extends LitElement { try { this.tagOptions = await listArticleTags() } catch (error) { - if (!this.errorMessage) { - this.errorMessage = describeApiError(error) - } + if (!this.errorMessage) this.errorMessage = describeApiError(error) } } private handleSearch(event: Event) { event.preventDefault() + const formData = new FormData(event.target as HTMLFormElement) + this.filters = { + ...this.filters, + q: (formData.get('q') as string) || '', + year: (formData.get('year') as string) || '', + platform: (formData.get('platform') as SearchFormState['platform']) || '', + } void this.reloadArticles() } private handleRefreshArticles = () => { void this.reloadArticles() } - private handleLoadMore = () => { - if (!this.nextCursor) return - void this.reloadArticles(this.nextCursor, true) + if (this.nextCursor) void this.reloadArticles(this.nextCursor, true) } - private handleClearFilters = () => { this.filters = createSearchFormState() void this.reloadArticles() @@ -616,43 +485,52 @@ export class PageAdminArticles extends LitElement { const nextTags = this.filters.tags.includes(tagName) ? this.filters.tags.filter((tag) => tag !== tagName) : [...this.filters.tags, tagName] - - this.filters = { - ...this.filters, - tags: nextTags, - } - + this.filters = { ...this.filters, tags: nextTags } void this.reloadArticles() } private handleStartCreate = () => { - if (!this.confirmDiscardChanges()) return - this.startCreateMode() + if (this.confirmDiscardChanges()) this.startCreateMode() } private handleStartEdit(article: ArticleItem) { - if (!this.confirmDiscardChanges()) return - this.startEditMode(article) + if (this.confirmDiscardChanges()) this.startEditMode(article) } private async handleSubmit(event: Event) { event.preventDefault() + const form = event.target as HTMLFormElement + if (!form.checkValidity()) return form.reportValidity() + + const formData = new FormData(form) + const draft: ArticleDraft = { + externalId: formData.get('externalId') as string, + platform: formData.get('platform') as ArticlePlatform, + title: formData.get('title') as string, + url: formData.get('url') as string, + publishedAt: (formData.get('publishedAt') as string) || '', + articleUpdatedAt: (formData.get('articleUpdatedAt') as string) || '', + tags: ((formData.get('tags') as string) || '') + .split('\n') + .map((s) => s.trim()) + .filter(Boolean), + } + this.saving = true this.errorMessage = '' this.successMessage = '' - try { if (this.editorMode === 'edit') { - await updateArticle(this.form.externalId, this.form) + await updateArticle(draft.externalId, draft) this.successMessage = '記事を更新しました。' } else { - await createArticle(this.form) + await createArticle(draft) this.editorMode = 'edit' this.successMessage = '記事を登録しました。' } - - this.setBaseline(cloneArticleDraft(this.form)) + this.setBaseline(cloneArticleDraft(draft)) await Promise.all([this.reloadArticles(), this.refreshTags()]) + this.localDirty = false } catch (error) { this.errorMessage = describeApiError(error) } finally { @@ -661,15 +539,14 @@ export class PageAdminArticles extends LitElement { } private async handleDelete() { - if (this.editorMode !== 'edit') return - if (!window.confirm('この記事を削除します。よろしいですか?')) return - + if ( + this.editorMode !== 'edit' || + !window.confirm('この記事を削除します。よろしいですか?') + ) + return this.deleting = true - this.errorMessage = '' - this.successMessage = '' - try { - await deleteArticle(this.form.externalId) + await deleteArticle(this.baseline.externalId) this.successMessage = '記事を削除しました。' this.startCreateMode() await Promise.all([this.reloadArticles(), this.refreshTags()]) @@ -680,9 +557,13 @@ export class PageAdminArticles extends LitElement { } } - private handleReset = () => { - if (!this.confirmDiscardChanges()) return - this.setForm(cloneArticleDraft(this.baseline)) + private handleReset = (e: Event) => { + e.preventDefault() + if (this.confirmDiscardChanges()) { + this.localDirty = false + this.articleRepo.setAdminDirty(false) + this.requestUpdate() + } } private startCreateMode() { @@ -690,6 +571,8 @@ export class PageAdminArticles extends LitElement { this.setBaseline(createEmptyArticleDraft()) this.successMessage = '' this.errorMessage = '' + this.localDirty = false + this.articleRepo.setAdminDirty(false) } private startEditMode(article: ArticleItem) { @@ -697,59 +580,21 @@ export class PageAdminArticles extends LitElement { this.setBaseline(articleDraftFromArticle(article)) this.successMessage = '' this.errorMessage = '' - } - - private updateForm( - key: Key, - value: ArticleDraft[Key], - ) { - this.setForm({ - ...this.form, - [key]: value, - }) + this.localDirty = false + this.articleRepo.setAdminDirty(false) } private setBaseline(nextBaseline: ArticleDraft) { this.baseline = nextBaseline - this.setForm(cloneArticleDraft(nextBaseline)) - } - - private setForm(nextForm: ArticleDraft) { - this.form = nextForm - this.updateDirtyState(nextForm) - } - - private updateDirtyState(nextForm: ArticleDraft) { - const nextDirty = JSON.stringify(nextForm) !== JSON.stringify(this.baseline) - if (this.isDirty === nextDirty) return - - this.isDirty = nextDirty - if (nextDirty) { - this.successMessage = '' - } - this.dispatchEvent( - new CustomEvent('admin-articles-dirty-change', { - detail: nextDirty, - bubbles: true, - composed: true, - }), - ) } private confirmDiscardChanges() { return ( - !this.isDirty || + (!this.articleRepo.adminDirty && !this.localDirty) || window.confirm('未保存の変更を破棄して切り替えてもよいですか?') ) } - private splitLines(value: string) { - return value - .split('\n') - .map((item) => item.trim()) - .filter(Boolean) - } - private toOptionalNumber(value: string) { const trimmed = value.trim() return trimmed === '' ? undefined : Number(trimmed) @@ -760,225 +605,47 @@ export class PageAdminArticles extends LitElement { } private platformLabel(platform: ArticlePlatform) { - switch (platform) { - case 'qiita': - return 'Qiita' - case 'zenn': - return 'Zenn' - case 'mochiya': - return 'Mochiya' - case 'note': - return 'note' + const labels: Record = { + qiita: 'Qiita', + zenn: 'Zenn', + mochiya: 'Mochiya', + note: 'note', } + return labels[platform] } static styles = [ adminFormStyles, css` - :host { - display: block; - } - - code { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - } - - .container { - display: grid; - gap: 24px; - } - - .page-header, - .section-header, - .article-card-header, - .actions { - display: flex; - gap: 16px; - justify-content: space-between; - align-items: center; - flex-wrap: wrap; - } - - .meta, - .article-meta, - .article-tags, - .tag-list, - .filter-actions { - display: flex; - gap: 8px; - flex-wrap: wrap; - } - - .meta span, - .article-meta span { - display: inline-flex; - align-items: center; - min-height: 28px; - padding: 0 10px; - border: 1px solid var(--color-border); - background: var(--color-surface); - color: var(--color-text-secondary); - font-size: 12px; - } - - .section, - .article-card { - display: grid; - gap: 16px; - padding: 24px; - border: 1px solid var(--color-border); - background: #fff; - } - - .section-copy { - display: grid; - gap: 6px; - } - - .section-help, - .loading, - .muted { - color: var(--color-text-tertiary); - font-size: 13px; - line-height: 1.8; - } - - .tag-filter { - display: grid; - gap: 12px; - } - - .tag-filter-label, - .article-platform { - font-family: var(--font-en); - font-size: 12px; - letter-spacing: var(--tracking-wide); - color: var(--color-text-tertiary); - } - - .tag-chip, - .inline-tag { - border: 1px solid var(--color-border); - background: var(--color-surface); - color: var(--color-text-secondary); - padding: 6px 10px; - font-size: 12px; - } - - .tag-chip.selected { - border-color: var(--admin-accent); - color: var(--admin-accent); - background: #e8f0fb; - } - - .tag-chip small { - font-size: 11px; - color: inherit; - } - - .content-grid { - display: grid; - gap: 24px; - grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.9fr); - align-items: start; - } - - .stack, - form { - display: grid; - gap: 16px; - } - - .grid { - display: grid; - gap: 16px; - grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); - } - - .empty-panel { - display: grid; - gap: 12px; - border: 1px dashed var(--color-border); - padding: 18px; - color: var(--color-text-secondary); - font-size: 14px; - } - - .article-card.selected { - border-color: var(--admin-accent); - box-shadow: 0 0 0 1px color-mix(in srgb, var(--admin-accent) 30%, transparent); - } - - .article-copy { - display: grid; - gap: 6px; - } - - .article-copy h3 { - font-size: 16px; - font-weight: 500; - color: var(--color-text-primary); - } - - .article-copy a { - font-size: 13px; - color: var(--admin-accent); - overflow-wrap: anywhere; - } - - .editor-section { - position: sticky; - top: 24px; - } - - .actions { - position: sticky; - bottom: 16px; - padding: 14px 16px; - border: 1px solid var(--color-border); - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(12px); - } - - .actions-copy { - margin-right: auto; - } - - .dirty-indicator { - font-size: 13px; - color: var(--color-text-tertiary); - } - - .dirty-indicator.dirty { - color: #9a6d2f; - } - - h2 { - font-size: 16px; - font-weight: 500; - color: var(--color-text-primary); - } - - @media (max-width: 1080px) { - .content-grid { - grid-template-columns: 1fr; - } - - .editor-section { - position: static; - } - } - - @media (max-width: 720px) { - .actions { - flex-wrap: wrap; - } - - .actions-copy { - width: 100%; - margin-right: 0; - } - } + :host { display: block; } + code { font-family: ui-monospace, SFMono-Regular, monospace; } + .container { display: grid; gap: 24px; } + .page-header, .article-card-header, .actions { display: flex; gap: 16px; justify-content: space-between; align-items: center; flex-wrap: wrap; } + .meta, .article-meta, .article-tags, .tag-list, .filter-actions { display: flex; gap: 8px; flex-wrap: wrap; } + .meta span, .article-meta span { display: inline-flex; align-items: center; min-height: 28px; padding: 0 10px; border: 1px solid var(--color-border); background: var(--color-bg-surface); color: var(--color-text-secondary); font-size: 12px; } + .article-card { display: grid; gap: 16px; padding: 24px; border: 1px solid var(--color-border); background: #fff; } + .loading, .muted { color: var(--color-text-tertiary); font-size: 13px; line-height: 1.8; } + .tag-filter { display: grid; gap: 12px; } + .tag-filter-label, .article-platform { font-family: var(--font-en); font-size: 12px; letter-spacing: var(--tracking-wide); color: var(--color-text-tertiary); } + .tag-chip, .inline-tag { border: 1px solid var(--color-border); background: var(--color-bg-surface); color: var(--color-text-secondary); padding: 6px 10px; font-size: 12px; cursor: pointer; } + .tag-chip.selected { border-color: var(--admin-accent); color: var(--admin-accent); background: #e8f0fb; } + .tag-chip small { font-size: 11px; color: inherit; } + .content-grid { display: grid; gap: 24px; grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.9fr); align-items: start; } + .stack, form { display: grid; gap: 16px; } + .grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); } + .field-wide { grid-column: 1 / -1; } + .empty-panel { display: grid; gap: 12px; border: 1px dashed var(--color-border); padding: 18px; color: var(--color-text-secondary); font-size: 14px; } + .article-card.selected { border-color: var(--admin-accent); box-shadow: 0 0 0 1px color-mix(in srgb, var(--admin-accent) 30%, transparent); } + .article-copy { display: grid; gap: 6px; } + .article-copy h3 { font-size: 16px; font-weight: 500; color: var(--color-text-primary); } + .article-copy a { font-size: 13px; color: var(--admin-accent); overflow-wrap: anywhere; } + .editor-section { position: sticky; top: 24px; } + .actions { position: sticky; bottom: 16px; padding: 14px 16px; border: 1px solid var(--color-border); background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(12px); z-index: 10; } + .actions-copy { margin-right: auto; } + .dirty-indicator { font-size: 13px; color: var(--color-text-tertiary); } + .dirty-indicator.dirty { color: #9a6d2f; } + @media (max-width: 1080px) { .content-grid { grid-template-columns: 1fr; } .editor-section { position: static; } } + @media (max-width: 720px) { .actions { flex-wrap: wrap; } .actions-copy { width: 100%; margin-right: 0; } } `, ] } diff --git a/frontend/src/pages/page-admin-login.ts b/frontend/src/pages/page-admin-login.ts index bceca90..2fa9d5d 100644 --- a/frontend/src/pages/page-admin-login.ts +++ b/frontend/src/pages/page-admin-login.ts @@ -1,35 +1,38 @@ +import { consume } from '@lit/context' import { css, html, LitElement } from 'lit' -import { customElement, property, state } from 'lit/decorators.js' +import { customElement, state } from 'lit/decorators.js' import { adminFormStyles } from '../admin/admin-form-styles.js' -import type { AdminLoginInput } from '../admin/types.js' +import { authContext } from '../contexts/auth-context.js' +import { RepositoryObserver } from '../controllers/RepositoryObserver.js' +import type { IAuthRepository } from '../domain/AuthRepository.js' +import '../components/admin/ui/me-text-input.js' @customElement('page-admin-login') export class PageAdminLogin extends LitElement { - @property({ type: Boolean }) - submitting = false - - @property() - errorMessage = '' - - @property() - noticeMessage = '' - - @state() - private emailAddress = '' - - @state() - private password = '' + @consume({ context: authContext, subscribe: true }) + set authRepo(repo: IAuthRepository) { + if (this._authRepo === repo) return + this._authRepo = repo + if (this._observer) this._observer.disconnect() + if (repo) this._observer = new RepositoryObserver(this, repo) + } + get authRepo() { + return this._authRepo + } + private _authRepo!: IAuthRepository + private _observer?: RepositoryObserver @state() private passwordVisible = false firstUpdated() { this.shadowRoot - ?.querySelector('input[name="emailAddress"]') + ?.querySelector('me-text-input[name="emailAddress"]') ?.focus() } render() { + const a = this.authRepo return html`
@@ -40,56 +43,48 @@ export class PageAdminLogin extends LitElement {

${ - this.noticeMessage - ? html`

${this.noticeMessage}

` + a.loginNotice + ? html`

${a.loginNotice}

` : null }
-
@@ -97,33 +92,19 @@ export class PageAdminLogin extends LitElement { ` } - private handleEmailInput(event: Event) { - this.emailAddress = (event.target as HTMLInputElement).value - } - - private handlePasswordInput(event: Event) { - this.password = (event.target as HTMLInputElement).value - } - private togglePasswordVisibility = () => { this.passwordVisible = !this.passwordVisible } - private handleSubmit(event: Event) { + private async handleSubmit(event: Event) { event.preventDefault() + const form = event.target as HTMLFormElement + const formData = new FormData(form) - const detail: AdminLoginInput = { - emailAddress: this.emailAddress.trim(), - password: this.password, - } - - this.dispatchEvent( - new CustomEvent('admin-login-submit', { - detail, - bubbles: true, - composed: true, - }), - ) + await this.authRepo.login({ + emailAddress: (formData.get('emailAddress') as string).trim(), + password: formData.get('password') as string, + }) } static styles = [ @@ -157,15 +138,21 @@ export class PageAdminLogin extends LitElement { gap: 18px; } - .password-field { + .password-field-container { + position: relative; display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; - align-items: center; } - .subtle { - min-width: 72px; + .password-toggle { + position: absolute; + right: 0; + top: 0; + height: 20px; + font-size: 12px; + } + + button[type="submit"] { + margin-top: 8px; } `, ] diff --git a/frontend/src/pages/page-admin-profile.ts b/frontend/src/pages/page-admin-profile.ts index 818a75e..7cba297 100644 --- a/frontend/src/pages/page-admin-profile.ts +++ b/frontend/src/pages/page-admin-profile.ts @@ -1,41 +1,38 @@ +import { consume } from '@lit/context' import { css, html, LitElement } from 'lit' -import { customElement, property, state } from 'lit/decorators.js' +import { customElement } from 'lit/decorators.js' import { adminFormStyles } from '../admin/admin-form-styles.js' -import { - cloneMeProfile, - createEmptyMeProfile, - type MeCertification, - type MeExperience, - type MeLink, - type MeProfile, - type MeSkillGroup, -} from '../admin/types.js' +import type { MeProfile } from '../admin/types.js' +import { profileContext } from '../contexts/profile-context.js' +import { RepositoryObserver } from '../controllers/RepositoryObserver.js' +import type { IProfileRepository } from '../domain/ProfileRepository.js' + +// Import encapsulated components +import '../components/admin/ui/me-admin-section.js' +import '../components/admin/ui/me-text-input.js' +import '../components/admin/ui/me-textarea.js' +import '../components/admin/profile/me-profile-skills-editor.js' +import '../components/admin/profile/me-profile-certifications-editor.js' +import '../components/admin/profile/me-profile-experiences-editor.js' +import '../components/admin/profile/me-profile-links-editor.js' @customElement('page-admin-profile') export class PageAdminProfile extends LitElement { - @property({ attribute: false }) - profile: MeProfile = createEmptyMeProfile() - - @property({ type: Boolean }) - loading = false - - @property({ type: Boolean }) - saving = false - - @property() - errorMessage = '' - - @property() - successMessage = '' - - @state() - private form: MeProfile = createEmptyMeProfile() - - @state() - private isDirty = false + @consume({ context: profileContext, subscribe: true }) + set profileRepo(repo: IProfileRepository) { + if (this._profileRepo === repo) return + this._profileRepo = repo + this._observer?.disconnect() + if (repo) this._observer = new RepositoryObserver(this, repo) + } + get profileRepo() { + return this._profileRepo + } + private _profileRepo!: IProfileRepository + private _observer?: RepositoryObserver private onBeforeUnload = (event: BeforeUnloadEvent) => { - if (!this.isDirty) return + if (!this.profileRepo.adminDirty) return event.preventDefault() event.returnValue = '' } @@ -50,487 +47,123 @@ export class PageAdminProfile extends LitElement { window.removeEventListener('beforeunload', this.onBeforeUnload) } - protected willUpdate(changedProperties: Map) { - if (changedProperties.has('profile')) { - this.setForm(cloneMeProfile(this.profile)) - } - } - render() { + const p = this.profileRepo + const profile = p.adminProfile + return html`
- ${this.errorMessage ? html`

${this.errorMessage}

` : null} + ${p.adminError ? html`

${p.adminError}

` : null} ${ - this.successMessage - ? html`

${this.successMessage}

` + p.adminSuccess + ? html`

${p.adminSuccess}

` : null } ${ - this.loading + p.adminLoading ? html`

プロフィールを読み込み中...

` : html` -
-
-
-

基本情報

-

- 最低限、表示名だけあれば更新できます。未入力項目は公開画面で省略されます。 -

-
+ +
- - - - + + + + + + +
-
- -
-
-
-

Skills

-

- カテゴリごとに整理し、Items は1行ずつ入力すると編集しやすいです。 -

-
- -
-
- ${ - this.form.skills.length === 0 - ? this.renderEmptyPanel( - 'まだ skill カテゴリがありません。', - 'カテゴリを追加', - this.addSkill, - ) - : this.form.skills.map( - (skill, index) => html` -
-
-

カテゴリ ${index + 1}

- -
-
- - - -
-
- `, - ) - } -
-
- -
-
-
-

Certifications

-

- month は任意です。年だけでも掲載できます。 -

-
- -
-
- ${ - this.form.certifications.length === 0 - ? this.renderEmptyPanel( - '資格がまだありません。', - '資格を追加', - this.addCertification, - ) - : this.form.certifications.map( - (certification, index) => html` -
-
-

資格 ${index + 1}

- -
-
- - - - -
-
- `, - ) - } -
-
- -
-
-
-

Experiences

-

- endYear を空にすると、継続中の経歴として扱えます。 -

-
- -
-
- ${ - this.form.experiences.length === 0 - ? this.renderEmptyPanel( - '経歴がまだありません。', - '経歴を追加', - this.addExperience, - ) - : this.form.experiences.map( - (experience, index) => html` -
-
-

経歴 ${index + 1}

- -
-
- - - - -
-
- `, - ) - } -
-
- -
-
-
-

Links

-

- platform と URL は必須です。label は公開側で見せたい名前を指定します。 -

-
- -
-
- ${ - this.form.links.length === 0 - ? this.renderEmptyPanel( - 'リンクがまだありません。', - 'リンクを追加', - this.addLink, - ) - : this.form.links.map( - (link, index) => html` -
-
-

リンク ${index + 1}

- -
-
- - -
-
- `, - ) - } -
-
- -
-
-

Likes

-

- 1行ごとに1件ずつ入力します。空行は保存時に除外されます。 -

-
- -
+ + + + + + + + + + + + +
-

- ${this.isDirty ? '未保存の変更があります。' : '保存済みの内容です。'} +

+ ${ + p.adminDirty + ? '未保存の変更があります。' + : '保存済みの内容です。' + }

-
@@ -540,173 +173,51 @@ export class PageAdminProfile extends LitElement { ` } - private updateField( - key: Key, - value: MeProfile[Key], - ) { - this.setForm({ - ...this.form, - [key]: value, - }) - } - - private updateSkill(index: number, patch: Partial) { - const skills = [...this.form.skills] - skills[index] = { ...skills[index], ...patch } - this.updateField('skills', skills) - } - - private updateCertification(index: number, patch: Partial) { - const certifications = [...this.form.certifications] - certifications[index] = { ...certifications[index], ...patch } - this.updateField('certifications', certifications) - } - - private updateExperience(index: number, patch: Partial) { - const experiences = [...this.form.experiences] - experiences[index] = { ...experiences[index], ...patch } - this.updateField('experiences', experiences) - } - - private updateLink(index: number, patch: Partial) { - const links = [...this.form.links] - links[index] = { ...links[index], ...patch } - this.updateField('links', links) - } - - private addSkill = () => { - this.updateField('skills', [ - ...this.form.skills, - { category: '', items: [], sortOrder: this.form.skills.length }, - ]) - } - - private removeSkill(index: number) { - this.updateField( - 'skills', - this.form.skills.filter((_, itemIndex) => itemIndex !== index), - ) - } - - private addCertification = () => { - this.updateField('certifications', [ - ...this.form.certifications, - { - name: '', - issuer: '', - year: new Date().getFullYear(), - }, - ]) - } - - private removeCertification(index: number) { - this.updateField( - 'certifications', - this.form.certifications.filter((_, itemIndex) => itemIndex !== index), - ) - } - - private addExperience = () => { - this.updateField('experiences', [ - ...this.form.experiences, - { - company: '', - url: '', - startYear: new Date().getFullYear(), - }, - ]) - } - - private removeExperience(index: number) { - this.updateField( - 'experiences', - this.form.experiences.filter((_, itemIndex) => itemIndex !== index), - ) - } - - private addLink = () => { - this.updateField('links', [ - ...this.form.links, - { - platform: '', - url: '', - }, - ]) - } - - private removeLink(index: number) { - this.updateField( - 'links', - this.form.links.filter((_, itemIndex) => itemIndex !== index), - ) + private handleInput() { + this.profileRepo.setAdminDirty(true) } - private splitLines(value: string) { - return value - .split('\n') - .map((item) => item.trim()) - .filter(Boolean) - } + private async handleSubmit(event: Event) { + event.preventDefault() + const form = event.target as HTMLFormElement + if (!form.checkValidity()) { + form.reportValidity() + return + } - private toOptionalNumber(value: string) { - const trimmed = value.trim() - return trimmed === '' ? undefined : Number(trimmed) - } + const formData = new FormData(form) + + const profile: MeProfile = { + displayName: formData.get('displayName') as string, + displayJa: formData.get('displayJa') as string, + role: formData.get('role') as string, + location: formData.get('location') as string, + skills: JSON.parse((formData.get('skills') as string) || '[]'), + certifications: JSON.parse( + (formData.get('certifications') as string) || '[]', + ), + experiences: JSON.parse((formData.get('experiences') as string) || '[]'), + links: JSON.parse((formData.get('links') as string) || '[]'), + likes: ((formData.get('likes') as string) || '') + .split('\n') + .map((s) => s.trim()) + .filter(Boolean), + updatedAt: this.profileRepo.adminProfile.updatedAt, + } - private handleSubmit(event: Event) { - event.preventDefault() - this.dispatchEvent( - new CustomEvent('admin-save-profile', { - detail: cloneMeProfile(this.form), - bubbles: true, - composed: true, - }), - ) + await this.profileRepo.saveAdminProfile(profile) } - private handleReset = () => { + private handleReset = (e: Event) => { + e.preventDefault() if ( - this.isDirty && + this.profileRepo.adminDirty && !window.confirm('未保存の変更を破棄して元に戻しますか?') ) { return } - - this.setForm(cloneMeProfile(this.profile)) - } - - private setForm(nextForm: MeProfile) { - this.form = nextForm - this.updateDirtyState(nextForm) - } - - private updateDirtyState(nextForm: MeProfile) { - const nextDirty = !this.profilesEqual(nextForm, this.profile) - if (nextDirty === this.isDirty) return - - this.isDirty = nextDirty - this.dispatchEvent( - new CustomEvent('admin-profile-dirty-change', { - detail: nextDirty, - bubbles: true, - composed: true, - }), - ) - } - - private profilesEqual(a: MeProfile, b: MeProfile) { - return JSON.stringify(a) === JSON.stringify(b) - } - - private renderEmptyPanel( - message: string, - actionLabel: string, - onClick: () => void, - ) { - return html`
-

${message}

- -
` + this.profileRepo.setAdminDirty(false) + this.requestUpdate() } static styles = [ @@ -736,86 +247,9 @@ export class PageAdminProfile extends LitElement { line-height: 1.8; } - .meta { - display: flex; - gap: 8px; - flex-wrap: wrap; - margin-top: 16px; - } - - .meta span { - display: inline-flex; - align-items: center; - height: 28px; - padding: 0 10px; - border: 1px solid var(--color-border); - background: var(--color-surface); - color: var(--color-text-secondary); - font-size: 12px; - } - - form, - .stack { - display: grid; - gap: 20px; - } - - .section-copy { + form { display: grid; - gap: 6px; - } - - .section-help { - color: var(--color-text-tertiary); - font-size: 13px; - line-height: 1.8; - } - - .section { - display: grid; - gap: 16px; - padding: 24px; - border: 1px solid var(--color-border); - background: #fff; - } - - .section-header, - .panel-header { - display: flex; - justify-content: space-between; - gap: 16px; - align-items: center; - flex-wrap: wrap; - } - - .panel { - display: grid; - gap: 16px; - border: 1px solid var(--color-border-light); - background: var(--color-surface); - padding: 20px; - } - - .empty-panel { - display: grid; - justify-items: start; - gap: 12px; - border: 1px dashed var(--color-border); - padding: 18px; - color: var(--color-text-secondary); - font-size: 14px; - } - - h2 { - font-size: 16px; - font-weight: 500; - color: var(--color-text-primary); - } - - h3 { - font-size: 14px; - font-weight: 500; - color: var(--color-text-secondary); + gap: 24px; } .grid { @@ -835,6 +269,7 @@ export class PageAdminProfile extends LitElement { border: 1px solid var(--color-border); background: rgba(255, 255, 255, 0.95); backdrop-filter: blur(12px); + z-index: 10; } .actions-copy { diff --git a/frontend/src/pages/page-articles.ts b/frontend/src/pages/page-articles.ts index 196be1b..faa1fdb 100644 --- a/frontend/src/pages/page-articles.ts +++ b/frontend/src/pages/page-articles.ts @@ -45,6 +45,9 @@ export class PageArticles extends LitElement { @state() private loadingMore = false + @state() + private showAllTags = false + @state() private suggestionLoading = false @@ -80,157 +83,161 @@ export class PageArticles extends LitElement { this.cleanups = [] } + private get displayedTags() { + const sorted = [...this.tagOptions].sort((a, b) => b.count - a.count) + if (this.showAllTags) return sorted + return sorted.slice(0, 12) + } + render() { return html`
- - -
-
- -
- - ${ - this.suggestionLoading - ? html`

候補を探しています...

` - : this.suggestions.length > 0 - ? html` -
    - ${this.suggestions.map( - (suggestion) => html` -
  • - -
  • - `, - )} -
- ` - : null - } -
+ ${this.renderHeader()} + ${this.renderSearchArea()} + ${this.renderTagCloud()} -
- ${this.tagOptions.map( - (tag) => html` - - `, - )} + ${this.errorMessage ? html`

${this.errorMessage}

` : null} + +
+ ${this.loading ? html`

記事を読み込み中...

` : this.renderArticleGroups()}
+ ${this.renderLoadMore()} +
+ ` + } + + private renderHeader() { + return html` + + ` + } -
- ${ - this.loading - ? html`

記事を読み込み中...

` - : this.articleGroups.length === 0 - ? html` -
-

条件に一致する記事がありません。

- -
- ` - : this.articleGroups.map( - (group) => html` -
-
${group.label}
-
    - ${group.items.map( - (article) => html` -
  • - - - ${article.title} - - -
  • - `, - )} -
-
- `, - ) - } + private renderSearchArea() { + return html` +
+
+ +
+ ${this.renderSuggestions()} +
+ ` + } + + private renderSuggestions() { + if (this.suggestionLoading) { + return html`

候補を探しています...

` + } + if (this.suggestions.length === 0) return null + + return html` +
    + ${this.suggestions.map( + (s) => html` +
  • + +
  • + `, + )} +
+ ` + } + + private renderTagCloud() { + return html` +
+ ${this.displayedTags.map((tag) => this.renderTag(tag))} + ${this.tagOptions.length > 12 ? this.renderTagToggle() : null} +
+ ` + } + + private renderTag(tag: ArticleTagItem) { + const isSelected = this.selectedTags.includes(tag.name) + return html` + + ` + } + + private renderTagToggle() { + return html` + + ` + } + + private renderArticleGroups() { + if (this.articleGroups.length === 0) { + return html` +
+

条件に一致する記事がありません。

+ +
+ ` + } + + return this.articleGroups.map( + (group) => html` +
+
${group.label}
+
    + ${group.items.map((article) => this.renderArticleRow(article))} +
+ `, + ) + } - ${ - this.nextCursor && !this.loading - ? html` -
- -
- ` - : null - } + private renderArticleRow(article: ArticleItem) { + return html` +
  • + + ${article.title} + +
  • + ` + } + + private renderLoadMore() { + if (!this.nextCursor || this.loading) return null + return html` +
    +
    ` } @@ -509,13 +516,13 @@ export class PageArticles extends LitElement { } .search-input::placeholder { - color: var(--color-text-tertiary); + color: var(--color-text-mute); } .search-input:focus-visible { - outline: 2px solid color-mix(in srgb, var(--color-text-primary) 70%, white); - outline-offset: 4px; + outline: none; border-bottom-color: var(--color-text-primary); + box-shadow: 0 1px 0 0 var(--color-text-primary); } .suggestion-list { @@ -544,7 +551,7 @@ export class PageArticles extends LitElement { align-items: center; gap: 12px; padding: 10px 0; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--color-border-subtle); text-align: left; } @@ -563,39 +570,75 @@ export class PageArticles extends LitElement { .tag-cloud { display: flex; flex-wrap: wrap; - gap: 8px; - margin-bottom: 48px; + column-gap: 20px; + row-gap: 12px; + margin-bottom: 64px; } - .tag { + .tag, + .tag-toggle { display: inline-flex; align-items: center; - gap: 6px; - padding: 6px 10px; - border: 1px solid var(--color-border); - color: var(--color-text-secondary); + gap: 4px; + padding: 4px 0; + color: var(--color-text-tertiary); font-family: var(--font-en); font-size: 13px; letter-spacing: var(--tracking-wide); - transition: - opacity 0.2s ease, - color 0.2s ease, - border-color 0.2s ease; + transition: color 0.3s ease, text-shadow 0.3s ease, opacity 0.3s ease; + background: transparent; + border: none; + cursor: pointer; + } + + .tag-hash { + font-size: 11px; + color: var(--color-text-secondary); + } + + .tag-name { + color: var(--color-text-secondary); + transition: color 0.3s ease; + } + + .tag:hover .tag-name { + color: var(--color-text-primary); } .tag.selected { color: var(--color-text-primary); - border-color: var(--color-text-primary); + text-shadow: 0 0 12px var(--color-glow-sharp); + } + + .tag.selected .tag-name { + color: var(--color-text-primary); } - .tag small { + .tag.selected .tag-hash { + color: var(--color-text-primary); + } + + .tag-count { font-size: 11px; - color: inherit; + margin-left: 2px; + font-style: italic; + color: var(--color-text-primary); + } + + .tag-toggle { + color: var(--color-text-tertiary); + font-style: italic; + opacity: 0.6; + } + + .tag-toggle:hover { + opacity: 1; + color: var(--color-text-secondary); } .message.error { margin-bottom: 24px; - color: #a04d40; + color: #8c5a52; } .year-group { @@ -607,7 +650,7 @@ export class PageArticles extends LitElement { font-weight: 300; font-size: 13px; letter-spacing: var(--tracking-wider); - color: var(--color-text-tertiary); + color: var(--color-text-primary); margin-bottom: 16px; } @@ -623,13 +666,14 @@ export class PageArticles extends LitElement { align-items: baseline; gap: 16px; padding: 16px 8px; - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--color-border-subtle); transition: background 0.2s ease, transform 0.2s ease; } .article-row:hover { - background: var(--color-surface); + background: var(--color-bg-surface); transform: translateX(4px); + border-bottom-color: var(--color-text-tertiary); } .article-date { diff --git a/frontend/src/pages/page-top.ts b/frontend/src/pages/page-top.ts index dc92775..1b54c3d 100644 --- a/frontend/src/pages/page-top.ts +++ b/frontend/src/pages/page-top.ts @@ -1,15 +1,28 @@ +import { consume } from '@lit/context' import { css, html, LitElement, nothing } from 'lit' -import { customElement, property, state } from 'lit/decorators.js' +import { customElement, query, state } from 'lit/decorators.js' import { listArticles } from '../admin/article-api.js' import type { ArticleItem } from '../admin/article-types.js' -import type { MeProfile } from '../admin/types.js' +import { profileContext } from '../contexts/profile-context.js' +import { RepositoryObserver } from '../controllers/RepositoryObserver.js' +import type { IProfileRepository } from '../domain/ProfileRepository.js' import { setupAmbientLines } from '../utils/ambient.js' import { setupFade, setupReveal } from '../utils/scroll.js' @customElement('page-top') export class PageTop extends LitElement { - @property({ attribute: false }) profile: MeProfile | null = null - @property({ type: Boolean }) loading = false + @consume({ context: profileContext, subscribe: true }) + set profileRepo(repo: IProfileRepository) { + if (this._profileRepo === repo) return + this._profileRepo = repo + if (this._observer) this._observer.disconnect() + if (repo) this._observer = new RepositoryObserver(this, repo) + } + get profileRepo() { + return this._profileRepo + } + private _profileRepo!: IProfileRepository + private _observer?: RepositoryObserver @state() private articles: ArticleItem[] = [] @@ -20,7 +33,19 @@ export class PageTop extends LitElement { @state() private articlesError = '' + @query('.layer-0') + private fvContainer?: HTMLElement + private cleanups: Array<() => void> = [] + private ambientSetup = false + + protected updated() { + // Setup ambient lines once the container is available in the DOM + if (this.fvContainer && !this.ambientSetup) { + this.cleanups.push(setupAmbientLines(this.fvContainer)) + this.ambientSetup = true + } + } firstUpdated() { const root = this.shadowRoot @@ -29,11 +54,9 @@ export class PageTop extends LitElement { root.querySelectorAll('.who > *, .articles-preview > *, .contact > *'), ) const fadeEls = Array.from(root.querySelectorAll('.layer-1, .layer-2')) - const fvSection = root.querySelector('.layer-0') as HTMLElement | null this.cleanups.push(setupReveal(revealEls, true)) this.cleanups.push(setupFade(fadeEls)) - if (fvSection) this.cleanups.push(setupAmbientLines(fvSection)) void this.loadArticles() } @@ -41,22 +64,26 @@ export class PageTop extends LitElement { super.disconnectedCallback() for (const cleanup of this.cleanups) cleanup() this.cleanups = [] + this.ambientSetup = false } render() { + const p = this.profileRepo.publicProfile + const loading = this.profileRepo.publicLoading + return html`
    -

    - ${this.profile?.displayName ?? ''} +

    + ${p?.displayName ?? ''}

    -

    ${this.profile?.role ?? ''}

    -

    ${this.profile?.location ?? ''}

    +

    ${p?.role ?? ''}

    +

    ${p?.location ?? ''}

    @@ -82,16 +109,17 @@ export class PageTop extends LitElement {

    Say Hello

    @@ -100,6 +128,14 @@ export class PageTop extends LitElement { ` } + private sanitizeUrl(url: string): string { + const trimmed = url.trim() + if (/^(https?|mailto):/i.test(trimmed)) { + return trimmed + } + return '#' + } + private async loadArticles() { this.articlesLoading = true this.articlesError = '' @@ -179,6 +215,7 @@ export class PageTop extends LitElement { align-items: center; justify-content: center; padding: 0; + position: relative; /* REQUIRED for ambient line canvas */ } .name { @@ -188,12 +225,21 @@ export class PageTop extends LitElement { letter-spacing: var(--tracking-wider); color: var(--color-text-primary); margin: 0; - animation: breathing 7s ease-in-out infinite; + animation: breathing 8s ease-in-out infinite; + text-shadow: 0 0 20px rgba(240, 237, 231, 0); + transition: text-shadow 0.5s ease; + z-index: 1; /* Ensure text is above canvas */ } @keyframes breathing { - 0%, 100% { opacity: 0.85; } - 50% { opacity: 1; } + 0%, 100% { + opacity: 0.7; + text-shadow: 0 0 30px rgba(240, 237, 231, 0); + } + 50% { + opacity: 1; + text-shadow: 0 0 40px rgba(240, 237, 231, 0.15); + } } /* Layer 1 */ @@ -270,7 +316,7 @@ export class PageTop extends LitElement { } .article-item:hover { - background: var(--color-surface); + background: var(--color-bg-surface); transform: translateX(4px); } @@ -345,11 +391,11 @@ export class PageTop extends LitElement { } .contact-links li { - border-bottom: 1px solid var(--color-border-light); + border-bottom: 1px solid var(--color-border-subtle); } .contact-links li:first-child { - border-top: 1px solid var(--color-border-light); + border-top: 1px solid var(--color-border-subtle); } .contact-links a { diff --git a/frontend/src/styles/theme-admin.css b/frontend/src/styles/theme-admin.css new file mode 100644 index 0000000..e5ed104 --- /dev/null +++ b/frontend/src/styles/theme-admin.css @@ -0,0 +1,45 @@ +:root[data-theme="admin"] { + /* Semantic Colors - Admin Light Workspace */ + --color-bg-deep: #f5f5f5; + --color-bg-dim: #ffffff; + --color-bg-surface: #ffffff; + + --color-text-primary: #1a1a1a; + --color-text-secondary: #4a4a4a; + --color-text-tertiary: #8a8a8a; + --color-text-mute: #bababa; + + --color-border: #d9d9d9; + --color-border-subtle: #e8e8e8; + + /* Semantic Typography */ + --font-en: var(--font-sans); + --font-jp: var(--font-sans); + --tracking-wide: 0.02em; + --tracking-wider: 0.04em; + + /* Admin specific functional tokens */ + --admin-accent: #0057b8; + --admin-accent-hover: #004494; + --admin-sidebar-width: 220px; + + /* Semantic status colors */ + --color-danger: #c0392b; + --color-danger-bg: rgba(192, 57, 43, 0.06); + --color-success: #3d7a56; + --color-success-bg: rgba(61, 122, 86, 0.06); + --color-notice: #5a6b85; + --color-notice-bg: rgba(90, 107, 133, 0.08); +} + +/* Theme specific global styles */ +html[data-theme="admin"] body { + background-color: var(--color-bg-deep); + color: var(--color-text-primary); + font-family: var(--font-jp); + font-weight: 400; +} + +html[data-theme="admin"] body::before { + display: none; +} diff --git a/frontend/src/styles/theme-public.css b/frontend/src/styles/theme-public.css new file mode 100644 index 0000000..9a536b9 --- /dev/null +++ b/frontend/src/styles/theme-public.css @@ -0,0 +1,43 @@ +:root[data-theme="public"] { + /* Semantic Colors - Chiaroscuro */ + --color-bg-deep: #0d0d0c; + --color-bg-dim: #161514; + --color-bg-surface: #1a1917; + + --color-text-primary: #f0ede7; + --color-text-secondary: #aaa6a0; + --color-text-tertiary: #7a7670; + --color-text-mute: #4a4844; + + --color-border: #2c2a26; + --color-border-subtle: #1f1e1c; + --color-glow: rgba(240, 237, 231, 0.2); + --color-glow-sharp: rgba(240, 237, 231, 0.4); + + /* Semantic Typography */ + --font-en: var(--font-serif-en); + --font-jp: var(--font-serif-jp); + --tracking-wide: 0.08em; + --tracking-wider: 0.15em; +} + +/* Theme specific global styles */ +html[data-theme="public"] body { + background-color: var(--color-bg-deep); + color: var(--color-text-primary); + font-family: var(--font-jp); + font-weight: 300; +} + +html[data-theme="public"] body::before { + content: ""; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 9999; + opacity: 0.04; + background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noiseFilter'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.65' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noiseFilter)'/%3E%3C/svg%3E"); +} diff --git a/frontend/src/styles/tokens.css b/frontend/src/styles/tokens.css new file mode 100644 index 0000000..295e4a4 --- /dev/null +++ b/frontend/src/styles/tokens.css @@ -0,0 +1,22 @@ +:root { + /* Spacing Primitives */ + --space-xs: 8px; + --space-sm: 16px; + --space-md: 32px; + --space-lg: 64px; + --space-xl: 120px; + + /* Typography Primitives */ + --font-serif-en: "Cormorant Garamond", serif; + --font-serif-jp: "Noto Serif JP", serif; + --font-sans: system-ui, -apple-system, sans-serif; + + /* Letter spacing Primitives */ + --tracking-tight: 0.02em; + --tracking-normal: 0.04em; + --tracking-wide: 0.08em; + --tracking-wider: 0.15em; + + /* Motion Primitives */ + --easing-smooth: cubic-bezier(0.22, 1, 0.36, 1); +} diff --git a/frontend/src/tokens.css b/frontend/src/tokens.css deleted file mode 100644 index 07db619..0000000 --- a/frontend/src/tokens.css +++ /dev/null @@ -1,29 +0,0 @@ -:root { - /* Colors */ - --color-bg-top: #f3f2ee; - --color-bg-bottom: #ffffff; - --color-text-primary: #2c2a26; - --color-text-secondary: #9a9690; - --color-text-tertiary: #b8b4ac; - --color-border: #e0ddd6; - --color-border-light: #f0ede7; - --color-surface: #f6f5f1; - - /* Typography */ - --font-en: "Cormorant Garamond", serif; - --font-jp: "Noto Serif JP", serif; - - /* Spacing */ - --space-xs: 8px; - --space-sm: 16px; - --space-md: 32px; - --space-lg: 64px; - --space-xl: 120px; - - /* Letter spacing */ - --tracking-wide: 0.08em; - --tracking-wider: 0.15em; - - /* Easing */ - --easing-smooth: cubic-bezier(0.22, 1, 0.36, 1); -} diff --git a/frontend/src/utils/ambient.ts b/frontend/src/utils/ambient.ts index 802e2a6..2586b06 100644 --- a/frontend/src/utils/ambient.ts +++ b/frontend/src/utils/ambient.ts @@ -1,5 +1,5 @@ const LINE_COUNT = 3 -const COLOR_PRIMARY_RGB = '44, 42, 38' +const COLOR_PRIMARY_RGB = '209, 205, 199' const CROSS_DURATION_MS = 12000 // 12秒で画面を横断 const REPULSION_RADIUS = 150 const REPULSION_STRENGTH = 30 diff --git a/frontend/src/utils/cursor.ts b/frontend/src/utils/cursor.ts index 1124dd9..ded3f77 100644 --- a/frontend/src/utils/cursor.ts +++ b/frontend/src/utils/cursor.ts @@ -1,7 +1,7 @@ const TRAIL_LIFETIME = 2200 // ms const MAX_POINTS = 120 -const COLOR_PRIMARY = '#2c2a26' -const COLOR_PRIMARY_RGB = '44, 42, 38' +const COLOR_PRIMARY = '#d1cdc7' +const COLOR_PRIMARY_RGB = '209, 205, 199' type Point = { x: number; y: number; t: number } type DotState = 'click' | 'hover' | 'idle' @@ -11,7 +11,7 @@ const DOT_STYLES: Record< { size: string; margin: string; opacity: string; duration: string } > = { click: { size: '4px', margin: '1px', opacity: '0.8', duration: '0.1s' }, - hover: { size: '24px', margin: '-9px', opacity: '0.3', duration: '0.3s' }, + hover: { size: '32px', margin: '-13px', opacity: '0.2', duration: '0.4s' }, idle: { size: '6px', margin: '0px', opacity: '0.6', duration: '0.3s' }, } @@ -100,6 +100,21 @@ export function setupCursor(): () => void { return () => window.removeEventListener('touchstart', onTouch) } + // Spotlight overlay + const spotlight = document.createElement('div') + spotlight.style.cssText = ` + position: fixed; + top: 0; left: 0; + width: 600px; height: 600px; + border-radius: 50%; + background: radial-gradient(circle, rgba(${COLOR_PRIMARY_RGB}, 0.03) 0%, rgba(${COLOR_PRIMARY_RGB}, 0) 70%); + pointer-events: none; + z-index: 1; + opacity: 0; + will-change: transform; + transform: translate(-50%, -50%); + ` + // Cursor dot const dot = document.createElement('div') dot.style.cssText = ` @@ -111,7 +126,7 @@ export function setupCursor(): () => void { opacity: 0.6; pointer-events: none; z-index: 9999; - mix-blend-mode: difference; + mix-blend-mode: screen; will-change: transform; transition: width 0.3s ease-out, height 0.3s ease-out, opacity 0.3s ease-out, margin 0.3s ease-out; ` @@ -131,6 +146,7 @@ export function setupCursor(): () => void { document.body.style.cursor = 'none' document.body.appendChild(canvas) + document.body.appendChild(spotlight) document.body.appendChild(dot) const resizeCanvas = () => { @@ -144,6 +160,8 @@ export function setupCursor(): () => void { let targetY = -100 let currentX = -100 let currentY = -100 + let spotlightX = -100 + let spotlightY = -100 let hovering = false let clicking = false let inViewport = false @@ -167,7 +185,17 @@ export function setupCursor(): () => void { currentX = targetX currentY = targetY + // Smooth spotlight follow + spotlightX += (targetX - spotlightX) * 0.1 + spotlightY += (targetY - spotlightY) * 0.1 + dot.style.transform = `translate(${currentX - 3}px, ${currentY - 3}px)` + if (inViewport) { + spotlight.style.opacity = '1' + spotlight.style.transform = `translate(${spotlightX - 300}px, ${spotlightY - 300}px)` + } else { + spotlight.style.opacity = '0' + } // Add point if moved enough const dx = currentX - lastX @@ -250,6 +278,7 @@ export function setupCursor(): () => void { window.removeEventListener('resize', resizeCanvas) dot.remove() canvas.remove() + spotlight.remove() document.body.style.cursor = '' } } diff --git a/frontend/src/utils/scroll.ts b/frontend/src/utils/scroll.ts index d2d9c9b..22d38a2 100644 --- a/frontend/src/utils/scroll.ts +++ b/frontend/src/utils/scroll.ts @@ -65,8 +65,8 @@ export function setupFade(els: Element[]): () => void { export function setupBackgroundShift(): () => void { if (isReducedMotion()) return () => {} - const from = { r: 0xf3, g: 0xf2, b: 0xee } - const to = { r: 0xff, g: 0xff, b: 0xff } + const from = { r: 0x0d, g: 0x0d, b: 0x0c } + const to = { r: 0x16, g: 0x15, b: 0x14 } const handler = () => { const max = document.documentElement.scrollHeight - window.innerHeight diff --git a/mise.toml b/mise.toml index 08988e4..313dc22 100644 --- a/mise.toml +++ b/mise.toml @@ -2,6 +2,7 @@ air = "v1.65.1" "aqua:mfridman/tparse" = "latest" go = "1.26.2" +gotestsum = "latest" node = "24" pnpm = "10" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b001f17..5c9670c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,6 +13,9 @@ importers: '@lit-labs/router': specifier: ^0.1.4 version: 0.1.4 + '@lit/context': + specifier: ^1.1.6 + version: 1.1.6 lit: specifier: ^3.3.2 version: 3.3.2 @@ -104,6 +107,9 @@ packages: '@lit-labs/ssr-dom-shim@1.5.1': resolution: {integrity: sha512-Aou5UdlSpr5whQe8AA/bZG0jMj96CoJIWbGfZ91qieWu5AWUMKw8VR/pAkQkJYvBNhmCcWnZlyyk5oze8JIqYA==} + '@lit/context@1.1.6': + resolution: {integrity: sha512-M26qDE6UkQbZA2mQ3RjJ3Gzd8TxP+/0obMgE5HfkfLhEEyYE3Bui4A5XHiGPjy0MUGAyxB3QgVuw2ciS0kHn6A==} + '@lit/reactive-element@2.1.2': resolution: {integrity: sha512-pbCDiVMnne1lYUIaYNN5wrwQXDtHaYtg7YEFPeW+hws6U47WeFvISGUWekPGKWOP1ygrs0ef0o1VJMk1exos5A==} @@ -463,6 +469,10 @@ snapshots: '@lit-labs/ssr-dom-shim@1.5.1': {} + '@lit/context@1.1.6': + dependencies: + '@lit/reactive-element': 2.1.2 + '@lit/reactive-element@2.1.2': dependencies: '@lit-labs/ssr-dom-shim': 1.5.1