コミットを比較

...

4 コミット

作成者 SHA1 メッセージ 日付
みてるぞ f00e97e335 #34 2026-07-05 20:18:36 +09:00
みてるぞ a0c1b8ba31 #34 2026-07-05 20:18:16 +09:00
みてるぞ 021d13a262 #34 2026-07-05 20:00:58 +09:00
みてるぞ 1b6cac43c2 #34 2026-07-05 18:21:39 +09:00
19個のファイルの変更2070行の追加521行の削除
+38 -30
ファイルの表示
@@ -266,38 +266,45 @@ const value =
《当用漢字による書きかえ》; prefer original forms such as `編輯`. 《当用漢字による書きかえ》; prefer original forms such as `編輯`.
- For user-facing Japanese ellipses, prefer `……` over ASCII `...`. - For user-facing Japanese ellipses, prefer `……` over ASCII `...`.
### Frontend TSX style ### Frontend TypeScript and TSX style
- Preserve the local TSX formatting style. - The delimiter-placement and line-breaking rules in this section apply to
both plain TypeScript `.ts` and TSX `.tsx`, unless a bullet explicitly says
it is JSX- or React-specific.
- Preserve the local TypeScript and TSX formatting style.
- Do not normalize TSX to common Prettier-style React formatting unless - Do not normalize TSX to common Prettier-style React formatting unless
explicitly asked. explicitly asked.
- Treat TSX formatting rules as hard constraints, not preferences. Before - Treat TypeScript and TSX formatting rules as hard constraints, not
finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}` preferences. Before finishing a TypeScript or TSX edit, inspect the edited
placement and fix violations instead of relying on formatter defaults. hunks for closing `)`, `]`, and `}` placement and fix violations instead of
- After every TSX edit, perform a style-only self-review of the edited hunks relying on formatter defaults.
before running verification or reporting completion. The task is not complete - After every TypeScript or TSX edit, perform a style-only self-review of the
while any edited TSX hunk violates these local formatting rules. edited hunks before running verification or reporting completion. The task is
- The TSX self-review must classify every edited leading or trailing `)`, `]`, not complete while any edited TypeScript or TSX hunk violates these local
and `}` by syntax role before deciding whether it is valid. Do not apply a formatting rules.
rule by glyph alone. A closing `)` for a function parameter list is different - The TypeScript/TSX self-review must classify every edited leading or trailing
from a closing `)` for a function call. A closing `}` for a block is different `)`, `]`, and `}` by syntax role before deciding whether it is valid. Do not
from a closing `}` for an object, type literal, import list, or destructuring apply a rule by glyph alone. A closing `)` for a function parameter list is
pattern. different from a closing `)` for a function call. A closing `}` for a block
- The TSX self-review must confirm there are no common Prettier-style React is different from a closing `}` for an object, type literal, import list, or
component declarations with a multi-line destructured parameter. destructuring pattern.
- The TSX self-review must confirm multi-line function declaration parameter - The TSX-specific self-review must confirm there are no common Prettier-style
`)` placement follows the detailed parameter-list rules below. React component declarations with a multi-line destructured parameter.
- The TSX self-review must confirm call-expression `)` is never at the - The TypeScript/TSX self-review must confirm multi-line function declaration
parameter `)` placement follows the detailed parameter-list rules below.
- The TypeScript/TSX self-review must confirm call-expression `)` is never at
the beginning of a line.
- The TypeScript/TSX self-review must confirm
object/type/import/destructuring `}` is not at the beginning of a line.
- The TypeScript/TSX self-review must confirm multi-line
function/lambda/callback/block `}` is on its own line and never at the end
of the previous line.
- The TypeScript/TSX self-review must confirm array `]` is not at the
beginning of a line. beginning of a line.
- The TSX self-review must confirm object/type/import/destructuring `}` is not - The TSX-specific self-review must confirm JSX closing markers and closing
at the beginning of a line. parentheses keep the surrounding compact style.
- The TSX self-review must confirm multi-line function/lambda/callback/block - The TypeScript/TSX self-review must confirm leading indentation follows
`}` is on its own line and never at the end of the previous line. 4-space logical indentation with tabs only as leading 8-space compression.
- The TSX self-review must confirm array `]` is not at the beginning of a line.
- The TSX self-review must confirm JSX closing markers and closing parentheses
keep the surrounding compact style.
- The TSX self-review must confirm leading indentation follows 4-space logical
indentation with tabs only as leading 8-space compression.
- Prefer `const` arrow functions for TypeScript/TSX component and helper declarations. - Prefer `const` arrow functions for TypeScript/TSX component and helper declarations.
- Put two blank lines before and after top-level `const` function - Put two blank lines before and after top-level `const` function
declarations, unless imports, exports, or file boundaries make that awkward. declarations, unless imports, exports, or file boundaries make that awkward.
@@ -699,10 +706,11 @@ Good:
Rule: JavaScript object braces on one line get one inner space. JSX expression Rule: JavaScript object braces on one line get one inner space. JSX expression
braces do not get inner spaces. braces do not get inner spaces.
#### Final TSX self-review checklist #### Final TypeScript/TSX self-review checklist
Before reporting completion after a TypeScript or TSX edit, check the edited Before reporting completion after a TypeScript or TSX edit, check the edited
hunks line by line: hunks line by line. Unless a step explicitly mentions JSX, it applies equally
to `.ts` and `.tsx`:
1. No import/export/type/object/destructuring `}` appears alone at the beginning 1. No import/export/type/object/destructuring `}` appears alone at the beginning
of a line. of a line.
+38
ファイルの表示
@@ -0,0 +1,38 @@
class UserThemeSlotsController < ApplicationController
wrap_parameters false
def index
return head :unauthorized unless current_user
render json: current_user
.theme_slots
.order(:base_theme, :slot_no)
.map { |slot| slot.serializable_hash }
end
def update
return head :unauthorized unless current_user
base_theme = params[:base_theme].to_s
slot_no = params[:slot_no].to_i
tokens = params[:tokens]
unless UserThemeSlot::BASE_THEMES.include?(base_theme)
return render_validation_error fields: { base_theme: ['値が不正です.'] }
end
unless UserThemeSlot::SLOT_NOS.include?(slot_no)
return render_validation_error fields: { slot_no: ['値が不正です.'] }
end
unless tokens.is_a?(ActionController::Parameters) || tokens.is_a?(Hash)
return render_validation_error fields: { tokens: ['JSON object で指定してください.'] }
end
slot = UserThemeSlot.find_or_initialize_by(user: current_user,
base_theme:,
slot_no:)
slot.tokens = tokens.is_a?(ActionController::Parameters) ? tokens.to_unsafe_h : tokens
slot.save!
render json: slot.serializable_hash, status: :ok
end
end
+1
ファイルの表示
@@ -8,6 +8,7 @@ class User < ApplicationRecord
has_many :created_posts, has_many :created_posts,
class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify
has_one :setting, dependent: :destroy has_one :setting, dependent: :destroy
has_many :theme_slots, class_name: 'UserThemeSlot', dependent: :destroy
has_many :user_ips, dependent: :destroy has_many :user_ips, dependent: :destroy
has_many :ip_addresses, through: :user_ips has_many :ip_addresses, through: :user_ips
has_many :user_post_views, dependent: :destroy has_many :user_post_views, dependent: :destroy
+27
ファイルの表示
@@ -0,0 +1,27 @@
class UserThemeSlot < ApplicationRecord
BASE_THEMES = ['light', 'dark'].freeze
SLOT_NOS = [1, 2, 3].freeze
belongs_to :user
validates :user_id, presence: true
validates :base_theme, presence: true, inclusion: { in: BASE_THEMES }
validates :slot_no, presence: true, inclusion: { in: SLOT_NOS }
validates :tokens, presence: true
validates :user_id, uniqueness: { scope: [:base_theme, :slot_no] }
validate :tokens_must_be_object
def serializable_hash(options = nil)
hash = { only: [:base_theme, :slot_no, :tokens, :created_at, :updated_at] }
super(hash.merge(options || {}))
end
private
def tokens_must_be_object
return if tokens.is_a?(Hash)
errors.add(:tokens, 'は JSON object で指定してください.')
end
end
+2
ファイルの表示
@@ -83,6 +83,8 @@ Rails.application.routes.draw do
get 'users/settings', to: 'user_settings#show' get 'users/settings', to: 'user_settings#show'
patch 'users/settings', to: 'user_settings#update' patch 'users/settings', to: 'user_settings#update'
get 'users/theme_slots', to: 'user_theme_slots#index'
put 'users/theme_slots/:base_theme/:slot_no', to: 'user_theme_slots#update'
resources :users, only: [:create, :update] do resources :users, only: [:create, :update] do
collection do collection do
+22
ファイルの表示
@@ -0,0 +1,22 @@
class CreateUserThemeSlots < ActiveRecord::Migration[8.0]
def change
create_table :user_theme_slots do |t|
t.references :user, null: false, foreign_key: true
t.string :base_theme, null: false
t.integer :slot_no, null: false
t.json :tokens, null: false
t.timestamps
end
add_index :user_theme_slots,
[:user_id, :base_theme, :slot_no],
unique: true,
name: 'index_user_theme_slots_on_user_theme_and_slot'
add_check_constraint :user_theme_slots,
"base_theme IN ('light', 'dark')",
name: 'user_theme_slots_base_theme_valid'
add_check_constraint :user_theme_slots,
'slot_no BETWEEN 1 AND 3',
name: 'user_theme_slots_slot_no_valid'
end
end
生成ファイル
+29 -36
ファイルの表示
@@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do ActiveRecord::Schema[8.0].define(version: 2026_07_05_000000) do
create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name", null: false t.string "name", null: false
t.string "record_type", null: false t.string "record_type", null: false
@@ -72,7 +72,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id" t.index ["correct_post_id"], name: "index_gekanator_games_on_correct_post_id"
t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id" t.index ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_id"
t.index ["user_id"], name: "index_gekanator_games_on_user_id" t.index ["user_id"], name: "index_gekanator_games_on_user_id"
t.check_constraint "`question_count` >= 0", name: "chk_gekanator_games_question_count_nonnegative"
end end
create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "gekanator_question_examples", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -275,10 +274,9 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_nico_tag_versions_on_created_at" t.index ["created_at"], name: "index_nico_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } t.index ["created_by_user_id", "created_at"], name: "index_nico_tag_versions_on_created_by_user_id_and_created_at"
t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } t.index ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_id_and_created_at"
t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true t.index ["tag_id", "version_no"], name: "index_nico_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "nico_tag_versions_version_no_positive"
end end
create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "post_implications", primary_key: ["post_id", "parent_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -287,14 +285,13 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id" t.index ["parent_post_id"], name: "index_post_implications_on_parent_post_id"
t.check_constraint "`post_id` <> `parent_post_id`", name: "chk_post_implications_no_self"
end end
create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "post_similarities", primary_key: ["post_id", "target_post_id"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "post_id", null: false t.bigint "post_id", null: false
t.bigint "target_post_id", null: false t.bigint "target_post_id", null: false
t.float "cos", null: false t.float "cos", null: false
t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos", order: { cos: :desc } t.index ["post_id", "cos"], name: "index_post_similarities_on_post_id_and_cos"
t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id" t.index ["target_post_id"], name: "index_post_similarities_on_target_post_id"
end end
@@ -350,8 +347,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
t.index ["post_id"], name: "index_post_versions_on_post_id" t.index ["post_id"], name: "index_post_versions_on_post_id"
t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id" t.index ["video_ms", "post_id"], name: "idx_post_versions_video_ms_post_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive" t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_post_versions_video_ms_positive"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "post_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "post_versions_version_no_positive"
end end
create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "posts", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -369,7 +364,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
t.index ["url"], name: "index_posts_on_url", unique: true t.index ["url"], name: "index_posts_on_url", unique: true
t.index ["video_ms", "id"], name: "idx_posts_video_ms_id" t.index ["video_ms", "id"], name: "idx_posts_video_ms_id"
t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive" t.check_constraint "(`video_ms` is null) or (`video_ms` > 0)", name: "chk_posts_video_ms_positive"
t.check_constraint "`version_no` > 0", name: "chk_posts_version_no_positive"
end end
create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "settings", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -377,6 +371,12 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.string "theme", default: "system", null: false t.string "theme", default: "system", null: false
t.string "display_density", default: "comfortable", null: false
t.string "font_size", default: "normal", null: false
t.integer "post_list_limit", default: 50, null: false
t.string "post_list_order", default: "created_at_desc", null: false
t.string "viewed_post_display", default: "show", null: false
t.boolean "tag_autocomplete_nico", default: true, null: false
t.string "auto_fetch_title", default: "manual", null: false t.string "auto_fetch_title", default: "manual", null: false
t.string "auto_fetch_thumbnail", default: "manual", null: false t.string "auto_fetch_thumbnail", default: "manual", null: false
t.string "wiki_editor_mode", default: "split", null: false t.string "wiki_editor_mode", default: "split", null: false
@@ -418,7 +418,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
t.bigint "tag_id", null: false t.bigint "tag_id", null: false
t.bigint "target_tag_id", null: false t.bigint "target_tag_id", null: false
t.float "cos", null: false t.float "cos", null: false
t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos", order: { cos: :desc } t.index ["tag_id", "cos"], name: "index_tag_similarities_on_tag_id_and_cos"
t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id" t.index ["target_tag_id"], name: "index_tag_similarities_on_target_tag_id"
end end
@@ -428,32 +428,30 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
t.string "event_type", null: false t.string "event_type", null: false
t.string "name", null: false t.string "name", null: false
t.string "category", null: false t.string "category", null: false
t.datetime "deprecated_at"
t.text "aliases", null: false t.text "aliases", null: false
t.text "parent_tag_ids", null: false t.text "parent_tag_ids", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.bigint "created_by_user_id" t.bigint "created_by_user_id"
t.index ["created_at"], name: "index_tag_versions_on_created_at" t.index ["created_at"], name: "index_tag_versions_on_created_at"
t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at", order: { created_at: :desc } t.index ["created_by_user_id", "created_at"], name: "index_tag_versions_on_created_by_user_id_and_created_at"
t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at", order: { created_at: :desc } t.index ["tag_id", "created_at"], name: "index_tag_versions_on_tag_id_and_created_at"
t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true t.index ["tag_id", "version_no"], name: "index_tag_versions_on_tag_id_and_version_no", unique: true
t.check_constraint "`version_no` > 0", name: "tag_versions_version_no_positive"
end end
create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "tag_name_id", null: false t.bigint "tag_name_id", null: false
t.string "category", default: "general", null: false t.string "category", default: "general", null: false
t.datetime "deprecated_at"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.integer "post_count", default: 0, null: false t.integer "post_count", default: 0, null: false
t.datetime "deprecated_at"
t.datetime "discarded_at" t.datetime "discarded_at"
t.integer "version_no", null: false t.integer "version_no", null: false
t.index ["deprecated_at"], name: "index_tags_on_deprecated_at" t.index ["deprecated_at"], name: "index_tags_on_deprecated_at"
t.index ["discarded_at"], name: "index_tags_on_discarded_at" t.index ["discarded_at"], name: "index_tags_on_discarded_at"
t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true t.index ["tag_name_id"], name: "index_tags_on_tag_name_id", unique: true
t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico" t.check_constraint "(`deprecated_at` is null) or (`category` <> _utf8mb4'nico')", name: "chk_tags_deprecated_at_not_nico"
t.check_constraint "`version_no` > 0", name: "chk_tags_version_no_positive"
end end
create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "theatre_comments", primary_key: ["theatre_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -568,6 +566,19 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
t.index ["post_id"], name: "index_user_post_views_on_post_id" t.index ["post_id"], name: "index_user_post_views_on_post_id"
end end
create_table "user_theme_slots", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "user_id", null: false
t.string "base_theme", null: false
t.integer "slot_no", null: false
t.json "tokens", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["user_id", "base_theme", "slot_no"], name: "index_user_theme_slots_on_user_theme_and_slot", unique: true
t.index ["user_id"], name: "index_user_theme_slots_on_user_id"
t.check_constraint "`base_theme` in (_utf8mb4'light',_utf8mb4'dark')", name: "user_theme_slots_base_theme_valid"
t.check_constraint "`slot_no` between 1 and 3", name: "user_theme_slots_slot_no_valid"
end
create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "users", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "name" t.string "name"
t.string "inheritance_code", limit: 64, null: false t.string "inheritance_code", limit: 64, null: false
@@ -578,19 +589,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
t.index ["banned_at"], name: "index_users_on_banned_at" t.index ["banned_at"], name: "index_users_on_banned_at"
end end
create_table "wiki_assets", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.bigint "wiki_page_id", null: false
t.integer "no", null: false
t.string "alt_text"
t.binary "sha256", limit: 32, null: false
t.bigint "created_by_user_id", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id"
t.index ["wiki_page_id", "no"], name: "index_wiki_assets_on_wiki_page_id_and_no", unique: true
t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true
end
create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
t.string "sha256", limit: 64, null: false t.string "sha256", limit: 64, null: false
t.text "body", null: false t.text "body", null: false
@@ -607,13 +605,11 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
t.datetime "discarded_at" t.datetime "discarded_at"
t.integer "next_asset_no", default: 1, null: false
t.integer "version_no", null: false t.integer "version_no", null: false
t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id"
t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at"
t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true
t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id" t.index ["updated_user_id"], name: "index_wiki_pages_on_updated_user_id"
t.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive"
end end
create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| create_table "wiki_revision_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t|
@@ -658,8 +654,6 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id" t.index ["created_by_user_id"], name: "index_wiki_versions_on_created_by_user_id"
t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true t.index ["wiki_page_id", "version_no"], name: "index_wiki_versions_on_wiki_page_id_and_version_no", unique: true
t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id" t.index ["wiki_page_id"], name: "index_wiki_versions_on_wiki_page_id"
t.check_constraint "`event_type` in (_utf8mb4'create',_utf8mb4'update',_utf8mb4'discard',_utf8mb4'restore')", name: "wiki_versions_event_type_valid"
t.check_constraint "`version_no` > 0", name: "wiki_versions_version_no_positive"
end end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id" add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
@@ -740,8 +734,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do
add_foreign_key "user_ips", "users" add_foreign_key "user_ips", "users"
add_foreign_key "user_post_views", "posts" add_foreign_key "user_post_views", "posts"
add_foreign_key "user_post_views", "users" add_foreign_key "user_post_views", "users"
add_foreign_key "wiki_assets", "users", column: "created_by_user_id" add_foreign_key "user_theme_slots", "users"
add_foreign_key "wiki_assets", "wiki_pages"
add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "tag_names"
add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "created_user_id"
add_foreign_key "wiki_pages", "users", column: "updated_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id"
+36 -28
ファイルの表示
@@ -122,37 +122,44 @@ pass or the remaining failure is clearly blocked.
if the class cannot be statically detected. if the class cannot be statically detected.
- Do not introduce new UI libraries or production dependencies without approval. - Do not introduce new UI libraries or production dependencies without approval.
## TSX formatting ## TypeScript and TSX formatting
- The delimiter-placement and line-breaking rules in this section apply to
both plain TypeScript `.ts` and TSX `.tsx`, unless a bullet explicitly says
it is JSX- or React-specific.
- Preserve compact TSX expression shapes such as inline ternary branches and - Preserve compact TSX expression shapes such as inline ternary branches and
closing `</div>)` forms when nearby code uses them. closing `</div>)` forms when nearby code uses them.
- Treat TSX formatting rules as hard constraints, not preferences. Before - Treat TypeScript and TSX formatting rules as hard constraints, not
finishing a TSX edit, inspect the edited hunks for closing `)`, `]`, and `}` preferences. Before finishing a TypeScript or TSX edit, inspect the edited
placement and fix violations instead of relying on formatter defaults. hunks for closing `)`, `]`, and `}` placement and fix violations instead of
- After every TSX edit, perform a style-only self-review of the edited hunks relying on formatter defaults.
before running verification or reporting completion. The task is not complete - After every TypeScript or TSX edit, perform a style-only self-review of the
while any edited TSX hunk violates these local formatting rules. edited hunks before running verification or reporting completion. The task is
- The TSX self-review must classify every edited leading or trailing `)`, `]`, not complete while any edited TypeScript or TSX hunk violates these local
and `}` by syntax role before deciding whether it is valid. Do not apply a formatting rules.
rule by glyph alone. A closing `)` for a function parameter list is different - The TypeScript/TSX self-review must classify every edited leading or trailing
from a closing `)` for a function call. A closing `}` for a block is different `)`, `]`, and `}` by syntax role before deciding whether it is valid. Do not
from a closing `}` for an object, type literal, import list, or destructuring apply a rule by glyph alone. A closing `)` for a function parameter list is
pattern. different from a closing `)` for a function call. A closing `}` for a block
- The TSX self-review must confirm there are no common Prettier-style React is different from a closing `}` for an object, type literal, import list, or
component declarations with a multi-line destructured parameter. destructuring pattern.
- The TSX self-review must confirm multi-line function declaration parameter - The TSX-specific self-review must confirm there are no common Prettier-style
`)` placement follows the detailed parameter-list rules below. React component declarations with a multi-line destructured parameter.
- The TSX self-review must confirm call-expression `)` is never at the - The TypeScript/TSX self-review must confirm multi-line function declaration
parameter `)` placement follows the detailed parameter-list rules below.
- The TypeScript/TSX self-review must confirm call-expression `)` is never at
the beginning of a line.
- The TypeScript/TSX self-review must confirm
object/type/import/destructuring `}` is not at the beginning of a line.
- The TypeScript/TSX self-review must confirm multi-line
function/lambda/callback/block `}` is on its own line and never at the end
of the previous line.
- The TypeScript/TSX self-review must confirm array `]` is not at the
beginning of a line. beginning of a line.
- The TSX self-review must confirm object/type/import/destructuring `}` is not - The TSX-specific self-review must confirm JSX closing markers and closing
at the beginning of a line. parentheses keep the surrounding compact style.
- The TSX self-review must confirm multi-line function/lambda/callback/block - The TypeScript/TSX self-review must confirm leading indentation follows
`}` is on its own line and never at the end of the previous line. 4-space logical indentation with tabs only as leading 8-space compression.
- The TSX self-review must confirm array `]` is not at the beginning of a line.
- The TSX self-review must confirm JSX closing markers and closing parentheses
keep the surrounding compact style.
- The TSX self-review must confirm leading indentation follows 4-space logical
indentation with tabs only as leading 8-space compression.
- For long Tailwind `className` strings, wrap across lines only when needed. - For long Tailwind `className` strings, wrap across lines only when needed.
- Keep continuation indentation aligned with the 4-space logical indentation - Keep continuation indentation aligned with the 4-space logical indentation
rule, using tabs only as leading 8-space compression. rule, using tabs only as leading 8-space compression.
@@ -215,7 +222,8 @@ pass or the remaining failure is clearly blocked.
### Delimiter decision table ### Delimiter decision table
Use this table before accepting any edited TypeScript or TSX hunk. The table is Use this table before accepting any edited TypeScript or TSX hunk. The table is
more authoritative than formatter habit. more authoritative than formatter habit. Unless a subsection explicitly
mentions JSX, it applies equally to `.ts` and `.tsx`.
#### Import and export named bindings #### Import and export named bindings
+16 -6
ファイルの表示
@@ -13,8 +13,12 @@ import DialogueProvider from '@/components/dialogues/DialogueProvider'
import { Toaster } from '@/components/ui/toaster' import { Toaster } from '@/components/ui/toaster'
import { apiPost, isApiError } from '@/lib/api' import { apiPost, isApiError } from '@/lib/api'
import { applyClientAppearance, import { applyClientAppearance,
fetchUserThemeSlots,
fetchUserSettings, fetchUserSettings,
getClientThemeMode, getClientThemeMode,
hasStoredClientThemeSelection,
normaliseUserThemeSlots,
setCachedUserThemeSlots,
seedClientThemeMode } from '@/lib/settings' seedClientThemeMode } from '@/lib/settings'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
@@ -175,12 +179,18 @@ const App: FC = () => {
return return
void (async () => { void (async () => {
try try
{ {
const settings = await fetchUserSettings () const [settings, themeSlots] = await Promise.all ([
seedClientThemeMode (settings.theme) fetchUserSettings (),
applyClientAppearance () fetchUserThemeSlots (),
} ])
const storedThemeSlots = normaliseUserThemeSlots (themeSlots)
setCachedUserThemeSlots (storedThemeSlots)
if (!(hasStoredClientThemeSelection ()))
seedClientThemeMode (settings.theme)
applyClientAppearance ()
}
catch catch
{ {
return return
+7
ファイルの表示
@@ -5,6 +5,7 @@ import { createPath, useNavigate } from 'react-router-dom'
import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { useOverlayStore } from '@/components/RouteBlockerOverlay'
import { prefetchForURL } from '@/lib/prefetchers' import { prefetchForURL } from '@/lib/prefetchers'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react'
@@ -33,6 +34,8 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const navigate = useNavigate () const navigate = useNavigate ()
const qc = useQueryClient () const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent'
const url = useMemo (() => { const url = useMemo (() => {
const path = (typeof to === 'string') ? to : createPath (to) const path = (typeof to === 'string') ? to : createPath (to)
return (new URL (path, location.origin)).toString () return (new URL (path, location.origin)).toString ()
@@ -54,11 +57,15 @@ export default forwardRef<HTMLAnchorElement, Props> (({
const handleMouseEnter = async (ev: MouseEvent<HTMLAnchorElement>) => { const handleMouseEnter = async (ev: MouseEvent<HTMLAnchorElement>) => {
onMouseEnter?.(ev) onMouseEnter?.(ev)
if (ev.defaultPrevented || linkPreloadMode !== 'intent')
return
await doPrefetch () await doPrefetch ()
} }
const handleTouchStart = async (ev: TouchEvent<HTMLAnchorElement>) => { const handleTouchStart = async (ev: TouchEvent<HTMLAnchorElement>) => {
onTouchStart?.(ev) onTouchStart?.(ev)
if (ev.defaultPrevented || linkPreloadMode !== 'intent')
return
await doPrefetch () await doPrefetch ()
} }
+54 -2
ファイルの表示
@@ -21,6 +21,7 @@ import { toast } from '@/components/ui/use-toast'
import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { CATEGORIES, CATEGORY_NAMES } from '@/consts'
import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api' import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api'
import { postsKeys, tagsKeys } from '@/lib/queryKeys' import { postsKeys, tagsKeys } from '@/lib/queryKeys'
import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings'
import { dateString, originalCreatedAtString } from '@/lib/utils' import { dateString, originalCreatedAtString } from '@/lib/utils'
import type { DragEndEvent } from '@dnd-kit/core' import type { DragEndEvent } from '@dnd-kit/core'
@@ -29,6 +30,9 @@ import type { FC, MutableRefObject, ReactNode } from 'react'
import type { Category, Post, TagWithSections } from '@/types' import type { Category, Post, TagWithSections } from '@/types'
type TagByCategory = { [key in Category]: TagWithSections[] } type TagByCategory = { [key in Category]: TagWithSections[] }
type FlatTagRow = {
tag: TagWithSections
parentTagId?: number }
const renderTagTree = ( const renderTagTree = (
@@ -61,6 +65,33 @@ const renderTagTree = (
} }
const flattenTagTree = (
tags: TagWithSections[],
): FlatTagRow[] => {
const seen = new Set<number> ()
const rows: FlatTagRow[] = []
const visit = (
tag: TagWithSections,
parentTagId?: number,
) => {
if (seen.has (tag.id))
return
seen.add (tag.id)
rows.push ({ tag, parentTagId })
for (const child of tag.children ?? [])
visit (child, tag.id)
}
for (const tag of tags)
visit (tag)
return rows.sort ((rowA, rowB) => rowA.tag.name < rowB.tag.name ? -1 : 1)
}
const isDescendant = ( const isDescendant = (
root: TagWithSections, root: TagWithSections,
targetId: number, targetId: number,
@@ -156,6 +187,8 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
sp = Boolean (sp) sp = Boolean (sp)
const qc = useQueryClient () const qc = useQueryClient ()
const behaviourSettings = useClientBehaviourSettings ()
const tagRelationDisplay = behaviourSettings.tagRelationDisplay ?? 'grouped'
const baseTags = useMemo<TagByCategory> (() => { const baseTags = useMemo<TagByCategory> (() => {
const tagsTmp = { } as TagByCategory const tagsTmp = { } as TagByCategory
@@ -321,8 +354,27 @@ const TagDetailSidebar: FC<Props> = ({ className, post, sp }) => {
</SubsectionTitle> </SubsectionTitle>
<ul> <ul>
{(tags[cat] ?? []).flatMap (tag => ( {(tagRelationDisplay === 'grouped'
renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined, sp)))} ? (tags[cat] ?? []).flatMap (tag =>
renderTagTree (
tag,
0,
`cat-${ cat }`,
suppressClickRef,
undefined,
sp,
),
)
: flattenTagTree (tags[cat] ?? []).map (row => (
<li key={`flat-${ cat }-${ row.tag.id }`} className="mb-1">
<DraggableDroppableTagRow
tag={row.tag}
nestLevel={0}
pathKey={`flat-${ cat }-${ row.tag.id }`}
parentTagId={row.parentTagId}
suppressClickRef={suppressClickRef}
sp={sp}/>
</li>)))}
<DropSlot cat={cat}/> <DropSlot cat={cat}/>
</ul> </ul>
</div>))} </div>))}
+26 -31
ファイルの表示
@@ -208,13 +208,11 @@ const TopNav: FC<Props> = ({ user }) => {
return ( return (
<> <>
<nav className="px-3 flex justify-between items-center w-full <nav className="top-nav-root px-3 flex justify-between items-center w-full">
bg-yellow-200 dark:bg-red-975 md:bg-yellow-50">
<div className="flex items-center gap-2 h-12"> <div className="flex items-center gap-2 h-12">
<PrefetchLink <PrefetchLink
to="/posts" to="/posts"
className="mx-4 text-xl font-bold text-pink-600 hover:text-pink-400 className="top-nav-brand-link mx-4 text-xl font-bold"
dark:text-pink-300 dark:hover:text-pink-100"
onClick={() => { onClick={() => {
scroll (0, 0) scroll (0, 0)
}}> }}>
@@ -224,7 +222,7 @@ const TopNav: FC<Props> = ({ user }) => {
<div ref={navRef} className="relative hidden md:flex h-12 items-center"> <div ref={navRef} className="relative hidden md:flex h-12 items-center">
<div aria-hidden <div aria-hidden
className={cn ('absolute inset-y-0 h-12', className={cn ('absolute inset-y-0 h-12',
'bg-yellow-200 dark:bg-red-950', 'top-nav-highlight',
highlightTransitionClass)} highlightTransitionClass)}
style={{ width: hl.width, style={{ width: hl.width,
transform: `translateX(${ hl.left }px)`, transform: `translateX(${ hl.left }px)`,
@@ -246,7 +244,7 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => { ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[i] = el itemsRef.current[i] = el
}} }}
className={cn ('relative z-10 flex h-full items-center px-5', className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'font-bold')}> (i === openItemIdx) && 'font-bold')}>
{item.name} {item.name}
</PrefetchLink> </PrefetchLink>
@@ -261,7 +259,7 @@ const TopNav: FC<Props> = ({ user }) => {
setMoreVsbl (true) setMoreVsbl (true)
measure (-1) measure (-1)
}} }}
className={cn ('relative z-10 flex h-full items-center px-5', className={cn ('top-nav-menu-link relative z-10 flex h-full items-center px-5',
(openItemIdx < 0 || moreVsbl) && 'font-bold')}> (openItemIdx < 0 || moreVsbl) && 'font-bold')}>
&raquo; &raquo;
</PrefetchLink> </PrefetchLink>
@@ -272,9 +270,7 @@ const TopNav: FC<Props> = ({ user }) => {
<button <button
type="button" type="button"
className="md:hidden ml-auto border-0 bg-transparent pr-4 className="top-nav-mobile-toggle md:hidden ml-auto border-0 bg-transparent pr-4"
text-pink-600 hover:text-pink-400
dark:text-pink-300 dark:hover:text-pink-100"
onClick={() => { onClick={() => {
setMenuOpen (!(menuOpen)) setMenuOpen (!(menuOpen))
}}> }}>
@@ -287,8 +283,7 @@ const TopNav: FC<Props> = ({ user }) => {
key="submenu-shell" key="submenu-shell"
{...(animationsOff ? { } {...(animationsOff ? { }
: { layout: true })} : { layout: true })}
className="relative z-20 hidden md:block overflow-hidden className="relative z-20 hidden md:block overflow-hidden top-nav-submenu"
bg-yellow-200 dark:bg-red-950"
animate={{ height: submenuHeight }} animate={{ height: submenuHeight }}
onMouseLeave={() => { onMouseLeave={() => {
if (moreVsbl) if (moreVsbl)
@@ -349,7 +344,7 @@ const TopNav: FC<Props> = ({ user }) => {
to={subItem.to} to={subItem.to}
target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined} target={subItem.to.slice (0, 2) === '//' ? '_blank' : undefined}
onClick={() => setMoreVsbl (false)} onClick={() => setMoreVsbl (false)}
className="h-full flex items-center px-3"> className="top-nav-menu-link h-full flex items-center px-3">
{subItem.name} {subItem.name}
</PrefetchLink> </PrefetchLink>
</motion.div>)))} </motion.div>)))}
@@ -376,7 +371,7 @@ const TopNav: FC<Props> = ({ user }) => {
target={item.to.slice (0, 2) === '//' target={item.to.slice (0, 2) === '//'
? '_blank' ? '_blank'
: undefined} : undefined}
className="h-full flex items-center px-3"> className="top-nav-menu-link h-full flex items-center px-3">
{item.name} {item.name}
</PrefetchLink> </PrefetchLink>
</div>)))} </div>)))}
@@ -415,7 +410,7 @@ const TopNav: FC<Props> = ({ user }) => {
target={item.to.slice (0, 2) === '//' target={item.to.slice (0, 2) === '//'
? '_blank' ? '_blank'
: undefined} : undefined}
className="h-full flex items-center px-3"> className="top-nav-menu-link h-full flex items-center px-3">
{item.name} {item.name}
</PrefetchLink> </PrefetchLink>
</motion.div>)))} </motion.div>)))}
@@ -429,16 +424,16 @@ const TopNav: FC<Props> = ({ user }) => {
? ( ? (
menuOpen && ( menuOpen && (
<div <div
className={cn ('flex flex-col md:hidden', className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
'bg-yellow-200 dark:bg-red-975 items-start')}> 'items-start')}>
<Separator/> <Separator/>
{visibleMenu.map ((item, i) => ( {visibleMenu.map ((item, i) => (
<Fragment key={i}> <Fragment key={i}>
<PrefetchLink <PrefetchLink
to={i === openItemIdx ? item.to : '#'} to={i === openItemIdx ? item.to : '#'}
className={cn ('w-full min-h-[40px] flex items-center pl-8', className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx) ((i === openItemIdx)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))} && 'top-nav-mobile-active font-bold'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => { onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx) if (i !== openItemIdx)
{ {
@@ -450,7 +445,7 @@ const TopNav: FC<Props> = ({ user }) => {
</PrefetchLink> </PrefetchLink>
{i === openItemIdx && ( {i === openItemIdx && (
<div className="w-full bg-yellow-50 dark:bg-red-950"> <div className="top-nav-submenu w-full">
{item.subMenu {item.subMenu
.filter (subItem => subItem.visible ?? true) .filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => ( .map ((subItem, j) => (
@@ -466,7 +461,7 @@ const TopNav: FC<Props> = ({ user }) => {
target={subItem.to.slice (0, 2) === '//' target={subItem.to.slice (0, 2) === '//'
? '_blank' ? '_blank'
: undefined} : undefined}
className="w-full min-h-[36px] flex items-center pl-12"> className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
{subItem.name} {subItem.name}
</PrefetchLink>)))} </PrefetchLink>)))}
</div>)} </div>)}
@@ -476,9 +471,9 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => { ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el itemsRef.current[visibleMenu.length] = el
}} }}
className={cn ('w-full min-h-[40px] flex items-center pl-8', className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0) ((openItemIdx < 0)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}> && 'top-nav-mobile-active font-bold'))}>
&raquo; &raquo;
</PrefetchLink> </PrefetchLink>
<TopNavUser user={user} sp/> <TopNavUser user={user} sp/>
@@ -489,8 +484,8 @@ const TopNav: FC<Props> = ({ user }) => {
{menuOpen && ( {menuOpen && (
<motion.div <motion.div
key="spmenu" key="spmenu"
className={cn ('flex flex-col md:hidden', className={cn ('top-nav-root top-nav-mobile-menu flex flex-col md:hidden',
'bg-yellow-200 dark:bg-red-975 items-start')} 'items-start')}
variants={{ closed: { clipPath: 'inset(0 0 100% 0)', variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0 }, height: 0 },
open: { clipPath: 'inset(0 0 0% 0)', open: { clipPath: 'inset(0 0 0% 0)',
@@ -504,9 +499,9 @@ const TopNav: FC<Props> = ({ user }) => {
<Fragment key={i}> <Fragment key={i}>
<PrefetchLink <PrefetchLink
to={i === openItemIdx ? item.to : '#'} to={i === openItemIdx ? item.to : '#'}
className={cn ('w-full min-h-[40px] flex items-center pl-8', className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((i === openItemIdx) ((i === openItemIdx)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))} && 'top-nav-mobile-active font-bold'))}
onClick={(ev: MouseEvent<HTMLAnchorElement>) => { onClick={(ev: MouseEvent<HTMLAnchorElement>) => {
if (i !== openItemIdx) if (i !== openItemIdx)
{ {
@@ -521,7 +516,7 @@ const TopNav: FC<Props> = ({ user }) => {
{i === openItemIdx && ( {i === openItemIdx && (
<motion.div <motion.div
key={`sp-sub-${ i }`} key={`sp-sub-${ i }`}
className="w-full bg-yellow-50 dark:bg-red-950" className="top-nav-submenu w-full"
variants={{ closed: { clipPath: 'inset(0 0 100% 0)', variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0, height: 0,
opacity: 0 }, opacity: 0 },
@@ -547,7 +542,7 @@ const TopNav: FC<Props> = ({ user }) => {
target={subItem.to.slice (0, 2) === '//' target={subItem.to.slice (0, 2) === '//'
? '_blank' ? '_blank'
: undefined} : undefined}
className="w-full min-h-[36px] flex items-center pl-12"> className="top-nav-mobile-row w-full min-h-[36px] flex items-center pl-12">
{subItem.name} {subItem.name}
</PrefetchLink>)))} </PrefetchLink>)))}
</motion.div>)} </motion.div>)}
@@ -558,9 +553,9 @@ const TopNav: FC<Props> = ({ user }) => {
ref={(el: (HTMLAnchorElement | null)) => { ref={(el: (HTMLAnchorElement | null)) => {
itemsRef.current[visibleMenu.length] = el itemsRef.current[visibleMenu.length] = el
}} }}
className={cn ('w-full min-h-[40px] flex items-center pl-8', className={cn ('top-nav-mobile-row w-full min-h-[40px] flex items-center pl-8',
((openItemIdx < 0) ((openItemIdx < 0)
&& 'font-bold bg-yellow-50 dark:bg-red-950'))}> && 'top-nav-mobile-active font-bold'))}>
&raquo; &raquo;
</PrefetchLink> </PrefetchLink>
<TopNavUser user={user} sp/> <TopNavUser user={user} sp/>
+47 -4
ファイルの表示
@@ -10,10 +10,16 @@ import type { FC, ReactNode } from 'react'
import type { ClientAnimationMode, import type { ClientAnimationMode,
ClientBehaviorSettings, ClientBehaviorSettings,
ClientEmbedAutoLoadMode, ClientEmbedAutoLoadMode,
ClientLinkPreloadMode,
ClientTagRelationDisplayMode,
ClientThumbnailMode } from '@/lib/settings' ClientThumbnailMode } from '@/lib/settings'
type Props = { type Props = {
sectionClassName: string } sectionClassName: string
onDirtyStateChange?: (
dirty: boolean,
discard: () => void,
) => void }
type SegmentedOption<T extends string> = { type SegmentedOption<T extends string> = {
value: T value: T
@@ -39,6 +45,14 @@ const embedAutoLoadOptions: SegmentedOption<ClientEmbedAutoLoadMode>[] = [
{ value: 'manual', label: 'クリック時' }, { value: 'manual', label: 'クリック時' },
{ value: 'auto', label: '自動' }] { value: 'auto', label: '自動' }]
const linkPreloadOptions: SegmentedOption<ClientLinkPreloadMode>[] = [
{ value: 'off', label: 'しない' },
{ value: 'intent', label: 'マウスを置いたら' }]
const tagRelationDisplayOptions: SegmentedOption<ClientTagRelationDisplayMode>[] = [
{ value: 'flat', label: 'まとめない' },
{ value: 'grouped', label: 'まとめる' }]
const thumbnailModeOptions: SegmentedOption<ClientThumbnailMode>[] = [ const thumbnailModeOptions: SegmentedOption<ClientThumbnailMode>[] = [
{ value: 'off', label: '非表示' }, { value: 'off', label: '非表示' },
{ value: 'light', label: '軽量' }, { value: 'light', label: '軽量' },
@@ -63,8 +77,8 @@ const SegmentedControl = <T extends string,> (
'transition-colors focus-visible:outline-none focus-visible:ring-2', 'transition-colors focus-visible:outline-none focus-visible:ring-2',
'focus-visible:ring-ring focus-visible:ring-offset-2', 'focus-visible:ring-ring focus-visible:ring-offset-2',
value === option.value value === option.value
? 'bg-background text-foreground shadow-sm' ? 'bg-primary text-primary-foreground shadow-sm ring-1 ring-primary/40'
: 'text-muted-foreground hover:bg-background/70 hover:text-foreground', : 'bg-transparent text-muted-foreground hover:bg-background/70 hover:text-foreground',
)} )}
onClick={() => onChange (option.value)}> onClick={() => onChange (option.value)}>
{option.label} {option.label}
@@ -85,7 +99,10 @@ const SettingBlock: FC<SettingBlockProps> = (
</div>) </div>)
const BehaviourSettingsSection: FC<Props> = ({ sectionClassName }) => { const BehaviourSettingsSection: FC<Props> = (
{ sectionClassName,
onDirtyStateChange },
) => {
const [savedSettings, setSavedSettings] = const [savedSettings, setSavedSettings] =
useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ()) useState<ClientBehaviorSettings> (() => getClientBehaviorSettings ())
const [draftSettings, setDraftSettings] = const [draftSettings, setDraftSettings] =
@@ -119,6 +136,14 @@ const BehaviourSettingsSection: FC<Props> = ({ sectionClassName }) => {
setDraftSettings (savedSettings) setDraftSettings (savedSettings)
} }
useEffect (() => {
onDirtyStateChange?.(hasUnsavedChanges, handleDiscard)
return () => {
onDirtyStateChange?.(false, () => { })
}
}, [handleDiscard, hasUnsavedChanges, onDirtyStateChange])
return ( return (
<section className={sectionClassName}> <section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
@@ -157,6 +182,24 @@ const BehaviourSettingsSection: FC<Props> = ({ sectionClassName }) => {
onChange={value => updateDraft ('animation', value)}/> onChange={value => updateDraft ('animation', value)}/>
</SettingBlock> </SettingBlock>
<SettingBlock
title="リンク先の先読み"
description="リンクにマウスを置いた時などに、移動先の情報を先に読みます。通信量や意図しない読込みが気になる場合は「しない」にできます。">
<SegmentedControl
value={draftSettings.linkPreload ?? 'intent'}
options={linkPreloadOptions}
onChange={value => updateDraft ('linkPreload', value)}/>
</SettingBlock>
<SettingBlock
title="タグの親子関係表示"
description="親子関係があるタグを、まとまりとして表示します。見た目を単純にしたい場合は「まとめない」にできます。">
<SegmentedControl
value={draftSettings.tagRelationDisplay ?? 'grouped'}
options={tagRelationDisplayOptions}
onChange={value => updateDraft ('tagRelationDisplay', value)}/>
</SettingBlock>
<SettingBlock <SettingBlock
title="埋め込み自動読込" title="埋め込み自動読込"
description="外部埋め込みを自動で読み込むかを切り替えます。"> description="外部埋め込みを自動で読み込むかを切り替えます。">
+16 -1
ファイルの表示
@@ -19,10 +19,17 @@ import type { FC } from 'react'
type Props = { type Props = {
sectionClassName: string sectionClassName: string
onDirtyStateChange?: (
dirty: boolean,
discard: () => void,
) => void
} }
const KeyboardSettingsSection: FC<Props> = ({ sectionClassName }) => { const KeyboardSettingsSection: FC<Props> = (
{ sectionClassName,
onDirtyStateChange },
) => {
const { const {
keyboardSettings, keyboardSettings,
saveKeyboardSettings, saveKeyboardSettings,
@@ -127,6 +134,14 @@ const KeyboardSettingsSection: FC<Props> = ({ sectionClassName }) => {
setCapturingActionId (null) setCapturingActionId (null)
} }
useEffect (() => {
onDirtyStateChange?.(hasUnsavedChanges, handleDiscard)
return () => {
onDirtyStateChange?.(false, () => { })
}
}, [handleDiscard, hasUnsavedChanges, onDirtyStateChange])
return ( return (
<section className={sectionClassName}> <section className={sectionClassName}>
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
+71
ファイルの表示
@@ -34,6 +34,13 @@
--input: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%; --ring: 222.2 84% 4.9%;
--theme-link: 221.2 83.2% 53.3%; --theme-link: 221.2 83.2% 53.3%;
--top-nav-root-bg-mobile: #fef08a;
--top-nav-root-bg-desktop: #fefce8;
--top-nav-active-bg: #fef08a;
--top-nav-submenu-bg: #fef08a;
--top-nav-mobile-active-bg: #fefce8;
--top-nav-brand-link: #db2777;
--top-nav-menu-link: #1d4ed8;
--tag-colour-deerjikist: #9f1239; --tag-colour-deerjikist: #9f1239;
--tag-colour-deerjikist-hover: #b62a51; --tag-colour-deerjikist-hover: #b62a51;
@@ -78,6 +85,13 @@
--border: 217.2 32.6% 17.5%; --border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%; --ring: 212.7 26.8% 83.9%;
--top-nav-root-bg-mobile: #230505;
--top-nav-root-bg-desktop: #230505;
--top-nav-active-bg: #450a0a;
--top-nav-submenu-bg: #450a0a;
--top-nav-mobile-active-bg: #450a0a;
--top-nav-brand-link: #f9a8d4;
--top-nav-menu-link: #93c5fd;
} }
body body
@@ -197,6 +211,63 @@ body
color: var(--tag-link-hover-colour) !important; color: var(--tag-link-hover-colour) !important;
} }
.top-nav-root
{
background: var(--top-nav-root-bg-mobile);
}
@media (min-width: 768px)
{
.top-nav-root
{
background: var(--top-nav-root-bg-desktop);
}
}
.top-nav-brand-link,
.top-nav-mobile-toggle
{
color: var(--top-nav-brand-link);
}
.top-nav-brand-link:hover,
.top-nav-mobile-toggle:hover
{
color: color-mix(in oklab, var(--top-nav-brand-link), white 18%);
}
.top-nav-menu-link,
.top-nav-mobile-row
{
color: var(--top-nav-menu-link);
}
.top-nav-menu-link:hover,
.top-nav-mobile-row:hover
{
color: color-mix(in oklab, var(--top-nav-menu-link), white 18%);
}
.top-nav-highlight
{
background: var(--top-nav-active-bg);
}
.top-nav-submenu
{
background: var(--top-nav-submenu-bg);
}
.top-nav-mobile-menu
{
background: var(--top-nav-root-bg-mobile);
}
.top-nav-mobile-active
{
background: var(--top-nav-mobile-active-bg);
}
.tag-marquee__animated .tag-marquee__animated
{ {
position: absolute; position: absolute;
+11 -11
ファイルの表示
@@ -17,9 +17,9 @@ export type ShortcutActionId =
| 'pagination.next' | 'pagination.next'
| 'pagination.previous' | 'pagination.previous'
| 'settings.account' | 'settings.account'
| 'settings.behavior'
| 'settings.theme' | 'settings.theme'
| 'settings.keyboard' | 'settings.keyboard'
| 'settings.behavior'
export type KeyBinding = { export type KeyBinding = {
key: string key: string
@@ -102,20 +102,20 @@ export const SHORTCUT_DEFINITIONS: ShortcutDefinition[] = [
defaultBinding: { key: '1' }, defaultBinding: { key: '1' },
}, },
{ {
id: 'settings.theme', id: 'settings.behavior',
label: '設定: テーマ', label: '設定: 動作',
scope: 'settings', scope: 'settings',
defaultBinding: { key: '2' }, defaultBinding: { key: '2' },
}, },
{ {
id: 'settings.keyboard', id: 'settings.theme',
label: '設定: キーボード', label: '設定: テーマ',
scope: 'settings', scope: 'settings',
defaultBinding: { key: '3' }, defaultBinding: { key: '3' },
}, },
{ {
id: 'settings.behavior', id: 'settings.keyboard',
label: '設定: 動作', label: '設定: キーボード',
scope: 'settings', scope: 'settings',
defaultBinding: { key: '4' }, defaultBinding: { key: '4' },
}, },
@@ -137,9 +137,9 @@ export const SHORTCUT_DEFINITIONS_BY_ID =
'pagination.next': SHORTCUT_DEFINITIONS[6], 'pagination.next': SHORTCUT_DEFINITIONS[6],
'pagination.previous': SHORTCUT_DEFINITIONS[7], 'pagination.previous': SHORTCUT_DEFINITIONS[7],
'settings.account': SHORTCUT_DEFINITIONS[8], 'settings.account': SHORTCUT_DEFINITIONS[8],
'settings.theme': SHORTCUT_DEFINITIONS[9], 'settings.behavior': SHORTCUT_DEFINITIONS[9],
'settings.keyboard': SHORTCUT_DEFINITIONS[10], 'settings.theme': SHORTCUT_DEFINITIONS[10],
'settings.behavior': SHORTCUT_DEFINITIONS[11], 'settings.keyboard': SHORTCUT_DEFINITIONS[11],
}, },
) )
@@ -159,9 +159,9 @@ export const DEFAULT_KEY_BINDINGS =
'pagination.next': null, 'pagination.next': null,
'pagination.previous': null, 'pagination.previous': null,
'settings.account': null, 'settings.account': null,
'settings.behavior': null,
'settings.theme': null, 'settings.theme': null,
'settings.keyboard': null, 'settings.keyboard': null,
'settings.behavior': null,
}, },
) )
+660 -70
ファイルの表示
@@ -1,5 +1,5 @@
import { CATEGORIES } from '@/consts' import { CATEGORIES } from '@/consts'
import { apiGet, apiPatch } from '@/lib/api' import { apiGet, apiPatch, apiPut } from '@/lib/api'
import { DEFAULT_KEY_BINDINGS, normaliseKeyBinding } from '@/lib/keyboardShortcuts' import { DEFAULT_KEY_BINDINGS, normaliseKeyBinding } from '@/lib/keyboardShortcuts'
import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types' import type { Category, FetchPostsOrder, FetchTagsOrder } from '@/types'
@@ -13,6 +13,13 @@ export type UserSettings = {
export type ThemeTagColours = Record<Category, string> export type ThemeTagColours = Record<Category, string>
export type BuiltinThemeId = 'light' | 'dark' export type BuiltinThemeId = 'light' | 'dark'
export type UserThemeSlotNo = 1 | 2 | 3
export type UserThemeSlotSelection = `user:${ BuiltinThemeId }:${ UserThemeSlotNo }`
export type ClientThemeMode = 'system' | 'light' | 'dark'
export type ThemeSlotSelection =
| 'system'
| `builtin:${ BuiltinThemeId }`
| UserThemeSlotSelection
export type ThemeId = BuiltinThemeId | `custom:${ string }` export type ThemeId = BuiltinThemeId | `custom:${ string }`
export type ActiveThemeId = ThemeId | 'system' export type ActiveThemeId = ThemeId | 'system'
export type ResolvedTheme = 'light' | 'dark' export type ResolvedTheme = 'light' | 'dark'
@@ -37,8 +44,33 @@ export type ThemeTokens = {
input: string input: string
ring: string ring: string
link: string link: string
tagColours: ThemeTagColours topNavRootBackgroundMobile: string
} topNavRootBackgroundDesktop: string
topNavActiveBackground: string
topNavSubmenuBackground: string
topNavMobileActiveBackground: string
topNavBrandLink: string
topNavMenuLink: string
tagColours: ThemeTagColours }
export type UserThemeSlot = {
baseTheme: BuiltinThemeId
slotNo: UserThemeSlotNo
tokens: ThemeTokens
createdAt?: string
updatedAt?: string }
export type UserThemeSlotMap =
Partial<Record<UserThemeSlotSelection, UserThemeSlot>>
export type ResolvedThemeSelection = {
selection: ThemeSlotSelection
baseTheme: BuiltinThemeId
builtin: boolean
tokens: ThemeTokens }
export type ResolvedDisplayedTheme = {
themeMode: ClientThemeMode
baseTheme: BuiltinThemeId
selection: UserThemeSlotSelection
slotNo: UserThemeSlotNo
tokens: ThemeTokens }
export type ClientTheme = { export type ClientTheme = {
id: ThemeId id: ThemeId
name: string name: string
@@ -53,6 +85,8 @@ export type TheatreTagFlow = 'vertical' | 'horizontal'
export type GekanatorBackgroundMotionMode = 'on' | 'calm' | 'off' export type GekanatorBackgroundMotionMode = 'on' | 'calm' | 'off'
export type ClientAnimationMode = 'normal' | 'reduced' | 'off' export type ClientAnimationMode = 'normal' | 'reduced' | 'off'
export type ClientEmbedAutoLoadMode = 'auto' | 'manual' | 'off' export type ClientEmbedAutoLoadMode = 'auto' | 'manual' | 'off'
export type ClientLinkPreloadMode = 'off' | 'intent'
export type ClientTagRelationDisplayMode = 'flat' | 'grouped'
export type ClientThumbnailMode = 'normal' | 'light' | 'off' export type ClientThumbnailMode = 'normal' | 'light' | 'off'
export type ClientPaneBreakpoint = 'desktop' | 'tablet' export type ClientPaneBreakpoint = 'desktop' | 'tablet'
export type ClientListKey = 'postList' | 'postSearch' | 'tagList' export type ClientListKey = 'postList' | 'postSearch' | 'tagList'
@@ -68,10 +102,13 @@ type ClientListSettings = {
export type ClientKeyboardSettings = { export type ClientKeyboardSettings = {
enabled?: boolean enabled?: boolean
bindings?: Partial<Record<ShortcutActionId, KeyBinding | null>> bindings?: Partial<Record<ShortcutActionId, KeyBinding | null>> }
}
export type ClientAppearanceSettings = { export type ClientAppearanceSettings = {
themeMode?: ClientThemeMode
activeLightThemeSlotNo?: UserThemeSlotNo
activeDarkThemeSlotNo?: UserThemeSlotNo
activeThemeSlot?: ThemeSlotSelection
activeThemeId?: ActiveThemeId activeThemeId?: ActiveThemeId
customThemes?: Record<string, CustomClientTheme> customThemes?: Record<string, CustomClientTheme>
// Legacy fields kept for migration from the earlier appearance model. // Legacy fields kept for migration from the earlier appearance model.
@@ -80,9 +117,10 @@ export type ClientAppearanceSettings = {
export type ClientBehaviorSettings = { export type ClientBehaviorSettings = {
animation?: ClientAnimationMode animation?: ClientAnimationMode
linkPreload?: ClientLinkPreloadMode
tagRelationDisplay?: ClientTagRelationDisplayMode
embedAutoLoad?: ClientEmbedAutoLoadMode embedAutoLoad?: ClientEmbedAutoLoadMode
thumbnailMode?: ClientThumbnailMode thumbnailMode?: ClientThumbnailMode }
}
// DB-backed settings. At present only `theme` is surfaced in `/users/settings`. // DB-backed settings. At present only `theme` is surfaced in `/users/settings`.
// The remaining fields are retained for existing backend work and are not wired // The remaining fields are retained for existing backend work and are not wired
@@ -102,17 +140,22 @@ export type ClientSettings = {
lists?: Partial<Record<ClientListKey, ClientListSettings>> lists?: Partial<Record<ClientListKey, ClientListSettings>>
theatre?: { theatre?: {
layoutMode?: TheatreLayoutMode layoutMode?: TheatreLayoutMode
tagFlow?: TheatreTagFlow tagFlow?: TheatreTagFlow }
} gekanator?: { backgroundMotion?: GekanatorBackgroundMotionMode }
gekanator?: {
backgroundMotion?: GekanatorBackgroundMotionMode
}
reducedMotion?: string reducedMotion?: string
embedAutoLoad?: string embedAutoLoad?: string
thumbnailMode?: string } thumbnailMode?: string }
export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings' export const CLIENT_SETTINGS_STORAGE_KEY = 'btrc_hub.client_settings'
export const CLIENT_SETTINGS_EVENT = 'btrc-hub:client-settings-change' export const CLIENT_SETTINGS_EVENT = 'btrc-hub:client-settings-change'
export const USER_THEME_SLOT_NOS: UserThemeSlotNo[] = [1, 2, 3]
export const USER_THEME_SLOT_SELECTIONS: UserThemeSlotSelection[] = [
'user:light:1',
'user:light:2',
'user:light:3',
'user:dark:1',
'user:dark:2',
'user:dark:3']
export const THEME_OPTIONS = ['system', 'light', 'dark'] as const export const THEME_OPTIONS = ['system', 'light', 'dark'] as const
export const LIST_LIMIT_OPTIONS = [20, 50, 100] as const export const LIST_LIMIT_OPTIONS = [20, 50, 100] as const
export const POST_LIST_ORDER_OPTIONS = [ export const POST_LIST_ORDER_OPTIONS = [
@@ -125,8 +168,7 @@ export const POST_LIST_ORDER_OPTIONS = [
'created_at:asc', 'created_at:asc',
'created_at:desc', 'created_at:desc',
'updated_at:asc', 'updated_at:asc',
'updated_at:desc', 'updated_at:desc'] as const satisfies readonly FetchPostsOrder[]
] as const satisfies readonly FetchPostsOrder[]
export const TAG_LIST_ORDER_OPTIONS = [ export const TAG_LIST_ORDER_OPTIONS = [
'name:asc', 'name:asc',
'name:desc', 'name:desc',
@@ -137,8 +179,7 @@ export const TAG_LIST_ORDER_OPTIONS = [
'created_at:asc', 'created_at:asc',
'created_at:desc', 'created_at:desc',
'updated_at:asc', 'updated_at:asc',
'updated_at:desc', 'updated_at:desc'] as const satisfies readonly FetchTagsOrder[]
] as const satisfies readonly FetchTagsOrder[]
export const DEFAULT_POST_LIST_LIMIT: ClientListLimit = 20 export const DEFAULT_POST_LIST_LIMIT: ClientListLimit = 20
export const DEFAULT_POST_LIST_ORDER: FetchPostsOrder = 'original_created_at:desc' export const DEFAULT_POST_LIST_ORDER: FetchPostsOrder = 'original_created_at:desc'
export const DEFAULT_TAG_LIST_ORDER: FetchTagsOrder = 'post_count:desc' export const DEFAULT_TAG_LIST_ORDER: FetchTagsOrder = 'post_count:desc'
@@ -149,8 +190,7 @@ export const DEFAULT_LIGHT_THEME_TAG_COLOURS: ThemeTagColours = {
general: '#155e75', general: '#155e75',
material: '#c2410c', material: '#c2410c',
meta: '#a16207', meta: '#a16207',
nico: '#4b5563', nico: '#4b5563' }
}
export const DEFAULT_DARK_THEME_TAG_COLOURS: ThemeTagColours = { export const DEFAULT_DARK_THEME_TAG_COLOURS: ThemeTagColours = {
deerjikist: '#fb7185', deerjikist: '#fb7185',
meme: '#d8b4fe', meme: '#d8b4fe',
@@ -158,8 +198,7 @@ export const DEFAULT_DARK_THEME_TAG_COLOURS: ThemeTagColours = {
general: '#67e8f9', general: '#67e8f9',
material: '#fdba74', material: '#fdba74',
meta: '#fde047', meta: '#fde047',
nico: '#e5e7eb', nico: '#e5e7eb' }
}
export const DEFAULT_THEME_TAG_COLOURS = DEFAULT_LIGHT_THEME_TAG_COLOURS export const DEFAULT_THEME_TAG_COLOURS = DEFAULT_LIGHT_THEME_TAG_COLOURS
export const LIGHT_THEME_TOKENS: ThemeTokens = { export const LIGHT_THEME_TOKENS: ThemeTokens = {
background: '0 0% 100%', background: '0 0% 100%',
@@ -182,8 +221,14 @@ export const LIGHT_THEME_TOKENS: ThemeTokens = {
input: '214.3 31.8% 91.4%', input: '214.3 31.8% 91.4%',
ring: '222.2 84% 4.9%', ring: '222.2 84% 4.9%',
link: '221.2 83.2% 53.3%', link: '221.2 83.2% 53.3%',
tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS, topNavRootBackgroundMobile: '#fef08a',
} topNavRootBackgroundDesktop: '#fefce8',
topNavActiveBackground: '#fef08a',
topNavSubmenuBackground: '#fef08a',
topNavMobileActiveBackground: '#fefce8',
topNavBrandLink: '#db2777',
topNavMenuLink: '#1d4ed8',
tagColours: DEFAULT_LIGHT_THEME_TAG_COLOURS }
export const DARK_THEME_TOKENS: ThemeTokens = { export const DARK_THEME_TOKENS: ThemeTokens = {
background: '222.2 84% 4.9%', background: '222.2 84% 4.9%',
foreground: '210 40% 98%', foreground: '210 40% 98%',
@@ -205,24 +250,27 @@ export const DARK_THEME_TOKENS: ThemeTokens = {
input: '217.2 32.6% 17.5%', input: '217.2 32.6% 17.5%',
ring: '212.7 26.8% 83.9%', ring: '212.7 26.8% 83.9%',
link: '213.1 93.9% 67.8%', link: '213.1 93.9% 67.8%',
tagColours: DEFAULT_DARK_THEME_TAG_COLOURS, topNavRootBackgroundMobile: '#230505',
} topNavRootBackgroundDesktop: '#230505',
topNavActiveBackground: '#450a0a',
topNavSubmenuBackground: '#450a0a',
topNavMobileActiveBackground: '#450a0a',
topNavBrandLink: '#f9a8d4',
topNavMenuLink: '#93c5fd',
tagColours: DEFAULT_DARK_THEME_TAG_COLOURS }
export const BUILTIN_THEMES: Record<BuiltinThemeId, ClientTheme> = { export const BUILTIN_THEMES: Record<BuiltinThemeId, ClientTheme> = {
light: { light: {
id: 'light', id: 'light',
name: 'ライト・モード', name: 'ライト・モード',
builtin: true, builtin: true,
baseTheme: 'light', baseTheme: 'light',
tokens: LIGHT_THEME_TOKENS, tokens: LIGHT_THEME_TOKENS },
},
dark: { dark: {
id: 'dark', id: 'dark',
name: 'ダーク・モード', name: 'ダーク・モード',
builtin: true, builtin: true,
baseTheme: 'dark', baseTheme: 'dark',
tokens: DARK_THEME_TOKENS, tokens: DARK_THEME_TOKENS } }
},
}
const LEGACY_THEATRE_LAYOUT_STORAGE_KEY = 'theatre-layout-mode' const LEGACY_THEATRE_LAYOUT_STORAGE_KEY = 'theatre-layout-mode'
const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow' const LEGACY_THEATRE_TAG_FLOW_STORAGE_KEY = 'theatre-tag-flow'
@@ -321,6 +369,14 @@ const validListLimit = (value: unknown): ClientListLimit | null =>
value === 20 || value === 50 || value === 100 ? value : null value === 20 || value === 50 || value === 100 ? value : null
const normaliseThemeMode = (value: unknown): ClientThemeMode | null =>
value === 'system' || value === 'light' || value === 'dark' ? value : null
const normaliseThemeSlotNo = (value: unknown): UserThemeSlotNo | null =>
value === 1 || value === 2 || value === 3 ? value : null
const normaliseAnimationMode = (value: unknown): ClientAnimationMode | null => { const normaliseAnimationMode = (value: unknown): ClientAnimationMode | null => {
if (value === 'normal' || value === 'reduced' || value === 'off') if (value === 'normal' || value === 'reduced' || value === 'off')
return value return value
@@ -336,6 +392,18 @@ const normaliseEmbedAutoLoadMode = (value: unknown): ClientEmbedAutoLoadMode | n
value === 'auto' || value === 'manual' || value === 'off' ? value : null value === 'auto' || value === 'manual' || value === 'off' ? value : null
const normaliseLinkPreloadMode = (
value: unknown,
): ClientLinkPreloadMode | null =>
value === 'off' || value === 'intent' ? value : null
const normaliseTagRelationDisplayMode = (
value: unknown,
): ClientTagRelationDisplayMode | null =>
value === 'flat' || value === 'grouped' ? value : null
const normaliseThumbnailMode = (value: unknown): ClientThumbnailMode | null => const normaliseThumbnailMode = (value: unknown): ClientThumbnailMode | null =>
value === 'normal' || value === 'light' || value === 'off' ? value : null value === 'normal' || value === 'light' || value === 'off' ? value : null
@@ -418,6 +486,35 @@ export const setClientAnimationMode = (
animation, animation,
}) })
export const getClientLinkPreloadMode = (): ClientLinkPreloadMode =>
normaliseLinkPreloadMode (loadClientSettings ().behavior?.linkPreload)
?? 'intent'
export const setClientLinkPreloadMode = (
linkPreload: ClientLinkPreloadMode,
): ClientBehaviorSettings =>
setClientBehaviorSettings ({
...getClientBehaviorSettings (),
linkPreload,
})
export const getClientTagRelationDisplayMode = (): ClientTagRelationDisplayMode =>
normaliseTagRelationDisplayMode (loadClientSettings ().behavior?.tagRelationDisplay)
?? 'grouped'
export const setClientTagRelationDisplayMode = (
tagRelationDisplay: ClientTagRelationDisplayMode,
): ClientBehaviorSettings =>
setClientBehaviorSettings ({
...getClientBehaviorSettings (),
tagRelationDisplay,
})
export const getClientEmbedAutoLoadMode = (): ClientEmbedAutoLoadMode => export const getClientEmbedAutoLoadMode = (): ClientEmbedAutoLoadMode =>
loadClientSettings ().behavior?.embedAutoLoad loadClientSettings ().behavior?.embedAutoLoad
?? legacyEmbedAutoLoadMode () ?? legacyEmbedAutoLoadMode ()
@@ -450,6 +547,8 @@ export const setClientThumbnailMode = (
export const getClientBehaviorSettings = (): ClientBehaviorSettings => ({ export const getClientBehaviorSettings = (): ClientBehaviorSettings => ({
animation: getClientAnimationMode (), animation: getClientAnimationMode (),
linkPreload: getClientLinkPreloadMode (),
tagRelationDisplay: getClientTagRelationDisplayMode (),
embedAutoLoad: getClientEmbedAutoLoadMode (), embedAutoLoad: getClientEmbedAutoLoadMode (),
thumbnailMode: getClientThumbnailMode (), thumbnailMode: getClientThumbnailMode (),
}) })
@@ -463,6 +562,14 @@ export const setClientBehaviorSettings = (
normaliseAnimationMode (behavior.animation) normaliseAnimationMode (behavior.animation)
?? getClientAnimationMode () ?? getClientAnimationMode ()
), ),
linkPreload: (
normaliseLinkPreloadMode (behavior.linkPreload)
?? getClientLinkPreloadMode ()
),
tagRelationDisplay: (
normaliseTagRelationDisplayMode (behavior.tagRelationDisplay)
?? getClientTagRelationDisplayMode ()
),
embedAutoLoad: ( embedAutoLoad: (
normaliseEmbedAutoLoadMode (behavior.embedAutoLoad) normaliseEmbedAutoLoadMode (behavior.embedAutoLoad)
?? getClientEmbedAutoLoadMode () ?? getClientEmbedAutoLoadMode ()
@@ -575,11 +682,17 @@ const themeCopyName = (baseTheme: BuiltinThemeId): string =>
const appearanceFromSettings = (): ClientAppearanceSettings => const appearanceFromSettings = (): ClientAppearanceSettings =>
loadClientSettings ().appearance ?? { } loadClientSettings ().appearance ?? { }
let cachedUserThemeSlots: UserThemeSlotMap = { }
export const hasStoredClientThemeSelection = (): boolean => { export const hasStoredClientThemeSelection = (): boolean => {
const appearance = appearanceFromSettings () const appearance = appearanceFromSettings ()
return ( return (
appearance.activeThemeId != null appearance.themeMode != null
|| appearance.activeLightThemeSlotNo != null
|| appearance.activeDarkThemeSlotNo != null
|| appearance.activeThemeSlot != null
|| appearance.activeThemeId != null
|| appearance.customThemes != null || appearance.customThemes != null
|| appearance.theme != null || appearance.theme != null
|| appearance.tagColours != null || appearance.tagColours != null
@@ -608,37 +721,119 @@ const mixHexColour = (
} }
export const getClientThemeMode = (): UserSettings['theme'] => const legacyThemeSlotSelection = (): ThemeSlotSelection => {
(() => { const appearance = appearanceFromSettings ()
const appearance = appearanceFromSettings () const customThemes = getClientCustomThemes ()
const customThemes = getClientCustomThemes ()
if (appearance.activeThemeId === 'system') if (appearance.activeThemeId === 'system')
return 'system' return 'system'
if (appearance.activeThemeId === 'light' || appearance.activeThemeId === 'dark') if (appearance.activeThemeId === 'light')
return appearance.activeThemeId return 'builtin:light'
if ( if (appearance.activeThemeId === 'dark')
typeof appearance.activeThemeId === 'string' return 'builtin:dark'
&& customThemes[appearance.activeThemeId] != null if (
) typeof appearance.activeThemeId === 'string'
return customThemes[appearance.activeThemeId].baseTheme && customThemes[appearance.activeThemeId] != null
)
return appearance.theme ?? DEFAULT_USER_SETTINGS.theme return `builtin:${ customThemes[appearance.activeThemeId].baseTheme }`
}) () if (appearance.theme === 'light')
return 'builtin:light'
if (appearance.theme === 'dark')
return 'builtin:dark'
return 'system'
}
export const setClientThemeMode = (theme: UserSettings['theme']): void => { const legacyThemeMode = (): ClientThemeMode =>
normaliseThemeMode (appearanceFromSettings ().theme)
?? (
legacyThemeSlotSelection () === 'system'
? 'system'
: (baseThemeOfSelection (legacyThemeSlotSelection ()) ?? 'system')
)
const legacyThemeSlotNoFor = (
baseTheme: BuiltinThemeId,
): UserThemeSlotNo => {
const legacySelection = legacyThemeSlotSelection ()
if (isUserThemeSlotSelection (legacySelection))
{
const [_, selectionBaseTheme, slotNo] = legacySelection.split (':')
if (selectionBaseTheme === baseTheme)
return Number (slotNo) as UserThemeSlotNo
}
return 1
}
export const getClientActiveThemeSlot = (): ThemeSlotSelection =>
normaliseThemeSlotSelection (appearanceFromSettings ().activeThemeSlot)
?? legacyThemeSlotSelection ()
export const setClientActiveThemeSlot = (
activeThemeSlot: ThemeSlotSelection,
): void => {
updateClientSettings (settings => ({ updateClientSettings (settings => ({
...settings, ...settings,
appearance: { appearance: {
...(settings.appearance ?? { }), ...(settings.appearance ?? { }),
activeThemeId: theme, activeThemeSlot,
theme,
}, },
})) }))
} }
export const getClientThemeMode = (): UserSettings['theme'] => {
return (
normaliseThemeMode (appearanceFromSettings ().themeMode)
?? legacyThemeMode ()
)
}
export const getClientActiveLightThemeSlotNo = (): UserThemeSlotNo =>
normaliseThemeSlotNo (appearanceFromSettings ().activeLightThemeSlotNo)
?? legacyThemeSlotNoFor ('light')
export const getClientActiveDarkThemeSlotNo = (): UserThemeSlotNo =>
normaliseThemeSlotNo (appearanceFromSettings ().activeDarkThemeSlotNo)
?? legacyThemeSlotNoFor ('dark')
export const setClientThemeAppearance = (
{ themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo }: {
themeMode: ClientThemeMode
activeLightThemeSlotNo: UserThemeSlotNo
activeDarkThemeSlotNo: UserThemeSlotNo },
): void => {
updateClientSettings (settings => ({
...settings,
appearance: {
...(settings.appearance ?? { }),
themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo,
},
}))
}
export const setClientThemeMode = (theme: UserSettings['theme']): void => {
setClientThemeAppearance ({
themeMode: theme,
activeLightThemeSlotNo: getClientActiveLightThemeSlotNo (),
activeDarkThemeSlotNo: getClientActiveDarkThemeSlotNo (),
})
}
export const seedClientThemeMode = (theme: UserSettings['theme']): void => { export const seedClientThemeMode = (theme: UserSettings['theme']): void => {
if (hasStoredClientThemeSelection ()) if (hasStoredClientThemeSelection ())
return return
@@ -684,6 +879,226 @@ const normaliseThemeTagColours = (
) )
const normaliseThemeTokenValue = (
value: unknown,
fallback: string,
): string =>
typeof value === 'string' && value !== '' ? value : fallback
const normaliseTopNavColourValue = (
value: unknown,
fallback: string,
): string => {
if (typeof value !== 'string' || value === '')
return fallback
const normalisedHex = normaliseHexColour (value)
if (normalisedHex != null)
return normalisedHex
return /^-?\d+(?:\.\d+)?\s+\d+(?:\.\d+)?%\s+\d+(?:\.\d+)?%$/.test (value)
? `hsl(${ value })`
: fallback
}
const normaliseThemeTokens = (
rawTokens: unknown,
baseTheme: BuiltinThemeId,
): ThemeTokens => {
const fallback = baseTheme === 'dark' ? DARK_THEME_TOKENS : LIGHT_THEME_TOKENS
const tokens =
rawTokens && typeof rawTokens === 'object'
? rawTokens as Partial<ThemeTokens>
: { }
const legacyTopNavTokens =
rawTokens && typeof rawTokens === 'object'
? rawTokens as Record<string, unknown>
: { }
return {
background: normaliseThemeTokenValue (tokens.background, fallback.background),
foreground: normaliseThemeTokenValue (tokens.foreground, fallback.foreground),
card: normaliseThemeTokenValue (tokens.card, fallback.card),
cardForeground: normaliseThemeTokenValue (
tokens.cardForeground,
fallback.cardForeground,
),
popover: normaliseThemeTokenValue (tokens.popover, fallback.popover),
popoverForeground: normaliseThemeTokenValue (
tokens.popoverForeground,
fallback.popoverForeground,
),
primary: normaliseThemeTokenValue (tokens.primary, fallback.primary),
primaryForeground: normaliseThemeTokenValue (
tokens.primaryForeground,
fallback.primaryForeground,
),
secondary: normaliseThemeTokenValue (tokens.secondary, fallback.secondary),
secondaryForeground: normaliseThemeTokenValue (
tokens.secondaryForeground,
fallback.secondaryForeground,
),
muted: normaliseThemeTokenValue (tokens.muted, fallback.muted),
mutedForeground: normaliseThemeTokenValue (
tokens.mutedForeground,
fallback.mutedForeground,
),
accent: normaliseThemeTokenValue (tokens.accent, fallback.accent),
accentForeground: normaliseThemeTokenValue (
tokens.accentForeground,
fallback.accentForeground,
),
destructive: normaliseThemeTokenValue (
tokens.destructive,
fallback.destructive,
),
destructiveForeground: normaliseThemeTokenValue (
tokens.destructiveForeground,
fallback.destructiveForeground,
),
border: normaliseThemeTokenValue (tokens.border, fallback.border),
input: normaliseThemeTokenValue (tokens.input, fallback.input),
ring: normaliseThemeTokenValue (tokens.ring, fallback.ring),
link: normaliseThemeTokenValue (tokens.link, fallback.link),
topNavRootBackgroundMobile: normaliseTopNavColourValue (
tokens.topNavRootBackgroundMobile ?? legacyTopNavTokens.topNavActiveBackground,
fallback.topNavRootBackgroundMobile,
),
topNavRootBackgroundDesktop: normaliseTopNavColourValue (
tokens.topNavRootBackgroundDesktop ?? legacyTopNavTokens.topNavBackground,
fallback.topNavRootBackgroundDesktop,
),
topNavActiveBackground: normaliseTopNavColourValue (
tokens.topNavActiveBackground
?? legacyTopNavTokens.topNavHighlightBackground
?? legacyTopNavTokens.topNavActiveBackground,
fallback.topNavActiveBackground,
),
topNavSubmenuBackground: normaliseTopNavColourValue (
tokens.topNavSubmenuBackground,
fallback.topNavSubmenuBackground,
),
topNavMobileActiveBackground: normaliseTopNavColourValue (
tokens.topNavMobileActiveBackground ?? legacyTopNavTokens.topNavBackground,
fallback.topNavMobileActiveBackground,
),
topNavBrandLink: normaliseTopNavColourValue (
tokens.topNavBrandLink
?? legacyTopNavTokens.topNavLink,
fallback.topNavBrandLink,
),
topNavMenuLink: normaliseTopNavColourValue (
tokens.topNavMenuLink
?? tokens.link
?? fallback.topNavMenuLink,
fallback.topNavMenuLink,
),
tagColours: normaliseThemeTagColours (tokens.tagColours, fallback.tagColours),
}
}
export const getUserThemeSlotSelection = (
baseTheme: BuiltinThemeId,
slotNo: UserThemeSlotNo,
): UserThemeSlotSelection => `user:${ baseTheme }:${ slotNo }`
const isBuiltinThemeSlotSelection = (
value: string,
): value is `builtin:${ BuiltinThemeId }` =>
value === 'builtin:light' || value === 'builtin:dark'
export const isUserThemeSlotSelection = (
value: unknown,
): value is UserThemeSlotSelection =>
typeof value === 'string'
&& USER_THEME_SLOT_SELECTIONS.includes (value as UserThemeSlotSelection)
const normaliseThemeSlotSelection = (
value: unknown,
): ThemeSlotSelection | null => {
if (value === 'system')
return 'system'
if (typeof value === 'string' && isBuiltinThemeSlotSelection (value))
return value
if (isUserThemeSlotSelection (value))
return value
return null
}
const baseThemeOfSelection = (
selection: ThemeSlotSelection,
): BuiltinThemeId | null => {
if (selection === 'system')
return null
if (isBuiltinThemeSlotSelection (selection))
return selection.slice ('builtin:'.length) as BuiltinThemeId
return selection.split (':')[1] as BuiltinThemeId
}
const slotNoOfSelection = (
selection: UserThemeSlotSelection,
): UserThemeSlotNo => Number (selection.split (':')[2]) as UserThemeSlotNo
export const getDefaultUserThemeSlot = (
baseTheme: BuiltinThemeId,
slotNo: UserThemeSlotNo,
): UserThemeSlot => ({
baseTheme,
slotNo,
tokens: normaliseThemeTokens ({ }, baseTheme),
})
const normaliseUserThemeSlot = (
rawSlot: unknown,
): UserThemeSlot | null => {
if (!(rawSlot) || typeof rawSlot !== 'object')
return null
const slot = rawSlot as Partial<UserThemeSlot>
const baseTheme = slot.baseTheme
const slotNo = slot.slotNo
if (
baseTheme == null
|| !(isBuiltinThemeId (baseTheme))
|| slotNo == null
|| !(USER_THEME_SLOT_NOS.includes (slotNo as UserThemeSlotNo))
)
return null
return {
baseTheme,
slotNo: slotNo as UserThemeSlotNo,
tokens: normaliseThemeTokens (slot.tokens, baseTheme),
createdAt: typeof slot.createdAt === 'string' ? slot.createdAt : undefined,
updatedAt: typeof slot.updatedAt === 'string' ? slot.updatedAt : undefined,
}
}
export const normaliseUserThemeSlots = (
slots: UserThemeSlot[],
): UserThemeSlotMap =>
slots.reduce<UserThemeSlotMap> (
(map, slot) => {
map[getUserThemeSlotSelection (slot.baseTheme, slot.slotNo)] = slot
return map
},
{ },
)
const normaliseCustomTheme = ( const normaliseCustomTheme = (
rawTheme: unknown, rawTheme: unknown,
): CustomClientTheme | null => { ): CustomClientTheme | null => {
@@ -708,26 +1123,21 @@ const normaliseCustomTheme = (
name, name,
builtin: false, builtin: false,
baseTheme, baseTheme,
tokens: { tokens: normaliseThemeTokens (
...( {
baseTheme === 'dark' ...(theme.tokens && typeof theme.tokens === 'object'
? DARK_THEME_TOKENS ? theme.tokens as Partial<ThemeTokens>
: LIGHT_THEME_TOKENS : { }),
), tagColours: (
...(theme.tokens && typeof theme.tokens === 'object'
? theme.tokens as Partial<ThemeTokens>
: { }),
tagColours: normaliseThemeTagColours (
(
theme.tokens theme.tokens
&& typeof theme.tokens === 'object' && typeof theme.tokens === 'object'
&& 'tagColours' in theme.tokens && 'tagColours' in theme.tokens
) )
? (theme.tokens as { tagColours?: Partial<ThemeTagColours> }).tagColours ? (theme.tokens as { tagColours?: Partial<ThemeTagColours> }).tagColours
: theme.tagColours as Partial<ThemeTagColours> | undefined, : theme.tagColours as Partial<ThemeTagColours> | undefined,
defaultThemeTagColoursFor (baseTheme), },
), baseTheme,
}, ),
} }
} }
@@ -772,6 +1182,87 @@ export const getClientCustomThemes = (): Record<string, CustomClientTheme> => {
} }
export const fetchUserThemeSlots = async (): Promise<UserThemeSlot[]> => {
const raw = await apiGet<Array<{
baseTheme: BuiltinThemeId
slotNo: UserThemeSlotNo
tokens: ThemeTokens
createdAt: string
updatedAt: string
}>> ('/users/theme_slots')
return raw
.map (slot => normaliseUserThemeSlot (slot))
.filter ((slot): slot is UserThemeSlot => slot != null)
}
export const upsertUserThemeSlot = async (
baseTheme: BuiltinThemeId,
slotNo: UserThemeSlotNo,
tokens: ThemeTokens,
): Promise<UserThemeSlot> => {
const raw = await apiPut<{
baseTheme: BuiltinThemeId
slotNo: UserThemeSlotNo
tokens: ThemeTokens
createdAt: string
updatedAt: string
}> (`/users/theme_slots/${ baseTheme }/${ slotNo }`, { tokens })
const slot = normaliseUserThemeSlot (raw)
if (slot == null)
throw new Error ('theme slot response is invalid')
return slot
}
export const setCachedUserThemeSlots = (themeSlots: UserThemeSlotMap): void => {
cachedUserThemeSlots = themeSlots
}
export const getCachedUserThemeSlots = (): UserThemeSlotMap =>
cachedUserThemeSlots
export const resolveDisplayedTheme = (
{ themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo }: {
themeMode: ClientThemeMode
activeLightThemeSlotNo: UserThemeSlotNo
activeDarkThemeSlotNo: UserThemeSlotNo },
themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
): ResolvedDisplayedTheme => {
const baseTheme = resolveThemeMode (themeMode)
const slotNo =
baseTheme === 'dark'
? activeDarkThemeSlotNo
: activeLightThemeSlotNo
const selection = getUserThemeSlotSelection (baseTheme, slotNo)
return {
themeMode,
baseTheme,
selection,
slotNo,
tokens: themeSlots[selection]?.tokens
?? getDefaultUserThemeSlot (baseTheme, slotNo).tokens,
}
}
export const normaliseThemeSlotSelectionAgainstSlots = (
selection: ThemeSlotSelection,
themeSlots: UserThemeSlotMap,
): ThemeSlotSelection =>
isUserThemeSlotSelection (selection) && themeSlots[selection] == null
? 'system'
: selection
export const getClientActiveThemeId = (): ActiveThemeId => { export const getClientActiveThemeId = (): ActiveThemeId => {
const appearance = appearanceFromSettings () const appearance = appearanceFromSettings ()
const customThemes = getClientCustomThemes () const customThemes = getClientCustomThemes ()
@@ -833,6 +1324,51 @@ export const deriveUserThemeModeFromSelection = (
} }
export const resolveThemeFromSlotSelection = (
selection: ThemeSlotSelection,
themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
): ResolvedThemeSelection => {
if (selection === 'system')
{
const baseTheme = resolveThemeMode ('system')
return {
selection,
baseTheme,
builtin: true,
tokens: BUILTIN_THEMES[baseTheme].tokens,
}
}
if (isBuiltinThemeSlotSelection (selection))
{
const baseTheme = selection.slice ('builtin:'.length) as BuiltinThemeId
return {
selection,
baseTheme,
builtin: true,
tokens: BUILTIN_THEMES[baseTheme].tokens,
}
}
const baseTheme = baseThemeOfSelection (selection) ?? 'light'
return {
selection,
baseTheme,
builtin: false,
tokens: themeSlots[selection]?.tokens
?? getDefaultUserThemeSlot (baseTheme, slotNoOfSelection (selection)).tokens,
}
}
export const deriveUserThemeModeFromSlotSelection = (
selection: ThemeSlotSelection,
): UserSettings['theme'] =>
selection === 'system'
? 'system'
: (baseThemeOfSelection (selection) ?? DEFAULT_USER_SETTINGS.theme)
export const createCustomThemeFromBase = ( export const createCustomThemeFromBase = (
baseTheme: BuiltinThemeId, baseTheme: BuiltinThemeId,
tokens?: ThemeTokens, tokens?: ThemeTokens,
@@ -947,23 +1483,77 @@ export const applyThemeTokens = (tokens: ThemeTokens): void => {
root.style.setProperty ('--input', tokens.input) root.style.setProperty ('--input', tokens.input)
root.style.setProperty ('--ring', tokens.ring) root.style.setProperty ('--ring', tokens.ring)
root.style.setProperty ('--theme-link', tokens.link) root.style.setProperty ('--theme-link', tokens.link)
root.style.setProperty (
'--top-nav-root-bg-mobile',
tokens.topNavRootBackgroundMobile,
)
root.style.setProperty (
'--top-nav-root-bg-desktop',
tokens.topNavRootBackgroundDesktop,
)
root.style.setProperty (
'--top-nav-active-bg',
tokens.topNavActiveBackground,
)
root.style.setProperty (
'--top-nav-submenu-bg',
tokens.topNavSubmenuBackground,
)
root.style.setProperty (
'--top-nav-mobile-active-bg',
tokens.topNavMobileActiveBackground,
)
root.style.setProperty (
'--top-nav-brand-link',
tokens.topNavBrandLink,
)
root.style.setProperty (
'--top-nav-menu-link',
tokens.topNavMenuLink,
)
applyThemeTagColours (tokens.tagColours) applyThemeTagColours (tokens.tagColours)
} }
export const applyThemeSelection = ( export const applyThemeSelection = (
activeThemeId: ActiveThemeId, activeThemeSlot: ThemeSlotSelection,
customThemes: Record<string, CustomClientTheme>, themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
): void => { ): void => {
const theme = getResolvedThemeFromSelection (activeThemeId, customThemes) const theme = resolveThemeFromSlotSelection (activeThemeSlot, themeSlots)
applyThemeMode (theme.baseTheme) applyThemeMode (theme.baseTheme)
applyThemeTokens (theme.tokens) applyThemeTokens (theme.tokens)
} }
export const applyClientAppearance = (): void => { export const applyThemeAppearanceState = (
applyThemeSelection (getClientActiveThemeId (), getClientCustomThemes ()) { themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo }: {
themeMode: ClientThemeMode
activeLightThemeSlotNo: UserThemeSlotNo
activeDarkThemeSlotNo: UserThemeSlotNo },
themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
): void => {
const theme = resolveDisplayedTheme ({
themeMode,
activeLightThemeSlotNo,
activeDarkThemeSlotNo,
}, themeSlots)
applyThemeMode (theme.baseTheme)
applyThemeTokens (theme.tokens)
}
export const applyClientAppearance = (
themeSlots: UserThemeSlotMap = getCachedUserThemeSlots (),
): void => {
applyThemeAppearanceState ({
themeMode: getClientThemeMode (),
activeLightThemeSlotNo: getClientActiveLightThemeSlotNo (),
activeDarkThemeSlotNo: getClientActiveDarkThemeSlotNo (),
}, themeSlots)
} }
+1 -1
ファイルの表示
@@ -219,7 +219,7 @@ export const KeyboardShortcutsProvider = ({ children }: PropsWithChildren) => {
return return
const actionId = candidates[0].id const actionId = candidates[0].id
const handler = builtinHandlers[actionId] ?? mergedRegisteredHandlers[actionId] const handler = mergedRegisteredHandlers[actionId] ?? builtinHandlers[actionId]
if (handler == null) if (handler == null)
return return
ファイル差分が大きすぎるため省略します 差分を読込み