#117 feat: オリジナルの作成日時追加( #101 )

Open
みてるぞ wants to merge 2 commits from '#101' into main
  1. +16
    -8
      backend/app/controllers/posts_controller.rb
  2. +18
    -0
      backend/app/models/post.rb
  3. +6
    -0
      backend/db/migrate/20250909075500_add_original_created_at_to_posts.rb
  4. +3
    -1
      backend/db/schema.rb
  5. +31
    -3
      frontend/src/components/PostEditForm.tsx
  6. +43
    -0
      frontend/src/components/common/DateTimeField.tsx
  7. +30
    -6
      frontend/src/pages/posts/PostNewPage.tsx
  8. +4
    -1
      frontend/src/types.ts

+ 16
- 8
backend/app/controllers/posts_controller.rb View File

@@ -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


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

@@ -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

+ 6
- 0
backend/db/migrate/20250909075500_add_original_created_at_to_posts.rb View File

@@ -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

+ 3
- 1
backend/db/schema.rb View File

@@ -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"


+ 31
- 3
frontend/src/components/PostEditForm.tsx View File

@@ -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 DateTimeField from '@/components/common/DateTimeField'
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, title: data.title,
tags: data.tags } as Post)
tags: data.tags,
originalCreatedFrom: data.originalCreatedFrom,
originalCreatedBefore: data.originalCreatedBefore } as Post)
} }


return ( return (
@@ -40,12 +51,29 @@ 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}/>


{/* オリジナルの作成日時 */}
<div>
<Label>オリジナルの作成日時</Label>
<div className="my-1">
<DateTimeField
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}/>
以降
</div>
<div className="my-1">
<DateTimeField
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
より前
</div>
</div>

{/* 送信 */} {/* 送信 */}
<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">


+ 43
- 0
frontend/src/components/common/DateTimeField.tsx View File

@@ -0,0 +1,43 @@
import { useEffect, useState } from 'react'

import type { FC } 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 }


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

useEffect (() => {
setLocal (value ? toDateTimeLocalValue (new Date (value)) : '')
}, [value])

return (
<input
className="border rounded p-2"
みてるぞ commented 5 days ago
Review

“以降”、“より前” までの距離が窮屈なので mr-1 欲しぃかも.

“以降”、“より前” までの距離が窮屈なので `mr-1` 欲しぃかも.
type="datetime-local"
step={1}
value={local}
onChange={ev => {
const v = ev.target.value
setLocal (v)
onChange?.(v ? (new Date (v)).toISOString () : null)
}}/>)
}) satisfies FC<Props>

+ 30
- 6
frontend/src/pages/posts/PostNewPage.tsx View File

@@ -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 DateTimeField from '@/components/common/DateTimeField'
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,23 @@ export default (({ user }: Props) => {
{/* タグ */} {/* タグ */}
<PostFormTagsArea tags={tags} setTags={setTags}/> <PostFormTagsArea tags={tags} setTags={setTags}/>


{/* オリジナルの作成日時 */}
<div>
<Label>オリジナルの作成日時</Label>
<div className="my-1">
<DateTimeField
value={originalCreatedFrom ?? undefined}
onChange={setOriginalCreatedFrom}/>
以降
</div>
<div className="my-1">
<DateTimeField
value={originalCreatedBefore ?? undefined}
onChange={setOriginalCreatedBefore}/>
より前
</div>
</div>

{/* 送信 */} {/* 送信 */}
<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"


+ 4
- 1
frontend/src/types.ts View File

@@ -24,7 +24,10 @@ export type Post = {
thumbnailBase: string thumbnailBase: string
tags: Tag[] tags: Tag[]
viewed: boolean viewed: boolean
related: Post[] }
related: Post[]
createdAt: string
originalCreatedFrom: string | null
originalCreatedBefore: string | null }


export type SubMenuItem = { export type SubMenuItem = {
component: React.ReactNode component: React.ReactNode


Loading…
Cancel
Save