From 1b6cac43c2910299706f20616014a8137fb4ed4e Mon Sep 17 00:00:00 2001 From: miteruzo Date: Sun, 5 Jul 2026 18:21:39 +0900 Subject: [PATCH] #34 --- .../user_theme_slots_controller.rb | 38 + backend/app/models/user.rb | 1 + backend/app/models/user_theme_slot.rb | 27 + backend/config/routes.rb | 2 + .../20260705000000_create_user_theme_slots.rb | 22 + backend/db/schema.rb | 65 +- frontend/src/App.tsx | 27 +- frontend/src/components/PrefetchLink.tsx | 7 + frontend/src/components/TagDetailSidebar.tsx | 56 +- frontend/src/components/TopNav.tsx | 37 +- .../users/BehaviourSettingsSection.tsx | 32 +- frontend/src/index.css | 55 + frontend/src/lib/settings.ts | 539 ++++++++- frontend/src/pages/users/SettingPage.tsx | 1029 ++++++++++++----- 14 files changed, 1540 insertions(+), 397 deletions(-) create mode 100644 backend/app/controllers/user_theme_slots_controller.rb create mode 100644 backend/app/models/user_theme_slot.rb create mode 100644 backend/db/migrate/20260705000000_create_user_theme_slots.rb diff --git a/backend/app/controllers/user_theme_slots_controller.rb b/backend/app/controllers/user_theme_slots_controller.rb new file mode 100644 index 0000000..e85a61c --- /dev/null +++ b/backend/app/controllers/user_theme_slots_controller.rb @@ -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 diff --git a/backend/app/models/user.rb b/backend/app/models/user.rb index 33de49b..59b076c 100644 --- a/backend/app/models/user.rb +++ b/backend/app/models/user.rb @@ -8,6 +8,7 @@ class User < ApplicationRecord has_many :created_posts, class_name: 'Post', foreign_key: :uploaded_user_id, dependent: :nullify has_one :setting, dependent: :destroy + has_many :theme_slots, class_name: 'UserThemeSlot', dependent: :destroy has_many :user_ips, dependent: :destroy has_many :ip_addresses, through: :user_ips has_many :user_post_views, dependent: :destroy diff --git a/backend/app/models/user_theme_slot.rb b/backend/app/models/user_theme_slot.rb new file mode 100644 index 0000000..f7919a0 --- /dev/null +++ b/backend/app/models/user_theme_slot.rb @@ -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 diff --git a/backend/config/routes.rb b/backend/config/routes.rb index 09619e1..223332a 100644 --- a/backend/config/routes.rb +++ b/backend/config/routes.rb @@ -83,6 +83,8 @@ Rails.application.routes.draw do get 'users/settings', to: 'user_settings#show' 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 collection do diff --git a/backend/db/migrate/20260705000000_create_user_theme_slots.rb b/backend/db/migrate/20260705000000_create_user_theme_slots.rb new file mode 100644 index 0000000..5df3d08 --- /dev/null +++ b/backend/db/migrate/20260705000000_create_user_theme_slots.rb @@ -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 diff --git a/backend/db/schema.rb b/backend/db/schema.rb index 311935c..04ab55e 100644 --- a/backend/db/schema.rb +++ b/backend/db/schema.rb @@ -10,7 +10,7 @@ # # 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| t.string "name", 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 ["guessed_post_id"], name: "index_gekanator_games_on_guessed_post_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 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.bigint "created_by_user_id" 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 ["tag_id", "created_at"], name: "index_nico_tag_versions_on_tag_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" 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 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 "updated_at", null: false 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 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 "target_post_id", 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" 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 ["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 "`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 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 ["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 "`version_no` > 0", name: "chk_posts_version_no_positive" end 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 "updated_at", 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_thumbnail", default: "manual", 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 "target_tag_id", 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" end @@ -428,32 +428,30 @@ ActiveRecord::Schema[8.0].define(version: 2026_07_04_000000) do t.string "event_type", null: false t.string "name", null: false t.string "category", null: false - t.datetime "deprecated_at" t.text "aliases", null: false t.text "parent_tag_ids", null: false + t.datetime "deprecated_at" t.datetime "created_at", null: false t.bigint "created_by_user_id" 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 ["tag_id", "created_at"], name: "index_tag_versions_on_tag_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" 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 create_table "tags", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| t.bigint "tag_name_id", null: false t.string "category", default: "general", null: false + t.datetime "deprecated_at" t.datetime "created_at", null: false t.datetime "updated_at", null: false t.integer "post_count", default: 0, null: false - t.datetime "deprecated_at" t.datetime "discarded_at" t.integer "version_no", null: false t.index ["deprecated_at"], name: "index_tags_on_deprecated_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.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 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" 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| t.string "name" 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" 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| t.string "sha256", limit: 64, 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 "updated_at", null: false t.datetime "discarded_at" - t.integer "next_asset_no", default: 1, null: false t.integer "version_no", null: false 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 ["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.check_constraint "`version_no` > 0", name: "chk_wiki_pages_version_no_positive" end 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 ["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.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 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_post_views", "posts" add_foreign_key "user_post_views", "users" - add_foreign_key "wiki_assets", "users", column: "created_by_user_id" - add_foreign_key "wiki_assets", "wiki_pages" + add_foreign_key "user_theme_slots", "users" add_foreign_key "wiki_pages", "tag_names" add_foreign_key "wiki_pages", "users", column: "created_user_id" add_foreign_key "wiki_pages", "users", column: "updated_user_id" diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2847686..a4cba2f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,8 +13,15 @@ import DialogueProvider from '@/components/dialogues/DialogueProvider' import { Toaster } from '@/components/ui/toaster' import { apiPost, isApiError } from '@/lib/api' import { applyClientAppearance, + fetchUserThemeSlots, fetchUserSettings, getClientThemeMode, + getClientActiveThemeSlot, + hasStoredClientThemeSelection, + normaliseThemeSlotSelectionAgainstSlots, + normaliseUserThemeSlots, + setCachedUserThemeSlots, + setClientActiveThemeSlot, seedClientThemeMode } from '@/lib/settings' import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' @@ -177,8 +184,26 @@ const App: FC = () => { void (async () => { try { - const settings = await fetchUserSettings () + const [settings, themeSlots] = await Promise.all ([ + fetchUserSettings (), + fetchUserThemeSlots (), + ]) + const storedThemeSlots = normaliseUserThemeSlots (themeSlots) + const initialSelection = ( + hasStoredClientThemeSelection () + ? getClientActiveThemeSlot () + : (settings.theme === 'system' + ? 'system' + : `builtin:${ settings.theme }`) + ) + const nextSelection = normaliseThemeSlotSelectionAgainstSlots ( + initialSelection, + storedThemeSlots, + ) + setCachedUserThemeSlots (storedThemeSlots) seedClientThemeMode (settings.theme) + if (nextSelection !== initialSelection) + setClientActiveThemeSlot (nextSelection) applyClientAppearance () } catch diff --git a/frontend/src/components/PrefetchLink.tsx b/frontend/src/components/PrefetchLink.tsx index 18fc855..e9b210e 100644 --- a/frontend/src/components/PrefetchLink.tsx +++ b/frontend/src/components/PrefetchLink.tsx @@ -5,6 +5,7 @@ import { createPath, useNavigate } from 'react-router-dom' import { useOverlayStore } from '@/components/RouteBlockerOverlay' import { prefetchForURL } from '@/lib/prefetchers' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { cn } from '@/lib/utils' import type { AnchorHTMLAttributes, MouseEvent, TouchEvent } from 'react' @@ -33,6 +34,8 @@ export default forwardRef (({ const navigate = useNavigate () const qc = useQueryClient () + const behaviourSettings = useClientBehaviourSettings () + const linkPreloadMode = behaviourSettings.linkPreload ?? 'intent' const url = useMemo (() => { const path = (typeof to === 'string') ? to : createPath (to) return (new URL (path, location.origin)).toString () @@ -54,11 +57,15 @@ export default forwardRef (({ const handleMouseEnter = async (ev: MouseEvent) => { onMouseEnter?.(ev) + if (ev.defaultPrevented || linkPreloadMode !== 'intent') + return await doPrefetch () } const handleTouchStart = async (ev: TouchEvent) => { onTouchStart?.(ev) + if (ev.defaultPrevented || linkPreloadMode !== 'intent') + return await doPrefetch () } diff --git a/frontend/src/components/TagDetailSidebar.tsx b/frontend/src/components/TagDetailSidebar.tsx index 10b7eb6..47d1daa 100644 --- a/frontend/src/components/TagDetailSidebar.tsx +++ b/frontend/src/components/TagDetailSidebar.tsx @@ -21,6 +21,7 @@ import { toast } from '@/components/ui/use-toast' import { CATEGORIES, CATEGORY_NAMES } from '@/consts' import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api' import { postsKeys, tagsKeys } from '@/lib/queryKeys' +import { useClientBehaviourSettings } from '@/lib/useClientBehaviourSettings' import { dateString, originalCreatedAtString } from '@/lib/utils' 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' type TagByCategory = { [key in Category]: TagWithSections[] } +type FlatTagRow = { + tag: TagWithSections + parentTagId?: number } const renderTagTree = ( @@ -61,6 +65,33 @@ const renderTagTree = ( } +const flattenTagTree = ( + tags: TagWithSections[], +): FlatTagRow[] => { + const seen = new Set () + 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 = ( root: TagWithSections, targetId: number, @@ -156,6 +187,8 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => { sp = Boolean (sp) const qc = useQueryClient () + const behaviourSettings = useClientBehaviourSettings () + const tagRelationDisplay = behaviourSettings.tagRelationDisplay ?? 'grouped' const baseTags = useMemo (() => { const tagsTmp = { } as TagByCategory @@ -321,8 +354,27 @@ const TagDetailSidebar: FC = ({ className, post, sp }) => {
    - {(tags[cat] ?? []).flatMap (tag => ( - renderTagTree (tag, 0, `cat-${ cat }`, suppressClickRef, undefined, sp)))} + {(tagRelationDisplay === 'grouped' + ? (tags[cat] ?? []).flatMap (tag => + renderTagTree ( + tag, + 0, + `cat-${ cat }`, + suppressClickRef, + undefined, + sp, + ), + ) + : flattenTagTree (tags[cat] ?? []).map (row => ( +
  • + +
  • )))}
))} diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index 334e81d..0f611d6 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -209,12 +209,11 @@ const TopNav: FC = ({ user }) => { return ( <>