Browse Source

#171

feature/171
みてるぞ 2 weeks ago
parent
commit
5b50642756
12 changed files with 185 additions and 122 deletions
  1. +51
    -56
      backend/app/controllers/posts_controller.rb
  2. +2
    -0
      backend/app/models/post.rb
  3. +2
    -1
      backend/app/models/tag.rb
  4. +2
    -0
      backend/app/models/wiki_page.rb
  5. +26
    -11
      frontend/src/components/PostEditForm.tsx
  6. +8
    -6
      frontend/src/components/PostFormTagsArea.tsx
  7. +7
    -1
      frontend/src/components/PostOriginalCreatedTimeField.tsx
  8. +3
    -3
      frontend/src/components/TopNav.tsx
  9. +5
    -4
      frontend/src/components/common/DateTimeField.tsx
  10. +13
    -11
      frontend/src/components/ui/dialog.tsx
  11. +50
    -28
      frontend/src/index.css
  12. +16
    -1
      frontend/tailwind.config.js

+ 51
- 56
backend/app/controllers/posts_controller.rb View File

@@ -173,8 +173,8 @@ class PostsController < ApplicationController
return head :unauthorized unless current_user
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

base_version_no = nil
@@ -201,15 +201,14 @@ class PostsController < ApplicationController
base_snapshot = post_snapshot_from_version(base_version)
current_snapshot = post_snapshot_from_record(post)
end
incoming_snapshot = post_incoming_snapshot(post,
title:,
incoming_snapshot = post_incoming_snapshot(title:,
original_created_from:,
original_created_before:,
tag_names:,
parent_post_ids:)

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
else
changes = post_snapshot_changes(base_snapshot, current_snapshot, incoming_snapshot)
@@ -448,30 +447,36 @@ class PostsController < ApplicationController
version_no
end

def truthy_param?(value) = ActiveModel::Type::Boolean.new.cast(value)

def post_snapshot_from_version version
{ title: version.title,
original_created_from: snapshot_time(version.original_created_from),
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) }
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
{ title: post.title,
original_created_from: snapshot_time(post.original_created_from),
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) }
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:,
original_created_from: snapshot_time(original_created_from),
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 }
end

@@ -494,44 +499,10 @@ class PostsController < ApplicationController
value.to_s
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

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_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)
sync_post_tags!(post, tags)

sync_post_tags!(post, tags)
sync_parent_posts!(post, snapshot[:parent_post_ids])

PostVersionRecorder.record!(post:, event_type: :update, created_by_user: current_user)
end

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],
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

def merge_scaler_snapshot_value base, current, mine
def merge_scalar_snapshot_value base, current, mine
return mine if current == base
return current if mine == base || current == mine

raise ArgumentError, '競合してゐる項目はマージできません.'
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

+ 2
- 0
backend/app/models/post.rb View File

@@ -28,6 +28,8 @@ class Post < ApplicationRecord

has_one_attached :thumbnail

attribute :version_no, :integer, default: 1

before_validation :normalise_url

validates :url, presence: true, uniqueness: true


+ 2
- 1
backend/app/models/tag.rb View File

@@ -40,6 +40,8 @@ class Tag < ApplicationRecord
belongs_to :tag_name
delegate :wiki_page, to: :tag_name

attribute :version_no, :integer, default: 1

delegate :name, to: :tag_name, allow_nil: true
validates :tag_name, presence: true

@@ -136,7 +138,6 @@ class Tag < ApplicationRecord
tn = tn.canonical if tn.canonical_id?

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
end
rescue ActiveRecord::RecordNotUnique


+ 2
- 0
backend/app/models/wiki_page.rb View File

@@ -15,6 +15,8 @@ class WikiPage < ApplicationRecord

has_many :wiki_versions

attribute :version_no, :integer, default: 1

belongs_to :tag_name
validates :tag_name, presence: true
validates :body, presence: true


+ 26
- 11
frontend/src/components/PostEditForm.tsx View File

