| @@ -8,8 +8,11 @@ class PostsController < ApplicationController | |||||
| limit = params[:limit].presence&.to_i | limit = params[:limit].presence&.to_i | ||||
| cursor = params[:cursor].presence | cursor = params[:cursor].presence | ||||
| q = filtered_posts.order(created_at: :desc) | |||||
| q = q.where('posts.created_at < ?', Time.iso8601(cursor)) if cursor | |||||
| created_at = ('COALESCE(posts.original_created_before - INTERVAL 1 SECOND,' + | |||||
| 'posts.original_created_from,' + | |||||
| 'posts.created_at)') | |||||
| q = filtered_posts.order(Arel.sql("#{ created_at } DESC")) | |||||
| q = q.where("#{ created_at } < ?", Time.iso8601(cursor)) if cursor | |||||
| posts = limit ? q.limit(limit + 1) : q | posts = limit ? q.limit(limit + 1) : q | ||||
| @@ -20,14 +23,14 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| render json: { posts: posts.map { |post| | render json: { posts: posts.map { |post| | ||||
| post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }).tap { |json| | |||||
| post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }).tap do |json| | |||||
| json['thumbnail'] = | json['thumbnail'] = | ||||
| if post.thumbnail.attached? | if post.thumbnail.attached? | ||||
| rails_storage_proxy_url(post.thumbnail, only_path: false) | rails_storage_proxy_url(post.thumbnail, only_path: false) | ||||
| else | else | ||||
| nil | nil | ||||
| end | end | ||||
| } | |||||
| end | |||||
| }, next_cursor: } | }, next_cursor: } | ||||
| end | end | ||||
| @@ -39,7 +42,7 @@ class PostsController < ApplicationController | |||||
| render json: (post | render json: (post | ||||
| .as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }) | .as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }) | ||||
| .merge(viewed: viewed)) | |||||
| .merge(viewed:)) | |||||
| end | end | ||||
| # GET /posts/1 | # GET /posts/1 | ||||
| @@ -60,14 +63,17 @@ class PostsController < ApplicationController | |||||
| return head :forbidden unless current_user.member? | return head :forbidden unless current_user.member? | ||||
| # TODO: URL が正規のものがチェック,不正ならエラー | # TODO: URL が正規のものがチェック,不正ならエラー | ||||
| # TODO: title、URL は必須にする. | |||||
| # TODO: URL は必須にする(タイトルは省略可). | |||||
| # TODO: サイトに応じて thumbnail_base 設定 | # TODO: サイトに応じて thumbnail_base 設定 | ||||
| title = params[:title] | title = params[:title] | ||||
| url = params[:url] | url = params[:url] | ||||
| thumbnail = params[:thumbnail] | thumbnail = params[:thumbnail] | ||||
| tag_names = params[:tags].to_s.split(' ') | tag_names = params[:tags].to_s.split(' ') | ||||
| original_created_from = params[:original_created_from] | |||||
| original_created_before = params[:original_created_before] | |||||
| post = Post.new(title:, url:, thumbnail_base: '', uploaded_user: current_user) | |||||
| post = Post.new(title:, url:, thumbnail_base: '', uploaded_user: current_user, | |||||
| original_created_from:, original_created_before:) | |||||
| post.thumbnail.attach(thumbnail) | post.thumbnail.attach(thumbnail) | ||||
| if post.save | if post.save | ||||
| post.resized_thumbnail! | post.resized_thumbnail! | ||||
| @@ -100,10 +106,12 @@ class PostsController < ApplicationController | |||||
| title = params[:title] | title = params[:title] | ||||
| tag_names = params[:tags].to_s.split(' ') | tag_names = params[:tags].to_s.split(' ') | ||||
| original_created_from = params[:original_created_from] | |||||
| original_created_before = params[:original_created_before] | |||||
| post = Post.find(params[:id].to_i) | post = Post.find(params[:id].to_i) | ||||
| tags = post.tags.where(category: 'nico').to_a + Tag.normalise_tags(tag_names) | tags = post.tags.where(category: 'nico').to_a + Tag.normalise_tags(tag_names) | ||||
| if post.update(title:, tags:) | |||||
| if post.update(title:, tags:, original_created_from:, original_created_before:) | |||||
| render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }), | render json: post.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }), | ||||
| status: :ok | status: :ok | ||||
| else | else | ||||
| @@ -15,6 +15,8 @@ class Post < ApplicationRecord | |||||
| foreign_key: :target_post_id | foreign_key: :target_post_id | ||||
| has_one_attached :thumbnail | has_one_attached :thumbnail | ||||
| validate :validate_original_created_range | |||||
| def as_json options = { } | def as_json options = { } | ||||
| super(options).merge({ thumbnail: thumbnail.attached? ? | super(options).merge({ thumbnail: thumbnail.attached? ? | ||||
| Rails.application.routes.url_helpers.rails_blob_url( | Rails.application.routes.url_helpers.rails_blob_url( | ||||
| @@ -49,4 +51,20 @@ class Post < ApplicationRecord | |||||
| filename: 'resized_thumbnail.jpg', | filename: 'resized_thumbnail.jpg', | ||||
| content_type: 'image/jpeg') | content_type: 'image/jpeg') | ||||
| end | end | ||||
| private | |||||
| def validate_original_created_range | |||||
| f = original_created_from | |||||
| b = original_created_before | |||||
| return if f.blank? || b.blank? | |||||
| f = Time.zone.parse(f) if String === f | |||||
| b = Time.zone.parse(b) if String === b | |||||
| return if !(f) || !(b) | |||||
| if f >= b | |||||
| errors.add :original_created_before, 'オリジナルの作成日時の順番がをかしぃです.' | |||||
| end | |||||
| end | |||||
| end | end | ||||
| @@ -0,0 +1,6 @@ | |||||
| class AddOriginalCreatedAtToPosts < ActiveRecord::Migration[8.0] | |||||
| def change | |||||
| add_column :posts, :original_created_from, :datetime, after: :created_at | |||||
| add_column :posts, :original_created_before, :datetime, after: :original_created_from | |||||
| end | |||||
| end | |||||
| @@ -10,7 +10,7 @@ | |||||
| # | # | ||||
| # It's strongly recommended that you check this file into your version control system. | # It's strongly recommended that you check this file into your version control system. | ||||
| ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do | |||||
| ActiveRecord::Schema[8.0].define(version: 2025_09_09_075500) do | |||||
| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | ||||
| t.string "name", null: false | t.string "name", null: false | ||||
| t.string "record_type", null: false | t.string "record_type", null: false | ||||
| @@ -83,6 +83,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_07_29_210600) do | |||||
| t.bigint "parent_id" | t.bigint "parent_id" | ||||
| t.bigint "uploaded_user_id" | t.bigint "uploaded_user_id" | ||||
| t.datetime "created_at", null: false | t.datetime "created_at", null: false | ||||
| t.datetime "original_created_from" | |||||
| t.datetime "original_created_before" | |||||
| t.datetime "updated_at", null: false | t.datetime "updated_at", null: false | ||||
| t.index ["parent_id"], name: "index_posts_on_parent_id" | t.index ["parent_id"], name: "index_posts_on_parent_id" | ||||
| t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" | t.index ["uploaded_user_id"], name: "index_posts_on_uploaded_user_id" | ||||
| @@ -3,6 +3,7 @@ import toCamel from 'camelcase-keys' | |||||
| import { useState } from 'react' | import { useState } from 'react' | ||||
| import PostFormTagsArea from '@/components/PostFormTagsArea' | import PostFormTagsArea from '@/components/PostFormTagsArea' | ||||
| import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | |||||
| import Label from '@/components/common/Label' | import Label from '@/components/common/Label' | ||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { API_BASE_URL } from '@/config' | import { API_BASE_URL } from '@/config' | ||||
| @@ -16,6 +17,10 @@ type Props = { post: Post | |||||
| export default (({ post, onSave }: Props) => { | export default (({ post, onSave }: Props) => { | ||||
| const [originalCreatedBefore, setOriginalCreatedBefore] = | |||||
| useState<string | null> (post.originalCreatedBefore) | |||||
| const [originalCreatedFrom, setOriginalCreatedFrom] = | |||||
| useState<string | null> (post.originalCreatedFrom) | |||||
| const [title, setTitle] = useState (post.title) | const [title, setTitle] = useState (post.title) | ||||
| const [tags, setTags] = useState<string> (post.tags | const [tags, setTags] = useState<string> (post.tags | ||||
| .filter (t => t.category !== 'nico') | .filter (t => t.category !== 'nico') | ||||
| @@ -23,13 +28,19 @@ export default (({ post, onSave }: Props) => { | |||||
| .join (' ')) | .join (' ')) | ||||
| const handleSubmit = async () => { | const handleSubmit = async () => { | ||||
| const res = await axios.put (`${ API_BASE_URL }/posts/${ post.id }`, { title, tags }, | |||||
| const res = await axios.put ( | |||||
| `${ API_BASE_URL }/posts/${ post.id }`, | |||||
| { title, tags, | |||||
| original_created_from: originalCreatedFrom, | |||||
| original_created_before: originalCreatedBefore }, | |||||
| { headers: { 'Content-Type': 'multipart/form-data', | { headers: { 'Content-Type': 'multipart/form-data', | ||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } } ) | |||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | |||||
| const data = toCamel (res.data as any, { deep: true }) as Post | const data = toCamel (res.data as any, { deep: true }) as Post | ||||
| onSave ({ ...post, | onSave ({ ...post, | ||||
| title: data.title, | |||||
| tags: data.tags } as Post) | |||||
| title: data.title, | |||||
| tags: data.tags, | |||||
| originalCreatedFrom: data.originalCreatedFrom, | |||||
| originalCreatedBefore: data.originalCreatedBefore } as Post) | |||||
| } | } | ||||
| return ( | return ( | ||||
| @@ -40,12 +51,19 @@ export default (({ post, onSave }: Props) => { | |||||
| <input type="text" | <input type="text" | ||||
| className="w-full border rounded p-2" | className="w-full border rounded p-2" | ||||
| value={title} | value={title} | ||||
| onChange={e => setTitle (e.target.value)}/> | |||||
| onChange={ev => setTitle (ev.target.value)}/> | |||||
| </div> | </div> | ||||
| {/* タグ */} | {/* タグ */} | ||||
| <PostFormTagsArea tags={tags} setTags={setTags}/> | <PostFormTagsArea tags={tags} setTags={setTags}/> | ||||
| {/* オリジナルの作成日時 */} | |||||
| <PostOriginalCreatedTimeField | |||||
| originalCreatedFrom={originalCreatedFrom} | |||||
| setOriginalCreatedFrom={setOriginalCreatedFrom} | |||||
| originalCreatedBefore={originalCreatedBefore} | |||||
| setOriginalCreatedBefore={setOriginalCreatedBefore}/> | |||||
| {/* 送信 */} | {/* 送信 */} | ||||
| <Button onClick={handleSubmit} | <Button onClick={handleSubmit} | ||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | ||||
| @@ -0,0 +1,49 @@ | |||||
| import DateTimeField from '@/components/common/DateTimeField' | |||||
| import Label from '@/components/common/Label' | |||||
| import type { FC } from 'react' | |||||
| type Props = { | |||||
| originalCreatedFrom: string | null | |||||
| setOriginalCreatedFrom: (x: string | null) => void | |||||
| originalCreatedBefore: string | null | |||||
| setOriginalCreatedBefore: (x: string | null) => void } | |||||
| export default (({ originalCreatedFrom, | |||||
| setOriginalCreatedFrom, | |||||
| originalCreatedBefore, | |||||
| setOriginalCreatedBefore }: Props) => ( | |||||
| <div> | |||||
| <Label>オリジナルの作成日時</Label> | |||||
| <div className="my-1"> | |||||
| <DateTimeField | |||||
| className="mr-2" | |||||
| value={originalCreatedFrom ?? undefined} | |||||
| onChange={setOriginalCreatedFrom} | |||||
| onBlur={ev => { | |||||
| const v = ev.target.value | |||||
| if (!(v)) | |||||
| return | |||||
| const d = new Date (v) | |||||
| if (d.getSeconds () === 0) | |||||
| { | |||||
| if (d.getMinutes () === 0 && d.getHours () === 0) | |||||
| d.setDate (d.getDate () + 1) | |||||
| else | |||||
| d.setMinutes (d.getMinutes () + 1) | |||||
| } | |||||
| else | |||||
| d.setSeconds (d.getSeconds () + 1) | |||||
| setOriginalCreatedBefore (d.toISOString ()) | |||||
| }}/> | |||||
| 以降 | |||||
| </div> | |||||
| <div className="my-1"> | |||||
| <DateTimeField | |||||
| className="mr-2" | |||||
| value={originalCreatedBefore ?? undefined} | |||||
| onChange={setOriginalCreatedBefore}/> | |||||
| より前 | |||||
| </div> | |||||
| </div>)) satisfies FC<Props> | |||||
| @@ -0,0 +1,48 @@ | |||||
| import { useEffect, useState } from 'react' | |||||
| import { cn } from '@/lib/utils' | |||||
| import type { FC, FocusEvent } from 'react' | |||||
| const pad = (n: number) => n.toString ().padStart (2, '0') | |||||
| const toDateTimeLocalValue = (d: Date) => { | |||||
| const y = d.getFullYear () | |||||
| const m = pad (d.getMonth () + 1) | |||||
| const day = pad (d.getDate ()) | |||||
| const h = pad (d.getHours ()) | |||||
| const min = pad (d.getMinutes ()) | |||||
| const s = pad (d.getSeconds ()) | |||||
| return `${ y }-${ m }-${ day }T${ h }:${ min }:${ s }` | |||||
| } | |||||
| type Props = { | |||||
| value?: string | |||||
| onChange?: (isoUTC: string | null) => void | |||||
| className?: string | |||||
| onBlur?: (ev: FocusEvent<HTMLInputElement>) => void } | |||||
| export default (({ value, onChange, className, onBlur }: Props) => { | |||||
| const [local, setLocal] = useState ('') | |||||
| useEffect (() => { | |||||
| setLocal (value ? toDateTimeLocalValue (new Date (value)) : '') | |||||
| }, [value]) | |||||
|
miteruzo marked this conversation as resolved
|
|||||
| return ( | |||||
| <input | |||||
| className={cn ('border rounded p-2', className)} | |||||
| type="datetime-local" | |||||
| step={1} | |||||
| value={local} | |||||
| onChange={ev => { | |||||
| const v = ev.target.value | |||||
| setLocal (v) | |||||
| onChange?.(v ? (new Date (v)).toISOString () : null) | |||||
| }} | |||||
| onBlur={onBlur}/>) | |||||
| }) satisfies FC<Props> | |||||
| @@ -4,6 +4,7 @@ import { Helmet } from 'react-helmet-async' | |||||
| import { useNavigate } from 'react-router-dom' | import { useNavigate } from 'react-router-dom' | ||||
| import PostFormTagsArea from '@/components/PostFormTagsArea' | import PostFormTagsArea from '@/components/PostFormTagsArea' | ||||
| import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | |||||
| import Form from '@/components/common/Form' | import Form from '@/components/common/Form' | ||||
| import Label from '@/components/common/Label' | import Label from '@/components/common/Label' | ||||
| import PageTitle from '@/components/common/PageTitle' | import PageTitle from '@/components/common/PageTitle' | ||||
| @@ -26,15 +27,17 @@ export default (({ user }: Props) => { | |||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| const [originalCreatedBefore, setOriginalCreatedBefore] = useState<string | null> (null) | |||||
| const [originalCreatedFrom, setOriginalCreatedFrom] = useState<string | null> (null) | |||||
| const [tags, setTags] = useState ('') | |||||
| const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) | |||||
| const [thumbnailFile, setThumbnailFile] = useState<File | null> (null) | |||||
| const [thumbnailLoading, setThumbnailLoading] = useState (false) | |||||
| const [thumbnailPreview, setThumbnailPreview] = useState<string> ('') | |||||
| const [title, setTitle] = useState ('') | const [title, setTitle] = useState ('') | ||||
| const [titleAutoFlg, setTitleAutoFlg] = useState (true) | const [titleAutoFlg, setTitleAutoFlg] = useState (true) | ||||
| const [titleLoading, setTitleLoading] = useState (false) | const [titleLoading, setTitleLoading] = useState (false) | ||||
| const [url, setURL] = useState ('') | const [url, setURL] = useState ('') | ||||
| const [thumbnailFile, setThumbnailFile] = useState<File | null> (null) | |||||
| const [thumbnailPreview, setThumbnailPreview] = useState<string> ('') | |||||
| const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) | |||||
| const [thumbnailLoading, setThumbnailLoading] = useState (false) | |||||
| const [tags, setTags] = useState ('') | |||||
| const previousURLRef = useRef ('') | const previousURLRef = useRef ('') | ||||
| @@ -45,6 +48,10 @@ export default (({ user }: Props) => { | |||||
| formData.append ('tags', tags) | formData.append ('tags', tags) | ||||
| if (thumbnailFile) | if (thumbnailFile) | ||||
| formData.append ('thumbnail', thumbnailFile) | formData.append ('thumbnail', thumbnailFile) | ||||
| if (originalCreatedFrom) | |||||
| formData.append ('original_created_from', originalCreatedFrom) | |||||
| if (originalCreatedBefore) | |||||
| formData.append ('original_created_before', originalCreatedBefore) | |||||
| try | try | ||||
| { | { | ||||
| @@ -122,7 +129,7 @@ export default (({ user }: Props) => { | |||||
| {/* URL */} | {/* URL */} | ||||
| <div> | <div> | ||||
| <Label>URL</Label> | <Label>URL</Label> | ||||
| <input type="text" | |||||
| <input type="url" | |||||
| placeholder="例:https://www.nicovideo.jp/watch/..." | placeholder="例:https://www.nicovideo.jp/watch/..." | ||||
| value={url} | value={url} | ||||
| onChange={e => setURL (e.target.value)} | onChange={e => setURL (e.target.value)} | ||||
| @@ -181,6 +188,13 @@ export default (({ user }: Props) => { | |||||
| {/* タグ */} | {/* タグ */} | ||||
| <PostFormTagsArea tags={tags} setTags={setTags}/> | <PostFormTagsArea tags={tags} setTags={setTags}/> | ||||
| {/* オリジナルの作成日時 */} | |||||
| <PostOriginalCreatedTimeField | |||||
| originalCreatedFrom={originalCreatedFrom} | |||||
| setOriginalCreatedFrom={setOriginalCreatedFrom} | |||||
| originalCreatedBefore={originalCreatedBefore} | |||||
| setOriginalCreatedBefore={setOriginalCreatedBefore}/> | |||||
| {/* 送信 */} | {/* 送信 */} | ||||
| <Button onClick={handleSubmit} | <Button onClick={handleSubmit} | ||||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400" | className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400" | ||||
| @@ -17,14 +17,17 @@ export type NicoTag = Tag & { | |||||
| linkedTags: Tag[] } | linkedTags: Tag[] } | ||||
| export type Post = { | export type Post = { | ||||
| id: number | |||||
| url: string | |||||
| title: string | |||||
| thumbnail: string | |||||
| thumbnailBase: string | |||||
| tags: Tag[] | |||||
| viewed: boolean | |||||
| related: Post[] } | |||||
| id: number | |||||
| url: string | |||||
| title: string | |||||
| thumbnail: string | |||||
| thumbnailBase: string | |||||
| tags: Tag[] | |||||
| viewed: boolean | |||||
| related: Post[] | |||||
| createdAt: string | |||||
| originalCreatedFrom: string | null | |||||
| originalCreatedBefore: string | null } | |||||
| export type SubMenuItem = { | export type SubMenuItem = { | ||||
| component: React.ReactNode | component: React.ReactNode | ||||
“以降”、“より前” までの距離が窮屈なので
mr-1欲しぃかも.