| @@ -177,8 +177,8 @@ class PostsController < ApplicationController | |||
| merge = bool?(: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 | |||
| tag_names = params[:tags].to_s.split | |||
| @@ -442,9 +442,11 @@ class PostsController < ApplicationController | |||
| def parse_base_version_no | |||
| 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 | |||
| def post_snapshot_from_version version | |||
| @@ -16,7 +16,7 @@ class VersionRecorder | |||
| @record = record_class.unscoped.lock.find(@record.id) | |||
| latest = latest_version | |||
| validate_version_sequence! latest | |||
| validate_version_sequence!(latest) | |||
| attrs = snapshot_attributes | |||
| @@ -27,7 +27,7 @@ class VersionRecorder | |||
| version = version_class.create!( | |||
| base_attributes(latest).merge(record_key => @record).merge(attrs)) | |||
| update_record_version_no! version.version_no | |||
| update_record_version_no!(version.version_no) | |||
| version | |||
| end | |||
| @@ -47,7 +47,7 @@ class VersionRecorder | |||
| end | |||
| def update_record_version_no! version_no | |||
| @record.update_columns version_no: version_no | |||
| @record.update_columns(version_no:) | |||
| @record.version_no = version_no | |||
| end | |||
| @@ -103,10 +103,16 @@ export default (({ post, onSave }: Props) => { | |||
| e.preventDefault () | |||
| 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 (() => { | |||
| @@ -114,7 +120,7 @@ export default (({ post, onSave }: Props) => { | |||
| }, [post]) | |||
| return ( | |||
| <div className="max-w-xl pt-2 space-y-4"> | |||
| <form onSubmit={handleSubmit} className="max-w-xl pt-2 space-y-4"> | |||
| {/* タイトル */} | |||
| <div> | |||
| <Label>タイトル</Label> | |||
| @@ -154,10 +160,8 @@ export default (({ post, onSave }: Props) => { | |||
| {/* 送信 */} | |||
| <Button | |||
| type="submit" | |||
| disabled={disabled} | |||
| onClick={handleSubmit} | |||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||
| disabled={disabled}> | |||
| 更新 | |||
| </Button> | |||
| </div>) | |||
| </form>) | |||
| }) 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) }` | |||
| 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 [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"> | |||
| <Label>タグ</Label> | |||
| <TextArea | |||
| {...rest} | |||
| ref={ref} | |||
| value={tags} | |||
| onChange={ev => setTags (ev.target.value)} | |||
| @@ -85,11 +85,11 @@ export default (({ tags, setTags, ...rest }: Props) => { | |||
| await recompute (pos) | |||
| }} | |||
| onFocus={() => setFocused (true)} | |||
| onBlur={() => { | |||
| onBlur={ev => { | |||
| setFocused (false) | |||
| setSuggestionsVsbl (false) | |||
| }} | |||
| {...rest}/> | |||
| onBlur?.(ev) | |||
| }}/> | |||
| {focused && ( | |||
| <TagSearchBox | |||
| suggestions={suggestionsVsbl && suggestions.length > 0 | |||
| @@ -34,6 +34,7 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => { | |||
| return ( | |||
| <input | |||
| {...rest} | |||
| className={cn ('border rounded p-2', className)} | |||
| type="datetime-local" | |||
| value={local} | |||
| @@ -42,6 +43,5 @@ export default (({ value, onChange, className, onBlur, ...rest }: Props) => { | |||
| setLocal (v) | |||
| onChange?.(v ? (new Date (v)).toISOString () : null) | |||
| }} | |||
| onBlur={onBlur} | |||
| {...rest}/>) | |||
| onBlur={onBlur}/>) | |||
| }) satisfies FC<Props> | |||
| @@ -5,6 +5,7 @@ import { Dialog, | |||
| DialogContent, | |||
| DialogDescription, | |||
| DialogFooter, | |||
| DialogHeader, | |||
| DialogTitle } from '@/components/ui/dialog' | |||
| import type { FC, ReactNode } from 'react' | |||
| @@ -118,13 +119,15 @@ export default (({ children }: Props) => { | |||
| closeActive (active?.kind !== 'confirm' && null) | |||
| }}> | |||
| {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> | |||
| {active.kind === 'confirm' && ( | |||
| @@ -4,34 +4,47 @@ import { cva, type VariantProps } from "class-variance-authority" | |||
| 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: { | |||
| 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: | |||
| "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: | |||
| "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: | |||
| "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: { | |||
| 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: { | |||
| variant: "default", | |||
| size: "default", | |||
| variant: 'default', | |||
| size: 'default', | |||
| }, | |||
| } | |||
| ) | |||
| }) | |||
| export interface ButtonProps | |||
| extends React.ButtonHTMLAttributes<HTMLButtonElement>, | |||
| @@ -50,14 +50,16 @@ const DialogContent = React.forwardRef< | |||
| {...props} | |||
| > | |||
| {children} | |||
| <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')}> | |||
| '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"/> | |||
| <span className="sr-only">Close</span> | |||
| <span className="sr-only">閉ぢる</span> | |||
| </DialogPrimitive.Close> | |||
| </DialogPrimitive.Content> | |||
| </DialogPortal> | |||