'#101'
into main
@@ -8,8 +8,11 @@ class PostsController < ApplicationController | |||
limit = params[:limit].presence&.to_i | |||
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 | |||
@@ -20,14 +23,14 @@ class PostsController < ApplicationController | |||
end | |||
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'] = | |||
if post.thumbnail.attached? | |||
rails_storage_proxy_url(post.thumbnail, only_path: false) | |||
else | |||
nil | |||
end | |||
} | |||
end | |||
}, next_cursor: } | |||
end | |||
@@ -39,7 +42,7 @@ class PostsController < ApplicationController | |||
render json: (post | |||
.as_json(include: { tags: { only: [:id, :name, :category, :post_count] } }) | |||
.merge(viewed: viewed)) | |||
.merge(viewed:)) | |||
end | |||
# GET /posts/1 | |||
@@ -60,14 +63,17 @@ class PostsController < ApplicationController | |||
return head :forbidden unless current_user.member? | |||
# TODO: URL が正規のものがチェック,不正ならエラー | |||
# TODO: title、URL は必須にする. | |||
# TODO: URL は必須にする(タイトルは省略可). | |||
# TODO: サイトに応じて thumbnail_base 設定 | |||
title = params[:title] | |||
url = params[:url] | |||
thumbnail = params[:thumbnail] | |||
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) | |||
if post.save | |||
post.resized_thumbnail! | |||
@@ -100,10 +106,12 @@ class PostsController < ApplicationController | |||
title = params[:title] | |||
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) | |||
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] } }), | |||
status: :ok | |||
else | |||
@@ -15,6 +15,8 @@ class Post < ApplicationRecord | |||
foreign_key: :target_post_id | |||
has_one_attached :thumbnail | |||
validate :validate_original_created_range | |||
def as_json options = { } | |||
super(options).merge({ thumbnail: thumbnail.attached? ? | |||
Rails.application.routes.url_helpers.rails_blob_url( | |||
@@ -49,4 +51,20 @@ class Post < ApplicationRecord | |||
filename: 'resized_thumbnail.jpg', | |||
content_type: 'image/jpeg') | |||
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 |
@@ -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. | |||
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| | |||
t.string "name", 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 "uploaded_user_id" | |||
t.datetime "created_at", null: false | |||
t.datetime "original_created_from" | |||
t.datetime "original_created_before" | |||
t.datetime "updated_at", null: false | |||
t.index ["parent_id"], name: "index_posts_on_parent_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 PostFormTagsArea from '@/components/PostFormTagsArea' | |||
import DateTimeField from '@/components/common/DateTimeField' | |||
import Label from '@/components/common/Label' | |||
import { Button } from '@/components/ui/button' | |||
import { API_BASE_URL } from '@/config' | |||
@@ -16,6 +17,10 @@ type Props = { post: Post | |||
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 [tags, setTags] = useState<string> (post.tags | |||
.filter (t => t.category !== 'nico') | |||
@@ -23,13 +28,19 @@ export default (({ post, onSave }: Props) => { | |||
.join (' ')) | |||
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', | |||
'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 | |||
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 ( | |||
@@ -40,12 +51,29 @@ export default (({ post, onSave }: Props) => { | |||
<input type="text" | |||
className="w-full border rounded p-2" | |||
value={title} | |||
onChange={e => setTitle (e.target.value)}/> | |||
onChange={ev => setTitle (ev.target.value)}/> | |||
</div> | |||
{/* タグ */} | |||
<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} | |||
className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||
@@ -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" | |||
|
|||
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> |
@@ -4,6 +4,7 @@ import { Helmet } from 'react-helmet-async' | |||
import { useNavigate } from 'react-router-dom' | |||
import PostFormTagsArea from '@/components/PostFormTagsArea' | |||
import DateTimeField from '@/components/common/DateTimeField' | |||
import Form from '@/components/common/Form' | |||
import Label from '@/components/common/Label' | |||
import PageTitle from '@/components/common/PageTitle' | |||
@@ -26,15 +27,17 @@ export default (({ user }: Props) => { | |||
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 [titleAutoFlg, setTitleAutoFlg] = useState (true) | |||
const [titleLoading, setTitleLoading] = useState (false) | |||
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 ('') | |||
@@ -45,6 +48,10 @@ export default (({ user }: Props) => { | |||
formData.append ('tags', tags) | |||
if (thumbnailFile) | |||
formData.append ('thumbnail', thumbnailFile) | |||
if (originalCreatedFrom) | |||
formData.append ('original_created_from', originalCreatedFrom) | |||
if (originalCreatedBefore) | |||
formData.append ('original_created_before', originalCreatedBefore) | |||
try | |||
{ | |||
@@ -122,7 +129,7 @@ export default (({ user }: Props) => { | |||
{/* URL */} | |||
<div> | |||
<Label>URL</Label> | |||
<input type="text" | |||
<input type="url" | |||
placeholder="例:https://www.nicovideo.jp/watch/..." | |||
value={url} | |||
onChange={e => setURL (e.target.value)} | |||
@@ -181,6 +188,23 @@ export default (({ user }: Props) => { | |||
{/* タグ */} | |||
<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} | |||
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[] } | |||
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 = { | |||
component: React.ReactNode | |||
“以降”、“より前” までの距離が窮屈なので
mr-1
欲しぃかも.