Merge branch 'main' into feature/142

このコミットが含まれているのは:
2025-12-26 00:29:44 +09:00
コミット 32bfd5828d
20個のファイルの変更744行の追加203行の削除
+2
ファイルの表示
@@ -63,3 +63,5 @@ gem 'diff-lcs'
gem 'dotenv-rails' gem 'dotenv-rails'
gem 'whenever', require: false gem 'whenever', require: false
gem 'discard'
+3
ファイルの表示
@@ -90,6 +90,8 @@ GEM
crass (1.0.6) crass (1.0.6)
date (3.4.1) date (3.4.1)
diff-lcs (1.6.2) diff-lcs (1.6.2)
discard (1.4.0)
activerecord (>= 4.2, < 9.0)
dotenv (3.1.8) dotenv (3.1.8)
dotenv-rails (3.1.8) dotenv-rails (3.1.8)
dotenv (= 3.1.8) dotenv (= 3.1.8)
@@ -420,6 +422,7 @@ DEPENDENCIES
bootsnap bootsnap
brakeman brakeman
diff-lcs diff-lcs
discard
dotenv-rails dotenv-rails
gollum gollum
image_processing (~> 1.14) image_processing (~> 1.14)
+77 -11
ファイルの表示
@@ -1,8 +1,6 @@
require 'open-uri'
require 'nokogiri'
class PostsController < ApplicationController class PostsController < ApplicationController
Event = Struct.new(:post, :tag, :user, :change_type, :timestamp, keyword_init: true)
# GET /posts # GET /posts
def index def index
limit = params[:limit].presence&.to_i limit = params[:limit].presence&.to_i
@@ -80,8 +78,9 @@ class PostsController < ApplicationController
post.thumbnail.attach(thumbnail) post.thumbnail.attach(thumbnail)
if post.save if post.save
post.resized_thumbnail! post.resized_thumbnail!
post.tags = Tag.normalise_tags(tag_names) tags = Tag.normalise_tags(tag_names)
post.tags = Tag.expand_parent_tags(post.tags) tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
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: :created status: :created
else else
@@ -114,10 +113,11 @@ class PostsController < ApplicationController
original_created_before = params[:original_created_before] 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 + if post.update(title:, original_created_from:, original_created_before:)
Tag.normalise_tags(tag_names, with_tagme: false) tags = post.tags.where(category: 'nico').to_a +
tags = Tag.expand_parent_tags(tags) Tag.normalise_tags(tag_names, with_tagme: false)
if post.update(title:, tags:, original_created_from:, original_created_before:) tags = Tag.expand_parent_tags(tags)
sync_post_tags!(post, tags)
json = post.as_json json = post.as_json
json['tags'] = build_tag_tree_for(post.tags) json['tags'] = build_tag_tree_for(post.tags)
render json:, status: :ok render json:, status: :ok
@@ -130,12 +130,54 @@ class PostsController < ApplicationController
def destroy def destroy
end end
def changes
id = params[:id]
page = (params[:page].presence || 1).to_i
limit = (params[:limit].presence || 20).to_i
page = 1 if page < 1
limit = 1 if limit < 1
offset = (page - 1) * limit
pts = PostTag.with_discarded
pts = pts.where(post_id: id) if id.present?
pts = pts.includes(:post, :tag, :created_user, :deleted_user)
events = []
pts.each do |pt|
events << Event.new(
post: pt.post,
tag: pt.tag,
user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name },
change_type: 'add',
timestamp: pt.created_at)
if pt.discarded_at
events << Event.new(
post: pt.post,
tag: pt.tag,
user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name },
change_type: 'remove',
timestamp: pt.discarded_at)
end
end
events.sort_by!(&:timestamp)
events.reverse!
render json: { changes: events.slice(offset, limit).as_json, count: events.size }
end
private private
def filtered_posts def filtered_posts
tag_names = params[:tags]&.split(' ') tag_names = params[:tags]&.split(' ')
match_type = params[:match] match_type = params[:match]
tag_names.present? ? filter_posts_by_tags(tag_names, match_type) : Post.all if tag_names.present?
filter_posts_by_tags(tag_names, match_type)
else
Post.all
end
end end
def filter_posts_by_tags tag_names, match_type def filter_posts_by_tags tag_names, match_type
@@ -150,6 +192,30 @@ class PostsController < ApplicationController
posts.distinct posts.distinct
end end
def sync_post_tags! post, desired_tags
desired_tags.each do |t|
t.save! if t.new_record?
end
desired_ids = desired_tags.map(&:id).to_set
current_ids = post.tags.pluck(:id).to_set
to_add = desired_ids - current_ids
to_remove = current_ids - desired_ids
Tag.where(id: to_add).find_each do |tag|
begin
PostTag.create!(post:, tag:, created_user: current_user)
rescue ActiveRecord::RecordNotUnique
;
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(current_user)
end
end
def build_tag_tree_for tags def build_tag_tree_for tags
tags = tags.to_a tags = tags.to_a
tag_ids = tags.map(&:id) tag_ids = tags.map(&:id)
+7 -5
ファイルの表示
@@ -1,11 +1,13 @@
require 'mini_magick'
class Post < ApplicationRecord class Post < ApplicationRecord
require 'mini_magick'
belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id' belongs_to :parent, class_name: 'Post', optional: true, foreign_key: 'parent_id'
belongs_to :uploaded_user, class_name: 'User', optional: true belongs_to :uploaded_user, class_name: 'User', optional: true
has_many :post_tags, dependent: :destroy
has_many :tags, through: :post_tags has_many :post_tags, dependent: :destroy, inverse_of: :post
has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :post
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :tags, through: :active_post_tags
has_many :user_post_views, dependent: :destroy has_many :user_post_views, dependent: :destroy
has_many :post_similarities_as_post, has_many :post_similarities_as_post,
class_name: 'PostSimilarity', class_name: 'PostSimilarity',
+18
ファイルの表示
@@ -1,7 +1,25 @@
class PostTag < ApplicationRecord class PostTag < ApplicationRecord
include Discard::Model
belongs_to :post belongs_to :post
belongs_to :tag, counter_cache: :post_count belongs_to :tag, counter_cache: :post_count
belongs_to :created_user, class_name: 'User', optional: true
belongs_to :deleted_user, class_name: 'User', optional: true
validates :post_id, presence: true validates :post_id, presence: true
validates :tag_id, presence: true validates :tag_id, presence: true
validates :post_id, uniqueness: {
scope: :tag_id,
conditions: -> { where(discarded_at: nil) } }
def discard_by! deleted_user
return self if discarded?
transaction do
update!(discarded_at: Time.current, deleted_user:)
Tag.where(id: tag_id).update_all('post_count = GREATEST(post_count - 1, 0)')
end
self
end
end end
+6 -4
ファイルの表示
@@ -1,6 +1,8 @@
class Tag < ApplicationRecord class Tag < ApplicationRecord
has_many :post_tags, dependent: :destroy has_many :post_tags, dependent: :delete_all, inverse_of: :tag
has_many :posts, through: :post_tags has_many :active_post_tags, -> { kept }, class_name: 'PostTag', inverse_of: :tag
has_many :post_tags_with_discarded, -> { with_discarded }, class_name: 'PostTag'
has_many :posts, through: :active_post_tags
has_many :tag_aliases, dependent: :destroy has_many :tag_aliases, dependent: :destroy
has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy has_many :nico_tag_relations, foreign_key: :nico_tag_id, dependent: :destroy
@@ -43,13 +45,13 @@ class Tag < ApplicationRecord
'meta:' => 'meta' }.freeze 'meta:' => 'meta' }.freeze
def self.tagme def self.tagme
@tagme ||= Tag.find_or_initialize_by(name: 'タグ希望') do |tag| @tagme ||= Tag.find_or_create_by!(name: 'タグ希望') do |tag|
tag.category = 'meta' tag.category = 'meta'
end end
end end
def self.bot def self.bot
@bot ||= Tag.find_or_initialize_by(name: 'bot操作') do |tag| @bot ||= Tag.find_or_create_by!(name: 'bot操作') do |tag|
tag.category = 'meta' tag.category = 'meta'
end end
end end
+1
ファイルの表示
@@ -4,6 +4,7 @@ Rails.application.routes.draw do
get 'tags/autocomplete', to: 'tags#autocomplete' get 'tags/autocomplete', to: 'tags#autocomplete'
get 'tags/name/:name', to: 'tags#show_by_name' get 'tags/name/:name', to: 'tags#show_by_name'
get 'posts/random', to: 'posts#random' get 'posts/random', to: 'posts#random'
get 'posts/changes', to: 'posts#changes'
post 'posts/:id/viewed', to: 'posts#viewed' post 'posts/:id/viewed', to: 'posts#viewed'
delete 'posts/:id/viewed', to: 'posts#unviewed' delete 'posts/:id/viewed', to: 'posts#unviewed'
get 'preview/title', to: 'preview#title' get 'preview/title', to: 'preview#title'
+36
ファイルの表示
@@ -0,0 +1,36 @@
class AddDiscardToPostTags < ActiveRecord::Migration[8.0]
def up
execute <<~SQL
DELETE
pt1
FROM
post_tags pt1
INNER JOIN
post_tags pt2
ON
pt1.post_id = pt2.post_id
AND pt1.tag_id = pt2.tag_id
AND pt1.id > pt2.id
;
SQL
add_column :post_tags, :discarded_at, :datetime
add_index :post_tags, :discarded_at
add_column :post_tags, :is_active, :boolean,
as: 'discarded_at IS NULL', stored: true
add_column :post_tags, :active_unique_key, :string,
as: "CASE WHEN discarded_at IS NULL THEN CONCAT(post_id, ':', tag_id) ELSE NULL END",
stored: true
add_index :post_tags, :active_unique_key, unique: true, name: 'idx_post_tags_active_unique'
add_index :post_tags, [:post_id, :discarded_at]
add_index :post_tags, [:tag_id, :discarded_at]
end
def down
raise ActiveRecord::IrreversibleMigration, '戻せません.'
end
end
+70 -36
ファイルの表示
@@ -5,12 +5,32 @@ namespace :nico do
require 'open-uri' require 'open-uri'
require 'nokogiri' require 'nokogiri'
fetch_thumbnail = -> url { fetch_thumbnail = -> url do
html = URI.open(url, read_timeout: 60, 'User-Agent' => 'Mozilla/5.0').read html = URI.open(url, read_timeout: 60, 'User-Agent' => 'Mozilla/5.0').read
doc = Nokogiri::HTML(html) doc = Nokogiri::HTML(html)
doc.at('meta[name="thumbnail"]')&.[]('content').presence doc.at('meta[name="thumbnail"]')&.[]('content').presence
} end
def sync_post_tags! post, desired_tag_ids
desired_ids = desired_tag_ids.compact.to_set
current_ids = post.tags.pluck(:id).to_set
to_add = desired_ids - current_ids
to_remove = current_ids - desired_ids
Tag.where(id: to_add.to_a).find_each do |tag|
begin
PostTag.create!(post:, tag:)
rescue ActiveRecord::RecordNotUnique
;
end
end
PostTag.where(post_id: post.id, tag_id: to_remove.to_a).kept.find_each do |pt|
pt.discard_by!(nil)
end
end
mysql_user = ENV['MYSQL_USER'] mysql_user = ENV['MYSQL_USER']
mysql_pass = ENV['MYSQL_PASS'] mysql_pass = ENV['MYSQL_PASS']
@@ -19,43 +39,57 @@ namespace :nico do
{ 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass }, { 'MYSQL_USER' => mysql_user, 'MYSQL_PASS' => mysql_pass },
'python3', "#{ nizika_nico_path }/get_videos.py") 'python3', "#{ nizika_nico_path }/get_videos.py")
if status.success? abort unless status.success?
data = JSON.parse(stdout)
data.each do |datum|
post = Post.where('url LIKE ?', '%nicovideo.jp%').find { |post|
post.url =~ %r{#{ Regexp.escape(datum['code']) }(?!\d)}
}
unless post
title = datum['title']
url = "https://www.nicovideo.jp/watch/#{ datum['code'] }"
thumbnail_base = fetch_thumbnail.(url) || '' rescue ''
post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil)
if thumbnail_base.present?
post.thumbnail.attach(
io: URI.open(thumbnail_base),
filename: File.basename(URI.parse(thumbnail_base).path),
content_type: 'image/jpeg')
end
post.save!
post.resized_thumbnail!
end
current_tags = post.tags.where(category: 'nico').pluck(:name).sort data = JSON.parse(stdout)
new_tags = datum['tags'].map { |tag| "nico:#{ tag }" }.sort data.each do |datum|
if current_tags != new_tags post = Post.where('url LIKE ?', '%nicovideo.jp%').find { |post|
post.tags.destroy(post.tags.where(name: current_tags)) post.url =~ %r{#{ Regexp.escape(datum['code']) }(?!\d)}
tags_to_add = [] }
new_tags.each do |name| unless post
tag = Tag.find_or_initialize_by(name:) do |t| title = datum['title']
t.category = 'nico' url = "https://www.nicovideo.jp/watch/#{ datum['code'] }"
end thumbnail_base = fetch_thumbnail.(url) || '' rescue ''
tags_to_add.concat([tag] + tag.linked_tags) post = Post.new(title:, url:, thumbnail_base:, uploaded_user: nil)
end if thumbnail_base.present?
tags_to_add << Tag.tagme if post.tags.size < 10 post.thumbnail.attach(
tags_to_add << Tag.bot io: URI.open(thumbnail_base),
post.tags = (post.tags + tags_to_add).uniq filename: File.basename(URI.parse(thumbnail_base).path),
content_type: 'image/jpeg')
end
post.save!
post.resized_thumbnail!
sync_post_tags!(post, [Tag.tagme.id])
end
kept_tags = post.tags.reload
kept_non_nico_ids = kept_tags.where.not(category: 'nico').pluck(:id).to_set
desired_nico_ids = []
desired_non_nico_ids = []
datum['tags'].each do |raw|
name = "nico:#{ raw }"
tag = Tag.find_or_initialize_by(name:) do |t|
t.category = 'nico'
end
tag.save! if tag.new_record?
desired_nico_ids << tag.id
unless tag.in?(kept_tags)
desired_non_nico_ids.concat(tag.linked_tags.pluck(:id))
desired_nico_ids.concat(tag.linked_tags.pluck(:id))
end end
end end
desired_nico_ids.uniq!
desired_all_ids = kept_non_nico_ids.to_a + desired_nico_ids
desired_non_nico_ids.concat(kept_non_nico_ids.to_a)
desired_non_nico_ids.uniq!
if kept_non_nico_ids.to_set != desired_non_nico_ids.to_set
desired_all_ids << Tag.bot.id
end
desired_all_ids.uniq!
sync_post_tags!(post, desired_all_ids)
end end
end end
end end
生成ファイル
+43
ファイルの表示
@@ -17,6 +17,7 @@
"camelcase-keys": "^9.1.3", "camelcase-keys": "^9.1.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.23.26",
"humps": "^2.0.1", "humps": "^2.0.1",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
@@ -3573,6 +3574,33 @@
"url": "https://github.com/sponsors/rawify" "url": "https://github.com/sponsors/rawify"
} }
}, },
"node_modules/framer-motion": {
"version": "12.23.26",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
"integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
"license": "MIT",
"dependencies": {
"motion-dom": "^12.23.23",
"motion-utils": "^12.23.6",
"tslib": "^2.4.0"
},
"peerDependencies": {
"@emotion/is-prop-valid": "*",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -5216,6 +5244,21 @@
"node": ">=16 || 14 >=14.17" "node": ">=16 || 14 >=14.17"
} }
}, },
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+1
ファイルの表示
@@ -19,6 +19,7 @@
"camelcase-keys": "^9.1.3", "camelcase-keys": "^9.1.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"framer-motion": "^12.23.26",
"humps": "^2.0.1", "humps": "^2.0.1",
"lucide-react": "^0.511.0", "lucide-react": "^0.511.0",
"markdown-it": "^14.1.0", "markdown-it": "^14.1.0",
+2 -3
ファイルの表示
@@ -19,7 +19,6 @@ const fetchPosts = async tagName => (await axios.get (`${ API_BASE_URL }/posts`,
{ params: { ...(tagName && { tags: tagName, { params: { ...(tagName && { tags: tagName,
match: 'all', match: 'all',
limit: '20' }) } })).data.posts limit: '20' }) } })).data.posts
const fetchPostIds = async () => (await fetchPosts ()).map (post => post.id)
const fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data const fetchTags = async () => (await axios.get (`${ API_BASE_URL }/tags`)).data
const fetchTagNames = async () => (await fetchTags ()).map (tag => tag.name) const fetchTagNames = async () => (await fetchTags ()).map (tag => tag.name)
@@ -33,7 +32,7 @@ const createPostListOutlet = async tagName => `
<div class="flex gap-4"><a href="#" class="font-bold">広場</a></div> <div class="flex gap-4"><a href="#" class="font-bold">広場</a></div>
<div class="mt-2"> <div class="mt-2">
<div class="flex flex-wrap gap-6 p-4"> <div class="flex flex-wrap gap-6 p-4">
${ (await fetchPosts (tagName)).map (post => ` ${ (await fetchPosts (tagName)).slice (0, 20).map (post => `
<a class="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg" <a class="w-40 h-40 overflow-hidden rounded-lg shadow-md hover:shadow-lg"
href="/posts/${ post.id }"> href="/posts/${ post.id }">
<img alt="${ post.title }" <img alt="${ post.title }"
@@ -42,7 +41,7 @@ const createPostListOutlet = async tagName => `
fetchpriority="high" fetchpriority="high"
decoding="async" decoding="async"
class="object-none w-full h-full" class="object-none w-full h-full"
src="${ post.url }" /> src="${ post.thumbnail }" />
</a>`).join ('') } </a>`).join ('') }
</div> </div>
</div> </div>
+2
ファイルの表示
@@ -9,6 +9,7 @@ import { API_BASE_URL } from '@/config'
import NicoTagListPage from '@/pages/tags/NicoTagListPage' import NicoTagListPage from '@/pages/tags/NicoTagListPage'
import NotFound from '@/pages/NotFound' import NotFound from '@/pages/NotFound'
import PostDetailPage from '@/pages/posts/PostDetailPage' import PostDetailPage from '@/pages/posts/PostDetailPage'
import PostHistoryPage from '@/pages/posts/PostHistoryPage'
import PostListPage from '@/pages/posts/PostListPage' import PostListPage from '@/pages/posts/PostListPage'
import PostNewPage from '@/pages/posts/PostNewPage' import PostNewPage from '@/pages/posts/PostNewPage'
import ServiceUnavailable from '@/pages/ServiceUnavailable' import ServiceUnavailable from '@/pages/ServiceUnavailable'
@@ -79,6 +80,7 @@ export default (() => {
<Route path="/posts" element={<PostListPage/>}/> <Route path="/posts" element={<PostListPage/>}/>
<Route path="/posts/new" element={<PostNewPage user={user}/>}/> <Route path="/posts/new" element={<PostNewPage user={user}/>}/>
<Route path="/posts/:id" element={<PostDetailPage user={user}/>}/> <Route path="/posts/:id" element={<PostDetailPage user={user}/>}/>
<Route path="/posts/changes" element={<PostHistoryPage/>}/>
<Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/> <Route path="/tags/nico" element={<NicoTagListPage user={user}/>}/>
<Route path="/wiki" element={<WikiSearchPage/>}/> <Route path="/wiki" element={<WikiSearchPage/>}/>
<Route path="/wiki/:title" element={<WikiDetailPage/>}/> <Route path="/wiki/:title" element={<WikiDetailPage/>}/>
+67 -56
ファイルの表示
@@ -1,3 +1,4 @@
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import TagLink from '@/components/TagLink' import TagLink from '@/components/TagLink'
@@ -18,18 +19,23 @@ const renderTagTree = (
tag: Tag, tag: Tag,
nestLevel: number, nestLevel: number,
path: string, path: string,
): ReactNode[] => { ): ReactNode[] => {
const key = `${ path }-${ tag.id }` const key = `${ path }-${ tag.id }`
const self = ( const self = (
<li key={key} className="mb-1"> <motion.li
key={key}
layout
transition={{ duration: .2, ease: 'easeOut' }}
className="mb-1">
<TagLink tag={tag} nestLevel={nestLevel}/> <TagLink tag={tag} nestLevel={nestLevel}/>
</li>) </motion.li>)
return [self, return [self,
...(tag.children ...((tag.children
?.sort ((a, b) => a.name < b.name ? -1 : 1) ?.sort ((a, b) => a.name < b.name ? -1 : 1)
.flatMap (child => renderTagTree (child, nestLevel + 1, key)) ?? [])] .flatMap (child => renderTagTree (child, nestLevel + 1, key)))
?? [])]
} }
@@ -70,55 +76,60 @@ export default (({ post }: Props) => {
return ( return (
<SidebarComponent> <SidebarComponent>
<TagSearch/> <TagSearch/>
{CATEGORIES.map ((cat: Category) => cat in tags && ( <motion.div key={post?.id ?? 0} layout>
<div className="my-3" key={cat}> {CATEGORIES.map ((cat: Category) => cat in tags && (
<SubsectionTitle>{categoryNames[cat]}</SubsectionTitle> <motion.div layout className="my-3" key={cat}>
<ul> <SubsectionTitle>{categoryNames[cat]}</SubsectionTitle>
{tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))}
</ul> <motion.ul layout>
</div>))} <AnimatePresence initial={false}>
{post && ( {tags[cat].map (tag => renderTagTree (tag, 0, `cat-${ cat }`))}
<div> </AnimatePresence>
<SectionTitle></SectionTitle> </motion.ul>
<ul> </motion.div>))}
<li>Id.: {post.id}</li> {post && (
{/* TODO: uploadedUser の取得を対応したらコメント外す */} <div>
{/* <SectionTitle></SectionTitle>
<li> <ul>
<>耕作者: </> <li>Id.: {post.id}</li>
{post.uploadedUser {/* TODO: uploadedUser の取得を対応したらコメント外す */}
? ( {/*
<Link to={`/users/${ post.uploadedUser.id }`}> <li>
{post.uploadedUser.name || '名もなきニジラー'} <>耕作者: </>
</Link>) {post.uploadedUser
: 'bot操作'} ? (
</li> <Link to={`/users/${ post.uploadedUser.id }`}>
*/} {post.uploadedUser.name || '名もなきニジラー'}
<li>: {(new Date (post.createdAt)).toLocaleString ()}</li> </Link>)
<li> : 'bot操作'}
<>: </> </li>
<a */}
className="break-all" <li>: {(new Date (post.createdAt)).toLocaleString ()}</li>
href={post.url} <li>
target="_blank" <>: </>
rel="noopener noreferrer nofollow"> <a
{post.url} className="break-all"
</a> href={post.url}
</li> target="_blank"
<li> rel="noopener noreferrer nofollow">
{/* TODO: 表示形式きしょすぎるので何とかする */} {post.url}
<>稿: </> </a>
{!(post.originalCreatedFrom) && !(post.originalCreatedBefore) </li>
? '不明' <li>
: ( {/* TODO: 表示形式きしょすぎるので何とかする */}
<> <>稿: </>
{post.originalCreatedFrom {!(post.originalCreatedFrom) && !(post.originalCreatedBefore)
&& `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `} ? '不明'
{post.originalCreatedBefore : (
&& `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`} <>
</>)} {post.originalCreatedFrom
</li> && `${ (new Date (post.originalCreatedFrom)).toLocaleString () } 以降 `}
</ul> {post.originalCreatedBefore
</div>)} && `${ (new Date (post.originalCreatedBefore)).toLocaleString () } より前`}
</>)}
</li>
</ul>
</div>)}
</motion.div>
</SidebarComponent>) </SidebarComponent>)
}) satisfies FC<Props> }) satisfies FC<Props>
+56 -32
ファイルの表示
@@ -1,4 +1,5 @@
import axios from 'axios' import axios from 'axios'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom' import { useLocation, useNavigate } from 'react-router-dom'
@@ -8,7 +9,6 @@ import SectionTitle from '@/components/common/SectionTitle'
import SidebarComponent from '@/components/layout/SidebarComponent' import SidebarComponent from '@/components/layout/SidebarComponent'
import { API_BASE_URL } from '@/config' import { API_BASE_URL } from '@/config'
import { CATEGORIES } from '@/consts' import { CATEGORIES } from '@/consts'
import { cn } from '@/lib/utils'
import type { FC } from 'react' import type { FC } from 'react'
@@ -58,47 +58,71 @@ export default (({ posts }: Props) => {
setTags (tagsTmp) setTags (tagsTmp)
}, [posts]) }, [posts])
const TagBlock = (
<>
<SectionTitle></SectionTitle>
<ul>
{CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => (
<li key={tag.id} className="mb-1">
<TagLink tag={tag}/>
</li>))) : [])}
</ul>
<SectionTitle></SectionTitle>
{posts.length > 0 && (
<a href="#"
onClick={ev => {
ev.preventDefault ()
void ((async () => {
try
{
const { data } = await axios.get (`${ API_BASE_URL }/posts/random`,
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
match: (anyFlg ? 'any' : 'all') } })
navigate (`/posts/${ (data as Post).id }`)
}
catch
{
;
}
}) ())
}}>
</a>)}
</>)
return ( return (
<SidebarComponent> <SidebarComponent>
<TagSearch/> <TagSearch/>
<div className={cn (!(tagsVsbl) && 'hidden', 'md:block mt-4')}>
<SectionTitle></SectionTitle> <div className="hidden md:block mt-4">
<ul> {TagBlock}
{CATEGORIES.flatMap (cat => cat in tags ? (
tags[cat].map (tag => (
<li key={tag.id} className="mb-1">
<TagLink tag={tag}/>
</li>))) : [])}
</ul>
<SectionTitle></SectionTitle>
{posts.length > 0 && (
<a href="#"
onClick={ev => {
ev.preventDefault ()
void ((async () => {
try
{
const { data } = await axios.get (`${ API_BASE_URL }/posts/random`,
{ params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '),
match: (anyFlg ? 'any' : 'all') } })
navigate (`/posts/${ (data as Post).id }`)
}
catch
{
;
}
}) ())
}}>
</a>)}
</div> </div>
<AnimatePresence initial={false}>
{tagsVsbl && (
<motion.div
key="sptags"
className="md:hidden mt-4"
variants={{ hidden: { clipPath: 'inset(0 0 100% 0)',
height: 0 },
visible: { clipPath: 'inset(0 0 0% 0)',
height: 'auto'} }}
initial="hidden"
animate="visible"
exit="hidden"
transition={{ duration: .2, ease: 'easeOut' }}>
{TagBlock}
</motion.div>)}
</AnimatePresence>
<a href="#" <a href="#"
className="md:hidden block my-2 text-center text-sm className="md:hidden block my-2 text-center text-sm
text-gray-500 hover:text-gray-400 text-gray-500 hover:text-gray-400
dark:text-gray-300 dark:hover:text-gray-100" dark:text-gray-300 dark:hover:text-gray-100"
onClick={ev => { onClick={ev => {
ev.preventDefault () ev.preventDefault ()
setTagsVsbl (!(tagsVsbl)) setTagsVsbl (v => !(v))
}}> }}>
{tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'} {tagsVsbl ? '▲▲▲ タグ一覧を閉じる ▲▲▲' : '▼▼▼ タグ一覧を表示 ▼▼▼'}
</a> </a>
+165 -53
ファイルの表示
@@ -1,6 +1,7 @@
import axios from 'axios' import axios from 'axios'
import toCamel from 'camelcase-keys' import toCamel from 'camelcase-keys'
import { Fragment, useState, useEffect } from 'react' import { AnimatePresence, motion } from 'framer-motion'
import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react'
import { Link, useLocation } from 'react-router-dom' import { Link, useLocation } from 'react-router-dom'
import Separator from '@/components/MenuSeparator' import Separator from '@/components/MenuSeparator'
@@ -19,6 +20,28 @@ type Props = { user: User | null }
export default (({ user }: Props) => { export default (({ user }: Props) => {
const location = useLocation () const location = useLocation ()
const dirRef = useRef<(-1) | 1> (1)
const itemsRef = useRef<(HTMLAnchorElement | null)[]> ([])
const navRef = useRef<HTMLDivElement | null> (null)
const measure = () => {
const nav = navRef.current
const el = itemsRef.current[activeIdx]
if (!(nav) || !(el) || activeIdx < 0)
return
const navRect = nav.getBoundingClientRect ()
const elRect = el.getBoundingClientRect ()
setHl ({ left: elRect.left - navRect.left,
width: elRect.width,
visible: true })
}
const [hl, setHl] = useState<{ left: number; width: number; visible: boolean }> ({
left: 0,
width: 0,
visible: false })
const [menuOpen, setMenuOpen] = useState (false) const [menuOpen, setMenuOpen] = useState (false)
const [openItemIdx, setOpenItemIdx] = useState (-1) const [openItemIdx, setOpenItemIdx] = useState (-1)
const [postCount, setPostCount] = useState<number | null> (null) const [postCount, setPostCount] = useState<number | null> (null)
@@ -30,6 +53,7 @@ export default (({ user }: Props) => {
{ name: '広場', to: '/posts', subMenu: [ { name: '広場', to: '/posts', subMenu: [
{ name: '一覧', to: '/posts' }, { name: '一覧', to: '/posts' },
{ name: '投稿追加', to: '/posts/new' }, { name: '投稿追加', to: '/posts/new' },
{ name: '耕作履歴', to: '/posts/changes' },
{ name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] }, { name: 'ヘルプ', to: '/wiki/ヘルプ:広場' }] },
{ name: 'タグ', to: '/tags', subMenu: [ { name: 'タグ', to: '/tags', subMenu: [
{ name: 'タグ一覧', to: '/tags', visible: false }, { name: 'タグ一覧', to: '/tags', visible: false },
@@ -52,6 +76,32 @@ export default (({ user }: Props) => {
{ name: 'お前', to: `/users/${ user?.id }`, visible: false }, { name: 'お前', to: `/users/${ user?.id }`, visible: false },
{ name: '設定', to: '/users/settings', visible: Boolean (user) }] }] { name: '設定', to: '/users/settings', visible: Boolean (user) }] }]
const activeIdx = menu.findIndex (item => location.pathname.startsWith (item.base || item.to))
const prevActiveIdxRef = useRef<number> (activeIdx)
if (activeIdx !== prevActiveIdxRef.current)
{
dirRef.current = activeIdx > prevActiveIdxRef.current ? 1 : -1
prevActiveIdxRef.current = activeIdx
}
const dir = dirRef.current
useLayoutEffect (() => {
if (activeIdx < 0)
return
const raf = requestAnimationFrame (measure)
const onResize = () => requestAnimationFrame (measure)
addEventListener ('resize', onResize)
return () => {
cancelAnimationFrame (raf)
removeEventListener ('resize', onResize)
}
}, [activeIdx])
useEffect (() => { useEffect (() => {
const unsubscribe = WikiIdBus.subscribe (setWikiId) const unsubscribe = WikiIdBus.subscribe (setWikiId)
return () => unsubscribe () return () => unsubscribe ()
@@ -97,16 +147,26 @@ export default (({ user }: Props) => {
</Link> </Link>
{menu.map ((item, i) => ( <div ref={navRef} className="relative hidden md:flex h-full items-center">
<Link key={i} <div aria-hidden
to={item.to} className={cn ('absolute top-1/2 -translate-y-1/2 h-full',
className={cn ('hidden md:flex h-full items-center', 'bg-yellow-200 dark:bg-red-950',
(location.pathname.startsWith (item.base || item.to) 'transition-[transform,width] duration-200 ease-out')}
? 'bg-yellow-200 dark:bg-red-950 px-4 font-bold' style={{ width: hl.width,
: 'px-2'))}> transform: `translate(${ hl.left }px, -50%)`,
{item.name} opacity: hl.visible ? 1 : 0 }}/>
</Link>
))} {menu.map ((item, i) => (
<Link key={i}
to={item.to}
ref={el => {
itemsRef.current[i] = el
}}
className={cn ('relative z-10 flex h-full items-center px-5',
(i === openItemIdx) && 'font-bold')}>
{item.name}
</Link>))}
</div>
</div> </div>
<TopNavUser user={user}/> <TopNavUser user={user}/>
@@ -123,49 +183,101 @@ export default (({ user }: Props) => {
</a> </a>
</nav> </nav>
<div className="hidden md:flex bg-yellow-200 dark:bg-red-950 <div className="relative hidden md:flex bg-yellow-200 dark:bg-red-950
items-center w-full min-h-[40px] px-3"> items-center w-full min-h-[40px] overflow-hidden">
{menu.find (item => location.pathname.startsWith (item.base || item.to))?.subMenu <AnimatePresence initial={false} custom={dir}>
.filter (item => item.visible ?? true) <motion.div
.map ((item, i) => 'component' in item ? item.component : ( key={activeIdx}
<Link key={i} custom={dir}
to={item.to} variants={{ enter: (d: -1 | 1) => ({ y: d * 24, opacity: 0 }),
className="h-full flex items-center px-3"> centre: { y: 0, opacity: 1 },
{item.name} exit: (d: -1 | 1) => ({ y: (-d) * 24, opacity: 0 }) }}
</Link>))} className="absolute inset-0 flex items-center px-3"
initial="enter"
animate="centre"
exit="exit"
transition={{ duration: .2, ease: 'easeOut' }}>
{(menu[activeIdx]?.subMenu ?? [])
.filter (item => item.visible ?? true)
.map ((item, i) => (
'component' in item
? <Fragment key={`c-${ i }`}>{item.component}</Fragment>
: (
<Link key={`l-${ i }`}
to={item.to}
className="h-full flex items-center px-3">
{item.name}
</Link>)))}
</motion.div>
</AnimatePresence>
</div> </div>
<div className={cn (menuOpen ? 'flex flex-col md:hidden' : 'hidden', <AnimatePresence initial={false}>
'bg-yellow-200 dark:bg-red-975 items-start')}> {menuOpen && (
<Separator/> <motion.div
{menu.map ((item, i) => ( key="spmenu"
<Fragment key={i}> className={cn ('flex flex-col md:hidden',
<Link to={i === openItemIdx ? item.to : '#'} 'bg-yellow-200 dark:bg-red-975 items-start')}
className={cn ('w-full min-h-[40px] flex items-center pl-8', variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
((i === openItemIdx) height: 0 },
&& 'font-bold bg-yellow-50 dark:bg-red-950'))} open: { clipPath: 'inset(0 0 0% 0)',
onClick={ev => { height: 'auto' } }}
if (i !== openItemIdx) initial="closed"
{ animate="open"
ev.preventDefault () exit="closed"
setOpenItemIdx (i) transition={{ duration: .2, ease: 'easeOut' }}>
} <Separator/>
}}> {menu.map ((item, i) => (
{item.name} <Fragment key={i}>
</Link> <Link to={i === openItemIdx ? item.to : '#'}
{i === openItemIdx && ( className={cn ('w-full min-h-[40px] flex items-center pl-8',
item.subMenu ((i === openItemIdx)
.filter (subItem => subItem.visible ?? true) && 'font-bold bg-yellow-50 dark:bg-red-950'))}
.map ((subItem, j) => 'component' in subItem ? subItem.component : ( onClick={ev => {
<Link key={j} if (i !== openItemIdx)
to={subItem.to} {
className="w-full min-h-[36px] flex items-center pl-12 ev.preventDefault ()
bg-yellow-50 dark:bg-red-950"> setOpenItemIdx (i)
{subItem.name} }
</Link>)))} }}>
</Fragment>))} {item.name}
<TopNavUser user={user} sp/> </Link>
<Separator/>
</div> <AnimatePresence initial={false}>
{i === openItemIdx && (
<motion.div
key={`sp-sub-${ i }`}
className="w-full bg-yellow-50 dark:bg-red-950"
variants={{ closed: { clipPath: 'inset(0 0 100% 0)',
height: 0,
opacity: 0 },
open: { clipPath: 'inset(0 0 0% 0)',
height: 'auto',
opacity: 1 } }}
initial="closed"
animate="open"
exit="closed"
transition={{ duration: .2, ease: 'easeOut' }}>
{item.subMenu
.filter (subItem => subItem.visible ?? true)
.map ((subItem, j) => (
'component' in subItem
? (
<Fragment key={`sp-c-${ i }-${ j }`}>
{subItem.component}
</Fragment>)
: (
<Link key={`sp-l-${ i }-${ j }`}
to={subItem.to}
className="w-full min-h-[36px] flex items-center pl-12">
{subItem.name}
</Link>)))}
</motion.div>)}
</AnimatePresence>
</Fragment>))}
<TopNavUser user={user} sp/>
<Separator/>
</motion.div>)}
</AnimatePresence>
</>) </>)
}) satisfies FC<Props> }) satisfies FC<Props>
+79
ファイルの表示
@@ -0,0 +1,79 @@
import { Link, useLocation } from 'react-router-dom'
import type { FC } from 'react'
type Props = { page: number
totalPages: number
siblingCount?: number }
const range = (start: number, end: number): number[] =>
[...Array (end - start + 1).keys ()].map (i => start + i)
const getPages = (
page: number,
total: number,
siblingCount: number,
): (number | '…')[] => {
if (total <= 1)
return [1]
const first = 1
const last = total
const left = Math.max (page - siblingCount, first)
const right = Math.min (page + siblingCount, last)
const pages: (number | '…')[] = []
pages.push (first)
if (left > first + 1)
pages.push ('…')
const midStart = Math.max (left, first + 1)
const midEnd = Math.min (right, last - 1)
pages.push (...range (midStart, midEnd))
if (right < last - 1)
pages.push ('…')
if (last !== first)
pages.push (last)
return pages.filter ((v, i, arr) => i === 0 || v !== arr[i - 1])
}
export default (({ page, totalPages, siblingCount = 4 }) => {
const location = useLocation ()
const buildTo = (p: number) => {
const qs = new URLSearchParams (location.search)
qs.set ('page', String (p))
return `${ location.pathname }?${ qs.toString () }`
}
const pages = getPages (page, totalPages, siblingCount)
return (
<nav className="mt-4 flex justify-center" aria-label="Pagination">
<div className="flex items-center gap-2">
{(page > 1)
? <Link to={buildTo (page - 1)} aria-label="前のページ">&lt;</Link>
: <span aria-hidden>&lt;</span>}
{pages.map ((p, idx) => (
(p === '…')
? <span key={`dots-${ idx }`}></span>
: ((p === page)
? <span key={p} className="font-bold" aria-current="page">{p}</span>
: <Link key={p} to={buildTo (p)}>{p}</Link>)))}
{(page < totalPages)
? <Link to={buildTo (page + 1)} aria-label="次のページ">&gt;</Link>
: <span aria-hidden>&gt;</span>}
</div>
</nav>)
}) satisfies FC<Props>
+90
ファイルの表示
@@ -0,0 +1,90 @@
import axios from 'axios'
import toCamel from 'camelcase-keys'
import { useEffect, useState } from 'react'
import { Helmet } from 'react-helmet-async'
import { Link, useLocation } from 'react-router-dom'
import TagLink from '@/components/TagLink'
import PageTitle from '@/components/common/PageTitle'
import Pagination from '@/components/common/Pagination'
import MainArea from '@/components/layout/MainArea'
import { API_BASE_URL, SITE_TITLE } from '@/config'
import type { FC } from 'react'
import type { PostTagChange } from '@/types'
export default (() => {
const [changes, setChanges] = useState<PostTagChange[]> ([])
const [totalPages, setTotalPages] = useState<number> (0)
const location = useLocation ()
const query = new URLSearchParams (location.search)
const id = query.get ('id')
const page = Number (query.get ('page') ?? 1)
const limit = Number (query.get ('limit') ?? 20)
useEffect (() => {
void (async () => {
const res = await axios.get (`${ API_BASE_URL }/posts/changes`,
{ params: { ...(id && { id }),
...(page && { page }),
...(limit && { limit }) } })
const data = toCamel (res.data as any, { deep: true }) as {
changes: PostTagChange[]
count: number }
setChanges (data.changes)
setTotalPages (Math.trunc ((data.count - 1) / limit))
}) ()
}, [location.search])
return (
<MainArea>
<Helmet>
<title>{`耕作履歴 | ${ SITE_TITLE }`}</title>
</Helmet>
<PageTitle>
{Boolean (id) && <>: 稿 {<Link to={`/posts/${ id }`}>#{id}</Link>}</>}
</PageTitle>
<table className="table-auto w-full border-collapse">
<thead>
<tr>
<th className="p-2 text-left">稿</th>
<th className="p-2 text-left"></th>
<th className="p-2 text-left"></th>
</tr>
</thead>
<tbody>
{changes.map (change => (
<tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}>
<td>
<Link to={`/posts/${ change.post.id }`}>
<img src={change.post.thumbnail || change.post.thumbnailBase || undefined}
alt={change.post.title || change.post.url}
title={change.post.title || change.post.url || undefined}
className="w-40"/>
</Link>
</td>
<td>
<TagLink tag={change.tag} withWiki={false} withCount={false}/>
{`${ change.changeType === 'add' ? '追加' : '削除' }`}
</td>
<td>
{change.user ? (
<Link to={`/users/${ change.user.id }`}>
{change.user.name}
</Link>) : 'bot 操作'}
<br/>
{change.timestamp}
</td>
</tr>))}
</tbody>
</table>
<Pagination page={page} totalPages={totalPages}/>
</MainArea>)
}) satisfies FC
+12 -3
ファイルの表示
@@ -36,9 +36,16 @@ export default () => {
if (/^\d+$/.test (title)) if (/^\d+$/.test (title))
{ {
void (async () => { void (async () => {
const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`) try
const data = res.data as WikiPage {
navigate (`/wiki/${ data.title }`, { replace: true }) const res = await axios.get (`${ API_BASE_URL }/wiki/${ title }`)
const data = res.data as WikiPage
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
}
catch
{
;
}
}) () }) ()
return return
@@ -51,6 +58,8 @@ export default () => {
`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (title) }`, `${ API_BASE_URL }/wiki/title/${ encodeURIComponent (title) }`,
{ params: version ? { version } : { } }) { params: version ? { version } : { } })
const data = toCamel (res.data as any, { deep: true }) as WikiPage const data = toCamel (res.data as any, { deep: true }) as WikiPage
if (data.title !== title)
navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true })
setWikiPage (data) setWikiPage (data)
WikiIdBus.set (data.id) WikiIdBus.set (data.id)
} }
+7
ファイルの表示
@@ -29,6 +29,13 @@ export type Post = {
originalCreatedFrom: string | null originalCreatedFrom: string | null
originalCreatedBefore: string | null } originalCreatedBefore: string | null }
export type PostTagChange = {
post: Post
tag: Tag
user?: User
changeType: 'add' | 'remove'
timestamp: string }
export type SubMenuItem = export type SubMenuItem =
| { component: ReactNode | { component: ReactNode
visible: boolean } visible: boolean }