| @@ -177,8 +177,8 @@ class PostsController < ApplicationController | |||||
| merge = bool?(:merge) | merge = bool?(:merge) | ||||
| return head :bad_request if force && merge | return head :bad_request if force && merge | ||||
| base_version_no = nil | |||||
| base_version_no = parse_base_version_no unless force | |||||
| base_version_no = parse_base_version_no | |||||
| return head :bad_request if !(force) && !(base_version_no) | |||||
| title = params[:title].presence | title = params[:title].presence | ||||
| tag_names = params[:tags].to_s.split | tag_names = params[:tags].to_s.split | ||||
| @@ -442,9 +442,11 @@ class PostsController < ApplicationController | |||||
| def parse_base_version_no | def parse_base_version_no | ||||
| version_no = Integer(params[:base_version_no], exception: false) | version_no = Integer(params[:base_version_no], exception: false) | ||||
| raise ArgumentError, 'base_version_no は必須です.' unless version_no&.positive? | |||||
| version_no | |||||
| if version_no&.positive? | |||||
| version_no | |||||
| else | |||||
| nil | |||||
| end | |||||
| end | end | ||||
| def post_snapshot_from_version version | def post_snapshot_from_version version | ||||
| @@ -16,7 +16,7 @@ class VersionRecorder | |||||
| @record = record_class.unscoped.lock.find(@record.id) | @record = record_class.unscoped.lock.find(@record.id) | ||||
| latest = latest_version | latest = latest_version | ||||
| validate_version_sequence! latest | |||||
| validate_version_sequence!(latest) | |||||
| attrs = snapshot_attributes | attrs = snapshot_attributes | ||||
| @@ -27,7 +27,7 @@ class VersionRecorder | |||||
| version = version_class.create!( | version = version_class.create!( | ||||
| base_attributes(latest).merge(record_key => @record).merge(attrs)) | base_attributes(latest).merge(record_key => @record).merge(attrs)) | ||||
| update_record_version_no! version.version_no | |||||
| update_record_version_no!(version.version_no) | |||||
| version | version | ||||
| end | end | ||||
| @@ -47,7 +47,7 @@ class VersionRecorder | |||||
| end | end | ||||
| def update_record_version_no! version_no | def update_record_version_no! version_no | ||||
| @record.update_columns version_no: version_no | |||||
| @record.update_columns(version_no:) | |||||
| @record.version_no = version_no | @record.version_no = version_no | ||||
| end | end | ||||
| @@ -103,10 +103,16 @@ export default (({ post, onSave }: Props) => { | |||||
| e.preventDefault () | e.preventDefault () | ||||
| setDisabled (true) | setDisabled (true) | ||||
| await update ({ id: post.id, title, tags, parentPostIds, | |||||
| originalCreatedFrom, originalCreatedBefore }, | |||||
| { baseVersionNo: post.versionNo }) | |||||
| setDisabled (false) | |||||
| try | |||||
| { | |||||
| await update ({ id: post.id, title, tags, parentPostIds, | |||||
| originalCreatedFrom, originalCreatedBefore }, | |||||
| { baseVersionNo: post.versionNo }) | |||||
| } | |||||
| finally | |||||
| { | |||||
| setDisabled (false) | |||||
| } | |||||
| } | } | ||||
| useEffect (() => { | useEffect (() => { | ||||
| @@ -114,7 +120,7 @@ export default (({ post, onSave }: Props) => { | |||||
| }, [post]) | }, [post]) | ||||
| return ( | return ( | ||||
| <div className="max-w-xl pt-2 space-y-4"> | |||||
| <form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4"> | |||||
| {/* タイトル */} | {/* タイトル */} | ||||
| <div> | <div> | ||||
| <Label>タイトル</Label> | <Label>タイトル</Label> | ||||
| @@ -154,10 +160,8 @@ export default (({ post, onSave }: Props) => { | |||||
| {/* 送信 */} | {/* 送信 */} | ||||
| <Button | <Button | ||||
| type="submit" | type="submit" | ||||
| disabled={disabled} | |||||
| onClick={handleSubmit} | |||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||||
| disabled={disabled}> | |||||
| 更新 | 更新 | ||||
| </Button> | </Button> | ||||
| </div>) | |||||
| </form>) | |||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -31,13 +31,12 @@ 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 } | |||||
| & ComponentPropsWithoutRef<'textarea'> | |||||
| type Props = Omit<ComponentPropsWithoutRef<'textarea'>, 'value' | 'onChange' | 'onBlur'> & { | |||||
| tags: string | |||||
| setTags: (tags: string) => void } | |||||
| export default (({ tags, setTags, ...rest }: Props) => { | |||||
| export default (({ tags, setTags, onBlur, ...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 }) | ||||
| @@ -77,6 +76,7 @@ export default (({ tags, setTags, ...rest }: Props) => { | |||||
| <div className="relative w-full"> | <div className="relative w-full"> | ||||
| <Label>タグ</Label> | <Label>タグ</Label> | ||||
| <TextArea | <TextArea | ||||
| {...rest} | |||||
| ref={ref} | ref={ref} | ||||
| value={tags} | value={tags} | ||||
| onChange={ev => setTags (ev.target.value)} | onChange={ev => setTags (ev.target.value)} | ||||
| @@ -85,11 +85,11 @@ export default (({ tags, setTags, ...rest }: Props) => { | |||||
| await recompute (pos) | await recompute (pos) | ||||
| }} | }} | ||||
| onFocus={() => setFocused (true)} | onFocus={() => setFocused (true)} | ||||
| onBlur={() => { | |||||
| onBlur={ev => { | |||||
| setFocused (false) | setFocused (false) | ||||
| setSuggestionsVsbl (false) | setSuggestionsVsbl (false) | ||||
| }} | |||||
| {...rest}/> | |||||
| onBlur?.(ev) | |||||
| }}/> | |||||
| {focused && ( | {focused && ( | ||||
| <TagSearchBox | <TagSearchBox | ||||
| suggestions={suggestionsVsbl && suggestions.length > 0 | suggestions={suggestionsVsbl && suggestions.length > 0 | ||||
| @@ -34,6 +34,7 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => { | |||||
| return ( | return ( | ||||
| <input | <input | ||||
| {...rest} | |||||
| className={cn ('border rounded p-2', className)} | className={cn ('border rounded p-2', className)} | ||||
| type="datetime-local" | type="datetime-local" | ||||
| value={local} | value={local} | ||||
| @@ -42,6 +43,5 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => { | |||||
| setLocal (v) | setLocal (v) | ||||
| onChange?.(v ? (new Date (v)).toISOString () : null) | onChange?.(v ? (new Date (v)).toISOString () : null) | ||||
| }} | }} | ||||
| onBlur={onBlur} | |||||
| {...rest}/>) | |||||
| onBlur={onBlur}/>) | |||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -5,6 +5,7 @@ import { Dialog, | |||||
| DialogContent, | DialogContent, | ||||
| DialogDescription, | DialogDescription, | ||||
| DialogFooter, | DialogFooter, | ||||
| DialogHeader, | |||||
| DialogTitle } from '@/components/ui/dialog' | DialogTitle } from '@/components/ui/dialog' | ||||
| import type { FC, ReactNode } from 'react' | import type { FC, ReactNode } from 'react' | ||||
| @@ -118,13 +119,15 @@ export default (({ children }: Props) => { | |||||
| closeActive (active?.kind !== 'confirm' && null) | closeActive (active?.kind !== 'confirm' && null) | ||||
| }}> | }}> | ||||
| {active && ( | {active && ( | ||||
| <DialogContent> | |||||
| <DialogTitle>{active.options.title}</DialogTitle> | |||||
| {active.options.description && ( | |||||
| <DialogDescription asChild> | |||||
| <div>{active.options.description}</div> | |||||
| </DialogDescription>)} | |||||
| <DialogContent className="px-6 pb-6 pt-7"> | |||||
| <DialogHeader className="pl-8"> | |||||
| <DialogTitle>{active.options.title}</DialogTitle> | |||||
| {active.options.description && ( | |||||
| <DialogDescription asChild> | |||||
| <div>{active.options.description}</div> | |||||
| </DialogDescription>)} | |||||
| </DialogHeader> | |||||
| <DialogFooter> | <DialogFooter> | ||||
| {active.kind === 'confirm' && ( | {active.kind === 'confirm' && ( | ||||
| @@ -4,34 +4,47 @@ import { cva, type VariantProps } from "class-variance-authority" | |||||
| import { cn } from "@/lib/utils" | import { cn } from "@/lib/utils" | ||||
| const buttonVariants = cva( | |||||
| "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0", | |||||
| const buttonVariants = cva ( | |||||
| [ | |||||
| 'inline-flex items-center justify-center gap-2 whitespace-nowrap', | |||||
| 'rounded-md text-sm font-medium transition-colors', | |||||
| 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400', | |||||
| 'disabled:pointer-events-none disabled:opacity-50', | |||||
| '[&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0', | |||||
| ].join (' '), | |||||
| { | { | ||||
| variants: { | variants: { | ||||
| variant: { | variant: { | ||||
| default: "bg-primary text-primary-foreground hover:bg-primary/90", | |||||
| default: | |||||
| 'bg-slate-900 text-white hover:bg-slate-700 dark:bg-slate-100 dark:text-slate-900 dark:hover:bg-slate-300', | |||||
| destructive: | destructive: | ||||
| "bg-destructive text-destructive-foreground hover:bg-destructive/90", | |||||
| 'bg-red-600 text-white hover:bg-red-700 dark:bg-red-700 dark:hover:bg-red-600', | |||||
| outline: | outline: | ||||
| "border border-input bg-background hover:bg-accent hover:text-accent-foreground", | |||||
| 'border border-slate-300 bg-white text-slate-900 hover:bg-slate-100 dark:border-slate-700 dark:bg-slate-900 dark:text-slate-100 dark:hover:bg-slate-800', | |||||
| secondary: | secondary: | ||||
| "bg-secondary text-secondary-foreground hover:bg-secondary/80", | |||||
| ghost: "hover:bg-accent hover:text-accent-foreground", | |||||
| link: "text-primary underline-offset-4 hover:underline", | |||||
| 'bg-slate-100 text-slate-900 hover:bg-slate-200 dark:bg-slate-800 dark:text-slate-100 dark:hover:bg-slate-700', | |||||
| ghost: | |||||
| 'text-slate-900 hover:bg-slate-100 dark:text-slate-100 dark:hover:bg-slate-800', | |||||
| link: | |||||
| 'text-blue-700 underline-offset-4 hover:underline dark:text-blue-300', | |||||
| }, | }, | ||||
| size: { | size: { | ||||
| default: "h-10 px-4 py-2", | |||||
| sm: "h-9 rounded-md px-3", | |||||
| lg: "h-11 rounded-md px-8", | |||||
| icon: "h-10 w-10", | |||||
| default: 'h-10 px-4 py-2', | |||||
| sm: 'h-9 rounded-md px-3', | |||||
| lg: 'h-11 rounded-md px-8', | |||||
| icon: 'h-10 w-10', | |||||
| }, | }, | ||||
| }, | }, | ||||
| defaultVariants: { | defaultVariants: { | ||||
| variant: "default", | |||||
| size: "default", | |||||
| variant: 'default', | |||||
| size: 'default', | |||||
| }, | }, | ||||
| } | |||||
| ) | |||||
| }) | |||||
| export interface ButtonProps | export interface ButtonProps | ||||
| extends React.ButtonHTMLAttributes<HTMLButtonElement>, | extends React.ButtonHTMLAttributes<HTMLButtonElement>, | ||||
| @@ -50,14 +50,16 @@ const DialogContent = React.forwardRef< | |||||
| {...props} | {...props} | ||||
| > | > | ||||
| {children} | {children} | ||||
| <DialogPrimitive.Close | <DialogPrimitive.Close | ||||
| className={cn ( | 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')}> | |||||
| 'absolute left-4 top-4 rounded-full p-1', | |||||
| 'text-slate-500 transition-colors', | |||||
| 'hover:bg-slate-200 hover:text-slate-900', | |||||
| 'dark:text-slate-400 dark:hover:bg-slate-700 dark:hover:text-slate-50', | |||||
| 'focus:outline-none focus:ring-2 focus:ring-slate-400')}> | |||||
| <X className="h-4 w-4"/> | <X className="h-4 w-4"/> | ||||
| <span className="sr-only">Close</span> | |||||
| <span className="sr-only">閉ぢる</span> | |||||
| </DialogPrimitive.Close> | </DialogPrimitive.Close> | ||||
| </DialogPrimitive.Content> | </DialogPrimitive.Content> | ||||
| </DialogPortal> | </DialogPortal> | ||||