コミットを比較
4 コミット
b0cce4b7ee
...
f00e97e335
| 作成者 | SHA1 | 日付 | |
|---|---|---|---|
| f00e97e335 | |||
| a0c1b8ba31 | |||
| 021d13a262 | |||
| 1b6cac43c2 |
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>))}
|
||||||
|
|||||||
@@ -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')}>
|
||||||
その他 »
|
その他 »
|
||||||
</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'))}>
|
||||||
その他 »
|
その他 »
|
||||||
</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'))}>
|
||||||
その他 »
|
その他 »
|
||||||
</PrefetchLink>
|
</PrefetchLink>
|
||||||
<TopNavUser user={user} sp/>
|
<TopNavUser user={user} sp/>
|
||||||
|
|||||||
@@ -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="外部埋め込みを自動で読み込むかを切り替えます。">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
ファイル差分が大きすぎるため省略します
差分を読込み
新しい課題から参照
ユーザをブロックする