@@ -8,7 +8,7 @@ import { Button } from '@/components/ui/button'
import { toast } from '@/components/ui/use-toast'
import { updatePost } from '@/lib/posts'

import type { FC } from 'react'
import type { FC, FormEvent } from 'react'

import type { Post, Tag } from '@/types'

@@ -33,6 +33,7 @@ type Props = { post: Post


export default (({ post, onSave }: Props) => {
const [disabled, setDisabled] = useState (false)
const [originalCreatedBefore, setOriginalCreatedBefore] =
useState<string | null> (post.originalCreatedBefore)
const [originalCreatedFrom, setOriginalCreatedFrom] =
@@ -61,7 +62,9 @@ export default (({ post, onSave }: Props) => {
}
catch (e)
{
if (e.response.status !== 409)
const response = (e as any)?.response

if (response?.status !== 409)
{
toast ({ description: '更新はできなかったよ……' })
return
@@ -74,7 +77,7 @@ export default (({ post, onSave }: Props) => {
<p>ほかの耕作員が先に更新してゐます.</p>
<p>現在の変更をどう扱ひますか?</p>
</div>),
choices: [{ value: 'merge', label: '差分をマージ' },
choices: [...(response?.data?.mergeable ? [{ value: 'merge', label: '差分をマージ' }] : []),
{ value: 'overwrite', label: '強制上書き', variant: 'danger' }] })

if (action === 'merge')
@@ -96,12 +99,14 @@ export default (({ post, onSave }: Props) => {
}
}

const handleSubmit = async e => {
const handleSubmit = async (e: FormEvent) => {
e.preventDefault ()

setDisabled (true)
await update ({ id: post.id, title, tags, parentPostIds,
originalCreatedFrom, originalCreatedBefore },
{ baseVersionNo: post.versionNo })
setDisabled (false)
}

useEffect (() => {
@@ -113,10 +118,12 @@ export default (({ post, onSave }: Props) => {
{/* タイトル */}
<div>
<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>

{/* 親投稿 */}
@@ -124,24 +131,32 @@ export default (({ post, onSave }: Props) => {
<Label>親投稿</Label>
<input
type="text"
disabled={disabled}
value={parentPostIds}
onChange={e => setParentPostIds (e.target.value)}
className="w-full border p-2 rounded"/>
</div>

{/* タグ */}
<PostFormTagsArea tags={tags} setTags={setTags}/>
<PostFormTagsArea
disabled={disabled}
tags={tags}
setTags={setTags}/>

{/* オリジナルの作成日時 */}
<PostOriginalCreatedTimeField
disabled={disabled}
originalCreatedFrom={originalCreatedFrom}
setOriginalCreatedFrom={setOriginalCreatedFrom}
originalCreatedBefore={originalCreatedBefore}
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>
</div>)


+ 8
- 6
frontend/src/components/PostFormTagsArea.tsx View File

@@ -7,7 +7,7 @@ import Label from '@/components/common/Label'
import TextArea from '@/components/common/TextArea'
import { apiGet } from '@/lib/api'

import type { FC, SyntheticEvent } from 'react'
import type { ComponentPropsWithoutRef, FC, SyntheticEvent } from 'react'

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) }`


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 [bounds, setBounds] = useState<{ start: number; end: number }> ({ start: 0, end: 0 })
@@ -87,7 +88,8 @@ export default (({ tags, setTags }: Props) => {
onBlur={() => {
setFocused (false)
setSuggestionsVsbl (false)
}}/>
}}
{...rest}/>
{focused && (
<TagSearchBox
suggestions={suggestionsVsbl && suggestions.length > 0


+ 7
- 1
frontend/src/components/PostOriginalCreatedTimeField.tsx View File

@@ -5,13 +5,15 @@ import { Button } from '@/components/ui/button'
import type { FC } from 'react'

type Props = {
disabled?: boolean
originalCreatedFrom: string | null
setOriginalCreatedFrom: (x: string | null) => void
originalCreatedBefore: string | null
setOriginalCreatedBefore: (x: string | null) => void }


export default (({ originalCreatedFrom,
export default (({ disabled,
originalCreatedFrom,
setOriginalCreatedFrom,
originalCreatedBefore,
setOriginalCreatedBefore }: Props) => (
@@ -21,6 +23,7 @@ export default (({ originalCreatedFrom,
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled ?? false}
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}
onBlur={ev => {
@@ -40,6 +43,7 @@ export default (({ originalCreatedFrom,
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedFrom (null)
}}>
@@ -51,6 +55,7 @@ export default (({ originalCreatedFrom,
<div className="w-80">
<DateTimeField
className="mr-2"
disabled={disabled}
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
より前
@@ -58,6 +63,7 @@ export default (({ originalCreatedFrom,
<div>
<Button
className="bg-gray-600 text-white rounded"
disabled={disabled}
onClick={() => {
setOriginalCreatedBefore (null)
}}>


+ 3
- 3
frontend/src/components/TopNav.tsx View File

@@ -36,12 +36,12 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '一覧', to: '/posts' },
{ name: '検索', to: '/posts/search' },
{ name: '追加', to: '/posts/new' },
{ name: '履歴', to: '/posts/changes' },
{ name: '全体履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [
{ name: 'マスタ', to: '/tags' },
{ name: 'ニコニコ連携', to: '/tags/nico' },
{ name: '履歴', to: '/tags/changes' },
{ name: '全体履歴', to: '/tags/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:タグ' },
{ component: <Separator/>, visible: tagFlg },
{ name: `広場 (${ postCount || 0 })`,
@@ -53,7 +53,7 @@ export const menuOutline = ({ tag, wikiId, user, pathName }: {
{ name: '一覧', to: '/materials' },
{ name: '検索', to: '/materials/search', visible: false },
{ name: '追加', to: '/materials/new' },
{ name: '履歴', to: '/materials/changes', visible: false },
{ name: '全体履歴', to: '/materials/changes', visible: false },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:素材集' }] },
{ name: '上映会', to: '/theatres/1', base: '/theatres', subMenu: [
{ name: <>第&thinsp;1&thinsp;会場</>, to: '/theatres/1' },


+ 5
- 4
frontend/src/components/common/DateTimeField.tsx View File

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react'

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')
@@ -18,14 +18,14 @@ const toDateTimeLocalValue = (d: Date) => {
}


type Props = {
type Props = Omit<ComponentPropsWithoutRef<'input'>, 'onChange'> & {
value?: string
onChange?: (isoUTC: string | null) => void
className?: string
onBlur?: (ev: FocusEvent<HTMLInputElement>) => void }


export default (({ value, onChange, className, onBlur }: Props) => {
export default (({ value, onChange, className, onBlur, ...rest }: Props) => {
const [local, setLocal] = useState ('')

useEffect (() => {
@@ -42,5 +42,6 @@ export default (({ value, onChange, className, onBlur }: Props) => {
setLocal (v)
onChange?.(v ? (new Date (v)).toISOString () : null)
}}
onBlur={onBlur}/>)
onBlur={onBlur}
{...rest}/>)
}) satisfies FC<Props>

+ 13
- 11
frontend/src/components/ui/dialog.tsx View File

@@ -37,25 +37,27 @@ const DialogContent = React.forwardRef<
<DialogOverlay />
<DialogPrimitive.Content
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%]',
'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=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]: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)}
{...props}
>
{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.Content>
</DialogPortal>


+ 50
- 28
frontend/src/index.css View File

@@ -6,6 +6,56 @@

@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
{
@apply overflow-x-clip;
@@ -54,34 +104,6 @@ body
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)
{
:root


+ 16
- 1
frontend/tailwind.config.js View File

@@ -19,7 +19,22 @@ export default {
'rainbow-scroll': 'rainbow-scroll .25s linear infinite' },
colors: {
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: {
'rainbow-scroll': {
'0%': { backgroundPosition: '0% 50%' },


Loading…
Cancel
Save