| @@ -173,8 +173,8 @@ class PostsController < ApplicationController | |||||
| return head :unauthorized unless current_user | return head :unauthorized unless current_user | ||||
| return head :forbidden unless current_user.gte_member? | return head :forbidden unless current_user.gte_member? | ||||
| force = truthy_param?(params[:force]) | |||||
| merge = truthy_param?(params[:merge]) | |||||
| force = bool?(:force) | |||||
| merge = bool?(:merge) | |||||
| return head :bad_request if force && merge | return head :bad_request if force && merge | ||||
| base_version_no = nil | base_version_no = nil | ||||
| @@ -201,15 +201,14 @@ class PostsController < ApplicationController | |||||
| base_snapshot = post_snapshot_from_version(base_version) | base_snapshot = post_snapshot_from_version(base_version) | ||||
| current_snapshot = post_snapshot_from_record(post) | current_snapshot = post_snapshot_from_record(post) | ||||
| end | end | ||||
| incoming_snapshot = post_incoming_snapshot(post, | |||||
| title:, | |||||
| incoming_snapshot = post_incoming_snapshot(title:, | |||||
| original_created_from:, | original_created_from:, | ||||
| original_created_before:, | original_created_before:, | ||||
| tag_names:, | tag_names:, | ||||
| parent_post_ids:) | parent_post_ids:) | ||||
| snapshot_to_apply = | snapshot_to_apply = | ||||
| if post.version_no == base_version_no || force | |||||
| if force || post.version_no == base_version_no || current_snapshot == base_snapshot | |||||
| incoming_snapshot | incoming_snapshot | ||||
| else | else | ||||
| changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot) | changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot) | ||||
| @@ -448,30 +447,36 @@ class PostsController < ApplicationController | |||||
| version_no | version_no | ||||
| end | end | ||||
| def truthy_param?(value) = ActiveModel::Type::Boolean.new.cast(value) | |||||
| def post_snapshot_from_version version | def post_snapshot_from_version version | ||||
| { title: version.title, | { title: version.title, | ||||
| original_created_from: snapshot_time(version.original_created_from), | original_created_from: snapshot_time(version.original_created_from), | ||||
| original_created_before: snapshot_time(version.original_created_before), | original_created_before: snapshot_time(version.original_created_before), | ||||
| tag_names: version.tags.to_s.split.filter { !(_1.start_with?('nico:')) }.sort, | |||||
| tag_names: editable_tag_names_from_version(version), | |||||
| parent_post_ids: snapshot_parent_post_ids_from_version(version) } | parent_post_ids: snapshot_parent_post_ids_from_version(version) } | ||||
| end | end | ||||
| def editable_tag_names_from_version version | |||||
| version.tags.to_s.split.reject { |name| name.downcase.start_with?('nico:') }.sort | |||||
| end | |||||
| def post_snapshot_from_record post | def post_snapshot_from_record post | ||||
| { title: post.title, | { title: post.title, | ||||
| original_created_from: snapshot_time(post.original_created_from), | original_created_from: snapshot_time(post.original_created_from), | ||||
| original_created_before: snapshot_time(post.original_created_before), | original_created_before: snapshot_time(post.original_created_before), | ||||
| tag_names: post.tags.joins(:tag_name).order('tag_names.name').pluck('tag_names.name'), | |||||
| tag_names: editable_tag_names_from_post(post), | |||||
| parent_post_ids: post.parent_posts.order(:id).pluck(:id) } | parent_post_ids: post.parent_posts.order(:id).pluck(:id) } | ||||
| end | end | ||||
| def post_incoming_snapshot post, title:, original_created_from:, original_created_before:, | |||||
| tag_names:, parent_post_ids: | |||||
| def editable_tag_names_from_post post | |||||
| post.tags.not_nico.joins(:tag_name).order('tag_names.name').pluck('tag_names.name') | |||||
| end | |||||
| def post_incoming_snapshot title:, original_created_from:, original_created_before:, | |||||
| tag_names:, parent_post_ids: | |||||
| { title:, | { title:, | ||||
| original_created_from: snapshot_time(original_created_from), | original_created_from: snapshot_time(original_created_from), | ||||
| original_created_before: snapshot_time(original_created_before), | original_created_before: snapshot_time(original_created_before), | ||||
| tag_names: incoming_tag_names_for_snapshot(post, tag_names), | |||||
| tag_names: incoming_tag_names_for_snapshot(tag_names), | |||||
| parent_post_ids: parent_post_ids.sort } | parent_post_ids: parent_post_ids.sort } | ||||
| end | end | ||||
| @@ -494,44 +499,10 @@ class PostsController < ApplicationController | |||||
| value.to_s | value.to_s | ||||
| end | end | ||||
| def incoming_tag_names_for_snapshot post, raw_tag_names | |||||
| manual_names = normalised_manual_tag_names_for_snapshot(raw_tag_names) | |||||
| existing_tags = | |||||
| Tag | |||||
| .joins(:tag_name) | |||||
| .where(tag_names: { name: manual_names }) | |||||
| .to_a | |||||
| def incoming_tag_names_for_snapshot raw_tag_names | |||||
| tags = Tag.normalise_tags!(raw_tag_names, with_tagme: false) | |||||
| expanded_names = Tag.expand_parent_tags(existing_tags).map(&:name) | |||||
| (manual_names + expanded_names).uniq.sort | |||||
| end | |||||
| def normalised_manual_tag_names_for_snapshot raw_tag_names | |||||
| if raw_tag_names.any? { |name| name.downcase.start_with?('nico:') } | |||||
| raise Tag::NicoTagNormalisationError | |||||
| end | |||||
| pairs = raw_tag_names.map do |raw_name| | |||||
| prefix, category = | |||||
| Tag::CATEGORY_PREFIXES.find { |p, _| raw_name.downcase.start_with?(p) } || ['', nil] | |||||
| name = TagName.canonicalise(raw_name.sub(/\A#{ Regexp.escape(prefix) }/i, '')).first | |||||
| [name, category] | |||||
| end | |||||
| names = pairs.map(&:first) | |||||
| has_deerjikist = pairs.any? do |name, category| | |||||
| category == :deerjikist || | |||||
| Tag.joins(:tag_name).where(category: :deerjikist, tag_names: { name: }).exists? | |||||
| end | |||||
| names << Tag.no_deerjikist.name unless has_deerjikist | |||||
| names.uniq.sort | |||||
| Tag.expand_parent_tags(tags).map(&:name).uniq.sort | |||||
| end | end | ||||
| def post_conflict_json post:, base_version_no:, base_snapshot:, | def post_conflict_json post:, base_version_no:, base_snapshot:, | ||||
| @@ -618,29 +589,53 @@ class PostsController < ApplicationController | |||||
| original_created_from: snapshot[:original_created_from], | original_created_from: snapshot[:original_created_from], | ||||
| original_created_before: snapshot[:original_created_before]) | original_created_before: snapshot[:original_created_before]) | ||||
| tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false) | |||||
| TagVersioning.record_tag_snapshots!(tags, created_by_user: current_user) | |||||
| editable_tags = Tag.normalise_tags!(snapshot[:tag_names], with_tagme: false) | |||||
| TagVersioning.record_tag_snapshots!(editable_tags, created_by_user: current_user) | |||||
| readonly_tags = post.tags.nico.to_a | |||||
| tags = readonly_tags + editable_tags | |||||
| tags = Tag.expand_parent_tags(tags) | tags = Tag.expand_parent_tags(tags) | ||||
| sync_post_tags!(post, tags) | |||||
| sync_post_tags!(post, tags) | |||||
| sync_parent_posts!(post, snapshot[:parent_post_ids]) | sync_parent_posts!(post, snapshot[:parent_post_ids]) | ||||
| PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) | PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user) | ||||
| end | end | ||||
| def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot | def merge_post_snapshots base_snapshot, current_snapshot, incoming_snapshot | ||||
| [:title, :original_created_from, :original_created_before, :tag_names, :parent_post_ids].map { | |||||
| [_1, merge_scaler_snapshot_value(base_snapshot[_1], | |||||
| [:title, :original_created_from, :original_created_before].map { | |||||
| [_1, merge_scalar_snapshot_value(base_snapshot[_1], | |||||
| current_snapshot[_1], | current_snapshot[_1], | ||||
| incoming_snapshot[_1])] | incoming_snapshot[_1])] | ||||
| }.to_h | |||||
| }.to_h.merge([:tag_names, :parent_post_ids].map { | |||||
| [_1, merge_set_snapshot_value(base_snapshot[_1], | |||||
| current_snapshot[_1], | |||||
| incoming_snapshot[_1])] | |||||
| }.to_h) | |||||
| end | end | ||||
| def merge_scaler_snapshot_value base, current, mine | |||||
| def merge_scalar_snapshot_value base, current, mine | |||||
| return mine if current == base | return mine if current == base | ||||
| return current if mine == base || current == mine | return current if mine == base || current == mine | ||||
| raise ArgumentError, '競合してゐる項目はマージできません.' | raise ArgumentError, '競合してゐる項目はマージできません.' | ||||
| end | end | ||||
| def merge_set_snapshot_value base, current, mine | |||||
| base = base.to_a | |||||
| current = current.to_a | |||||
| mine = mine.to_a | |||||
| added_by_current = current - base | |||||
| removed_by_current = base - current | |||||
| added_by_me = mine - base | |||||
| removed_by_me = base - mine | |||||
| merged = base + added_by_current + added_by_me | |||||
| merged -= removed_by_current | |||||
| merged -= removed_by_me | |||||
| merged.uniq.sort | |||||
| end | |||||
| end | end | ||||
| @@ -28,6 +28,8 @@ class Post < ApplicationRecord | |||||
| has_one_attached :thumbnail | has_one_attached :thumbnail | ||||
| attribute :version_no, :integer, default: 1 | |||||
| before_validation :normalise_url | before_validation :normalise_url | ||||
| validates :url, presence: true, uniqueness: true | validates :url, presence: true, uniqueness: true | ||||
| @@ -40,6 +40,8 @@ class Tag < ApplicationRecord | |||||
| belongs_to :tag_name | belongs_to :tag_name | ||||
| delegate :wiki_page, to: :tag_name | delegate :wiki_page, to: :tag_name | ||||
| attribute :version_no, :integer, default: 1 | |||||
| delegate :name, to: :tag_name, allow_nil: true | delegate :name, to: :tag_name, allow_nil: true | ||||
| validates :tag_name, presence: true | validates :tag_name, presence: true | ||||
| @@ -136,7 +138,6 @@ class Tag < ApplicationRecord | |||||
| tn = tn.canonical if tn.canonical_id? | tn = tn.canonical if tn.canonical_id? | ||||
| Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t| | Tag.find_undiscard_or_create_by!(tag_name_id: tn.id) do |t| | ||||
| t.version_no = TagVersion.where(tag_id: t.id).order(version_no: :desc).first || 1 | |||||
| t.category = category | t.category = category | ||||
| end | end | ||||
| rescue ActiveRecord::RecordNotUnique | rescue ActiveRecord::RecordNotUnique | ||||
| @@ -15,6 +15,8 @@ class WikiPage < ApplicationRecord | |||||
| has_many :wiki_versions | has_many :wiki_versions | ||||
| attribute :version_no, :integer, default: 1 | |||||
| belongs_to :tag_name | belongs_to :tag_name | ||||
| validates :tag_name, presence: true | validates :tag_name, presence: true | ||||
| validates :body, presence: true | validates :body, presence: true | ||||
| @@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button' | |||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { updatePost } from '@/lib/posts' | import { updatePost } from '@/lib/posts' | ||||
| import type { FC } from 'react' | |||||
| import type { FC, FormEvent } from 'react' | |||||
| import type { Post, Tag } from '@/types' | import type { Post, Tag } from '@/types' | ||||
| @@ -33,6 +33,7 @@ type Props = { post: Post | |||||
| export default (({ post, onSave }: Props) => { | export default (({ post, onSave }: Props) => { | ||||
| const [disabled, setDisabled] = useState (false) | |||||
| const [originalCreatedBefore, setOriginalCreatedBefore] = | const [originalCreatedBefore, setOriginalCreatedBefore] = | ||||
| useState<string | null> (post.originalCreatedBefore) | useState<string | null> (post.originalCreatedBefore) | ||||
| const [originalCreatedFrom, setOriginalCreatedFrom] = | const [originalCreatedFrom, setOriginalCreatedFrom] = | ||||
| @@ -61,7 +62,9 @@ export default (({ post, onSave }: Props) => { | |||||
| } | } | ||||
| catch (e) | catch (e) | ||||
| { | { | ||||
| if (e.response.status !== 409) | |||||
| const response = (e as any)?.response | |||||
| if (response?.status !== 409) | |||||
| { | { | ||||
| toast ({ description: '更新はできなかったよ……' }) | toast ({ description: '更新はできなかったよ……' }) | ||||
| return | return | ||||
| @@ -74,7 +77,7 @@ export default (({ post, onSave }: Props) => { | |||||
| <p>ほかの耕作員が先に更新してゐます.</p> | <p>ほかの耕作員が先に更新してゐます.</p> | ||||
| <p>現在の変更をどう扱ひますか?</p> | <p>現在の変更をどう扱ひますか?</p> | ||||
| </div>), | </div>), | ||||
| choices: [{ value: 'merge', label: '差分をマージ' }, | |||||
| choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []), | |||||
| { value: 'overwrite', label: '強制上書き', variant: 'danger' }] }) | { value: 'overwrite', label: '強制上書き', variant: 'danger' }] }) | ||||
| if (action === 'merge') | if (action === 'merge') | ||||
| @@ -96,12 +99,14 @@ export default (({ post, onSave }: Props) => { | |||||
| } | } | ||||
| } | } | ||||
| const handleSubmit = async e => { | |||||
| const handleSubmit = async (e: FormEvent) => { | |||||
| e.preventDefault () | e.preventDefault () | ||||
| setDisabled (true) | |||||
| await update ({ id: post.id, title, tags, parentPostIds, | await update ({ id: post.id, title, tags, parentPostIds, | ||||
| originalCreatedFrom, originalCreatedBefore }, | originalCreatedFrom, originalCreatedBefore }, | ||||
| { baseVersionNo: post.versionNo }) | { baseVersionNo: post.versionNo }) | ||||
| setDisabled (false) | |||||
| } | } | ||||
| useEffect (() => { | useEffect (() => { | ||||
| @@ -113,10 +118,12 @@ export default (({ post, onSave }: Props) => { | |||||
| {/* タイトル */} | {/* タイトル */} | ||||
| <div> | <div> | ||||
| <Label>タイトル</Label> | <Label>タイトル</Label> | ||||
| <input type="text" | |||||
| className="w-full border rounded p-2" | |||||
| value={title ?? ''} | |||||
| onChange={ev => setTitle (ev.target.value)}/> | |||||
| <input | |||||
| type="text" | |||||
| disabled={disabled} | |||||
| className="w-full border rounded p-2" | |||||
| value={title ?? ''} | |||||
| onChange={ev => setTitle (ev.target.value)}/> | |||||
| </div> | </div> | ||||
| {/* 親投稿 */} | {/* 親投稿 */} | ||||
| @@ -124,24 +131,32 @@ export default (({ post, onSave }: Props) => { | |||||
| <Label>親投稿</Label> | <Label>親投稿</Label> | ||||
| <input | <input | ||||
| type="text" | type="text" | ||||
| disabled={disabled} | |||||
| value={parentPostIds} | value={parentPostIds} | ||||
| onChange={e => setParentPostIds (e.target.value)} | onChange={e => setParentPostIds (e.target.value)} | ||||
| className="w-full border p-2 rounded"/> | className="w-full border p-2 rounded"/> | ||||
| </div> | </div> | ||||
| {/* タグ */} | {/* タグ */} | ||||
| <PostFormTagsArea tags={tags} setTags={setTags}/> | |||||
| <PostFormTagsArea | |||||
| disabled={disabled} | |||||
| tags={tags} | |||||
| setTags={setTags}/> | |||||
| {/* オリジナルの作成日時 */} | {/* オリジナルの作成日時 */} | ||||
| <PostOriginalCreatedTimeField | <PostOriginalCreatedTimeField | ||||
| disabled={disabled} | |||||
| originalCreatedFrom={originalCreatedFrom} | originalCreatedFrom={originalCreatedFrom} | ||||
| setOriginalCreatedFrom={setOriginalCreatedFrom} | setOriginalCreatedFrom={setOriginalCreatedFrom} | ||||
| originalCreatedBefore={originalCreatedBefore} | originalCreatedBefore={originalCreatedBefore} | ||||
| setOriginalCreatedBefore={setOriginalCreatedBefore}/> | setOriginalCreatedBefore={setOriginalCreatedBefore}/> | ||||
| {/* 送信 */} | {/* 送信 */} | ||||
| <Button onClick={handleSubmit} | |||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||||
| <Button | |||||
| type="submit" | |||||
| disabled={disabled} | |||||
| onClick={handleSubmit} | |||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||||
| 更新 | 更新 | ||||
| </Button> | </Button> | ||||
| </div>) | </div>) | ||||
| @@ -7,7 +7,7 @@ import Label from '@/components/common/Label' | |||||
| import TextArea from '@/components/common/TextArea' | import TextArea from '@/components/common/TextArea' | ||||
| import { apiGet } from '@/lib/api' | import { apiGet } from '@/lib/api' | ||||
| import type { FC, SyntheticEvent } from 'react' | |||||
| import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react' | |||||
| import type { Tag } from '@/types' | import type { Tag } from '@/types' | ||||
| @@ -31,12 +31,13 @@ const replaceToken = (value: string, start: number, end: number, text: string) = | |||||
| `${ value.slice (0, start) }${ text }${ value.slice (end) }` | `${ value.slice (0, start) }${ text }${ value.slice (end) }` | ||||
| type Props = { | |||||
| tags: string | |||||
| setTags: (tags: string) => void } | |||||
| type Props = | |||||
| & { tags: string | |||||
| setTags: (tags: string) => void } | |||||
| & ComponentPropsWithoutRef<'textarea'> | |||||
| export default (({ tags, setTags }: Props) => { | |||||
| export default (({ tags, setTags, ...rest }: Props) => { | |||||
| const ref = useRef<HTMLTextAreaElement> (null) | const ref = useRef<HTMLTextAreaElement> (null) | ||||
| const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) | const [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 }) | ||||
| @@ -87,7 +88,8 @@ export default (({ tags, setTags }: Props) => { | |||||
| onBlur={() => { | onBlur={() => { | ||||
| setFocused (false) | setFocused (false) | ||||
| setSuggestionsVsbl (false) | setSuggestionsVsbl (false) | ||||
| }}/> | |||||
| }} | |||||
| {...rest}/> | |||||
| {focused && ( | {focused && ( | ||||
| <TagSearchBox | <TagSearchBox | ||||
| suggestions={suggestionsVsbl && suggestions.length > 0 | suggestions={suggestionsVsbl && suggestions.length > 0 | ||||
| @@ -5,13 +5,15 @@ import { Button } from '@/components/ui/button' | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| type Props = { | type Props = { | ||||
| disabled?: boolean | |||||
| originalCreatedFrom: string | null | originalCreatedFrom: string | null | ||||
| setOriginalCreatedFrom: (x: string | null) => void | setOriginalCreatedFrom: (x: string | null) => void | ||||
| originalCreatedBefore: string | null | originalCreatedBefore: string | null | ||||
| setOriginalCreatedBefore: (x: string | null) => void } | setOriginalCreatedBefore: (x: string | null) => void } | ||||
| export default (({ originalCreatedFrom, | |||||
| export default (({ disabled, | |||||
| originalCreatedFrom, | |||||
| setOriginalCreatedFrom, | setOriginalCreatedFrom, | ||||
| originalCreatedBefore, | originalCreatedBefore, | ||||
| setOriginalCreatedBefore }: Props) => ( | setOriginalCreatedBefore }: Props) => ( | ||||
| @@ -21,6 +23,7 @@ export default (({ originalCreatedFrom, | |||||
| <div className="w-80"> | <div className="w-80"> | ||||
| <DateTimeField | <DateTimeField | ||||
| className="mr-2" | className="mr-2" | ||||
| disabled={disabled ?? false} | |||||
| value={originalCreatedFrom ?? undefined} | value={originalCreatedFrom ?? undefined} | ||||
| onChange={setOriginalCreatedFrom} | onChange={setOriginalCreatedFrom} | ||||
| onBlur={ev => { | onBlur={ev => { | ||||
| @@ -40,6 +43,7 @@ export default (({ originalCreatedFrom, | |||||
| <div> | <div> | ||||
| <Button | <Button | ||||
| className="bg-gray-600 text-white rounded" | className="bg-gray-600 text-white rounded" | ||||
| disabled={disabled} | |||||
| onClick={() => { | onClick={() => { | ||||
| setOriginalCreatedFrom (null) | setOriginalCreatedFrom (null) | ||||
| }}> | }}> | ||||
| @@ -51,6 +55,7 @@ export default (({ originalCreatedFrom, | |||||
| <div className="w-80"> | <div className="w-80"> | ||||
| <DateTimeField | <DateTimeField | ||||
| className="mr-2" | className="mr-2" | ||||
| disabled={disabled} | |||||
| value={originalCreatedBefore ?? undefined} | value={originalCreatedBefore ?? undefined} | ||||
| onChange={setOriginalCreatedBefore}/> | onChange={setOriginalCreatedBefore}/> | ||||
| より前 | より前 | ||||
| @@ -58,6 +63,7 @@ export default (({ originalCreatedFrom, | |||||
| <div> | <div> | ||||
| <Button | <Button | ||||
| className="bg-gray-600 text-white rounded" | className="bg-gray-600 text-white rounded" | ||||
| disabled={disabled} | |||||
| onClick={() => { | onClick={() => { | ||||
| setOriginalCreatedBefore (null) | setOriginalCreatedBefore (null) | ||||
| }}> | }}> | ||||
| @@ -36,12 +36,12 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||||
| { name: '一覧', to: '/posts' }, | { name: '一覧', to: '/posts' }, | ||||
| { name: '検索', to: '/posts/search' }, | { name: '検索', to: '/posts/search' }, | ||||
| { name: '追加', to: '/posts/new' }, | { name: '追加', to: '/posts/new' }, | ||||
| { name: '履歴', to: '/posts/changes' }, | |||||
| { name: '全体履歴', to: '/posts/changes' }, | |||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, | ||||
| { name: 'タグ', to: '/tags', subMenu: [ | { name: 'タグ', to: '/tags', subMenu: [ | ||||
| { name: 'マスタ', to: '/tags' }, | { name: 'マスタ', to: '/tags' }, | ||||
| { name: 'ニコニコ連携', to: '/tags/nico' }, | { name: 'ニコニコ連携', to: '/tags/nico' }, | ||||
| { name: '履歴', to: '/tags/changes' }, | |||||
| { name: '全体履歴', to: '/tags/changes' }, | |||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, | { name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' }, | ||||
| { component: <Separator/>, visible: tagFlg }, | { component: <Separator/>, visible: tagFlg }, | ||||
| { name: `広場 (${ postCount || 0 })`, | { name: `広場 (${ postCount || 0 })`, | ||||
| @@ -53,7 +53,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: { | |||||
| { name: '一覧', to: '/materials' }, | { name: '一覧', to: '/materials' }, | ||||
| { name: '検索', to: '/materials/search', visible: false }, | { name: '検索', to: '/materials/search', visible: false }, | ||||
| { name: '追加', to: '/materials/new' }, | { name: '追加', to: '/materials/new' }, | ||||
| { name: '履歴', to: '/materials/changes', visible: false }, | |||||
| { name: '全体履歴', to: '/materials/changes', visible: false }, | |||||
| { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, | { name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] }, | ||||
| { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ | { name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [ | ||||
| { name: <>第 1 会場</>, to: '/theatres/1' }, | { name: <>第 1 会場</>, to: '/theatres/1' }, | ||||
| @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' | |||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| import type { FC, FocusEvent } from 'react' | |||||
| import type { ComponentPropsWithoutRef, FC, FocusEvent } from 'react' | |||||
| const pad = (n: number): string => n.toString ().padStart (2, '0') | const pad = (n: number): string => n.toString ().padStart (2, '0') | ||||
| @@ -18,14 +18,14 @@ const toDateTimeLocalValue = (d: Date) => { | |||||
| } | } | ||||
| type Props = { | |||||
| type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & { | |||||
| value?: string | value?: string | ||||
| onChange?: (isoUTC: string | null) => void | onChange?: (isoUTC: string | null) => void | ||||
| className?: string | className?: string | ||||
| onBlur?: (ev: FocusEvent<HTMLInputElement>) => void } | onBlur?: (ev: FocusEvent<HTMLInputElement>) => void } | ||||
| export default (({ value, onChange, className, onBlur }: Props) => { | |||||
| export default (({ value, onChange, className, onBlur, ...rest }: Props) => { | |||||
| const [local, setLocal] = useState ('') | const [local, setLocal] = useState ('') | ||||
| useEffect (() => { | useEffect (() => { | ||||
| @@ -42,5 +42,6 @@ export default (({ value, onChange, className, onBlur }: Props) => { | |||||
| setLocal (v) | setLocal (v) | ||||
| onChange?.(v ? (new Date (v)).toISOString () : null) | onChange?.(v ? (new Date (v)).toISOString () : null) | ||||
| }} | }} | ||||
| onBlur={onBlur}/>) | |||||
| onBlur={onBlur} | |||||
| {...rest}/>) | |||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -37,25 +37,27 @@ const DialogContent = React.forwardRef< | |||||
| <DialogOverlay /> | <DialogOverlay /> | ||||
| <DialogPrimitive.Content | <DialogPrimitive.Content | ||||
| ref={ref} | ref={ref} | ||||
| className={cn( | |||||
| 'fixed left-[50%] top-[50%] z-50 w-[90%] grid max-w-lg', | |||||
| className={cn ( | |||||
| 'fixed left-[50%] top-[50%] z-50 grid w-[calc(100%-2rem)] max-w-lg', | |||||
| 'translate-x-[-50%] translate-y-[-50%]', | 'translate-x-[-50%] translate-y-[-50%]', | ||||
| 'gap-4 border bg-gray-300/80 dark:bg-gray-700/80', | |||||
| 'p-6 shadow-lg duration-200', | |||||
| 'gap-5 rounded-2xl border border-border', | |||||
| 'bg-background p-6 text-foreground shadow-2xl', | |||||
| 'duration-200', | |||||
| 'data-[state=open]:animate-in data-[state=closed]:animate-out', | 'data-[state=open]:animate-in data-[state=closed]:animate-out', | ||||
| 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', | 'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0', | ||||
| 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', | 'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', | ||||
| 'data-[state=closed]:slide-out-to-left-1/2', | |||||
| 'data-[state=closed]:slide-out-to-top-[48%]', | |||||
| 'data-[state=open]:slide-in-from-left-1/2', | |||||
| 'data-[state=open]:slide-in-from-top-[48%] rounded-lg', | |||||
| className)} | className)} | ||||
| {...props} | {...props} | ||||
| > | > | ||||
| {children} | {children} | ||||
| <DialogPrimitive.Close className="absolute right-4 top-4 bg-red-500 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground"> | |||||
| <X className="h-3 w-3" /> | |||||
| <span className="sr-only">Close</span> | |||||
| <DialogPrimitive.Close | |||||
| className={cn ( | |||||
| 'absolute right-4 top-4 rounded-full p-1', | |||||
| 'text-muted-foreground opacity-70 transition-opacity', | |||||
| 'hover:bg-accent hover:text-accent-foreground hover:opacity-100', | |||||
| 'focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2')}> | |||||
| <X className="h-4 w-4"/> | |||||
| <span className="sr-only">Close</span> | |||||
| </DialogPrimitive.Close> | </DialogPrimitive.Close> | ||||
| </DialogPrimitive.Content> | </DialogPrimitive.Content> | ||||
| </DialogPortal> | </DialogPortal> | ||||
| @@ -6,6 +6,56 @@ | |||||
| @layer base | @layer base | ||||
| { | { | ||||
| :root | |||||
| { | |||||
| --background: 0 0% 100%; | |||||
| --foreground: 222.2 84% 4.9%; | |||||
| --primary: 222.2 47.4% 11.2%; | |||||
| --primary-foreground: 210 40% 98%; | |||||
| --secondary: 210 40% 96.1%; | |||||
| --secondary-foreground: 222.2 47.4% 11.2%; | |||||
| --destructive: 0 72.2% 50.6%; | |||||
| --destructive-foreground: 210 40% 98%; | |||||
| --muted: 210 40% 96.1%; | |||||
| --muted-foreground: 215.4 16.3% 46.9%; | |||||
| --accent: 210 40% 96.1%; | |||||
| --accent-foreground: 222.2 47.4% 11.2%; | |||||
| --border: 214.3 31.8% 91.4%; | |||||
| --input: 214.3 31.8% 91.4%; | |||||
| --ring: 222.2 84% 4.9%; | |||||
| } | |||||
| .dark | |||||
| { | |||||
| --background: 222.2 84% 4.9%; | |||||
| --foreground: 210 40% 98%; | |||||
| --primary: 210 40% 98%; | |||||
| --primary-foreground: 222.2 47.4% 11.2%; | |||||
| --secondary: 217.2 32.6% 17.5%; | |||||
| --secondary-foreground: 210 40% 98%; | |||||
| --destructive: 0 62.8% 45%; | |||||
| --destructive-foreground: 210 40% 98%; | |||||
| --muted: 217.2 32.6% 17.5%; | |||||
| --muted-foreground: 215 20.2% 65.1%; | |||||
| --accent: 217.2 32.6% 17.5%; | |||||
| --accent-foreground: 210 40% 98%; | |||||
| --border: 217.2 32.6% 17.5%; | |||||
| --input: 217.2 32.6% 17.5%; | |||||
| --ring: 212.7 26.8% 83.9%; | |||||
| } | |||||
| body | body | ||||
| { | { | ||||
| @apply overflow-x-clip; | @apply overflow-x-clip; | ||||
| @@ -54,34 +104,6 @@ body | |||||
| min-height: 100dvh; | min-height: 100dvh; | ||||
| } | } | ||||
| h1 | |||||
| { | |||||
| font-size: 3.2em; | |||||
| line-height: 1.1; | |||||
| } | |||||
| button | |||||
| { | |||||
| border-radius: 8px; | |||||
| border: 1px solid transparent; | |||||
| padding: 0.6em 1.2em; | |||||
| font-size: 1em; | |||||
| font-weight: 500; | |||||
| font-family: inherit; | |||||
| background-color: #1a1a1a; | |||||
| cursor: pointer; | |||||
| transition: border-color 0.25s; | |||||
| } | |||||
| button:hover | |||||
| { | |||||
| border-color: #646cff; | |||||
| } | |||||
| button:focus, | |||||
| button:focus-visible | |||||
| { | |||||
| outline: 4px auto -webkit-focus-ring-color; | |||||
| } | |||||
| @media (prefers-color-scheme: light) | @media (prefers-color-scheme: light) | ||||
| { | { | ||||
| :root | :root | ||||
| @@ -19,7 +19,22 @@ export default { | |||||
| 'rainbow-scroll': 'rainbow-scroll .25s linear infinite' }, | 'rainbow-scroll': 'rainbow-scroll .25s linear infinite' }, | ||||
| colors: { | colors: { | ||||
| red: { 925: '#5f1414', | red: { 925: '#5f1414', | ||||
| 975: '#230505' } }, | |||||
| 975: '#230505' }, | |||||
| border: 'hsl(var(--border))', | |||||
| input: 'hsl(var(--input))', | |||||
| ring: 'hsl(var(--ring))', | |||||
| background: 'hsl(var(--background))', | |||||
| foreground: 'hsl(var(--foreground))', | |||||
| primary: { DEFAULT: 'hsl(var(--primary))', | |||||
| foreground: 'hsl(var(--primary-foreground))' }, | |||||
| secondary: { DEFAULT: 'hsl(var(--secondary))', | |||||
| foreground: 'hsl(var(--secondary-foreground))' }, | |||||
| destructive: { DEFAULT: 'hsl(var(--destructive))', | |||||
| foreground: 'hsl(var(--destructive-foreground))' }, | |||||
| muted: { DEFAULT: 'hsl(var(--muted))', | |||||
| foreground: 'hsl(var(--muted-foreground))' }, | |||||
| accent: { DEFAULT: 'hsl(var(--accent))', | |||||
| foreground: 'hsl(var(--accent-foreground))' } }, | |||||
| keyframes: { | keyframes: { | ||||
| 'rainbow-scroll': { | 'rainbow-scroll': { | ||||
| '0%': { backgroundPosition: '0% 50%' }, | '0%': { backgroundPosition: '0% 50%' }, | ||||