| @@ -6,11 +6,11 @@ class NicoTagsController < ApplicationController | |||||
| cursor = params[:cursor].presence | cursor = params[:cursor].presence | ||||
| q = Tag.nico_tags | q = Tag.nico_tags | ||||
| .includes(:tag_name, linked_tags: :tag_name) | |||||
| .includes(:tag_name, tag_name: :wiki_page, linked_tags: { tag_name: :wiki_page }) | |||||
| .order(updated_at: :desc) | .order(updated_at: :desc) | ||||
| q = q.where('tags.updated_at < ?', Time.iso8601(cursor)) if cursor | q = q.where('tags.updated_at < ?', Time.iso8601(cursor)) if cursor | ||||
| tags = q.limit(limit + 1) | |||||
| tags = q.limit(limit + 1).to_a | |||||
| next_cursor = nil | next_cursor = nil | ||||
| if tags.size > limit | if tags.size > limit | ||||
| @@ -17,16 +17,17 @@ class PostsController < ApplicationController | |||||
| 'posts.created_at)' | 'posts.created_at)' | ||||
| q = | q = | ||||
| filtered_posts | filtered_posts | ||||
| .preload(tags: :tag_name) | |||||
| .preload(tags: { tag_name: :wiki_page }) | |||||
| .with_attached_thumbnail | .with_attached_thumbnail | ||||
| .select("posts.*, #{ sort_sql } AS sort_ts") | .select("posts.*, #{ sort_sql } AS sort_ts") | ||||
| .order(Arel.sql("#{ sort_sql } DESC")) | .order(Arel.sql("#{ sort_sql } DESC")) | ||||
| posts = ( | |||||
| posts = | |||||
| if cursor | if cursor | ||||
| q.where("#{ sort_sql } < ?", Time.iso8601(cursor)).limit(limit + 1) | q.where("#{ sort_sql } < ?", Time.iso8601(cursor)).limit(limit + 1) | ||||
| else | else | ||||
| q.limit(limit).offset(offset) | q.limit(limit).offset(offset) | ||||
| end).to_a | |||||
| end | |||||
| .to_a | |||||
| next_cursor = nil | next_cursor = nil | ||||
| if cursor && posts.length > limit | if cursor && posts.length > limit | ||||
| @@ -52,7 +53,9 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| def random | def random | ||||
| post = filtered_posts.preload(tags: :tag_name).order('RAND()').first | |||||
| post = filtered_posts.preload(tags: { tag_name: :wiki_page }) | |||||
| .order('RAND()') | |||||
| .first | |||||
| return head :not_found unless post | return head :not_found unless post | ||||
| viewed = current_user&.viewed?(post) || false | viewed = current_user&.viewed?(post) || false | ||||
| @@ -64,7 +67,7 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| def show | def show | ||||
| post = Post.includes(tags: :tag_name).find(params[:id]) | |||||
| post = Post.includes(tags: { tag_name: :wiki_page }).find(params[:id]) | |||||
| return head :not_found unless post | return head :not_found unless post | ||||
| viewed = current_user&.viewed?(post) || false | viewed = current_user&.viewed?(post) || false | ||||
| @@ -151,7 +154,7 @@ class PostsController < ApplicationController | |||||
| end | end | ||||
| def changes | def changes | ||||
| id = params[:id] | |||||
| id = params[:id].presence | |||||
| page = (params[:page].presence || 1).to_i | page = (params[:page].presence || 1).to_i | ||||
| limit = (params[:limit].presence || 20).to_i | limit = (params[:limit].presence || 20).to_i | ||||
| @@ -162,30 +165,34 @@ class PostsController < ApplicationController | |||||
| pts = PostTag.with_discarded | pts = PostTag.with_discarded | ||||
| pts = pts.where(post_id: id) if id.present? | pts = pts.where(post_id: id) if id.present? | ||||
| pts = pts.includes(:post, { tag: :tag_name }, :created_user, :deleted_user) | |||||
| pts = pts.includes(:post, :created_user, :deleted_user, | |||||
| tag: { tag_name: :wiki_page }) | |||||
| events = [] | events = [] | ||||
| pts.each do |pt| | pts.each do |pt| | ||||
| tag = pt.tag.as_json(only: [:id, :category], methods: [:name, :has_wiki]) | |||||
| post = pt.post | |||||
| events << Event.new( | events << Event.new( | ||||
| post: pt.post, | |||||
| tag: pt.tag.as_json(only: [:id, :category], methods: [:name, :has_wiki]), | |||||
| user: pt.created_user && { id: pt.created_user.id, name: pt.created_user.name }, | |||||
| change_type: 'add', | |||||
| timestamp: pt.created_at) | |||||
| post:, | |||||
| 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 | if pt.discarded_at | ||||
| events << Event.new( | events << Event.new( | ||||
| post: pt.post, | |||||
| tag: pt.tag.as_json(only: [:id, :category], methods: [:name, :has_wiki]), | |||||
| user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name }, | |||||
| change_type: 'remove', | |||||
| timestamp: pt.discarded_at) | |||||
| post:, | |||||
| tag:, | |||||
| user: pt.deleted_user && { id: pt.deleted_user.id, name: pt.deleted_user.name }, | |||||
| change_type: 'remove', | |||||
| timestamp: pt.discarded_at) | |||||
| end | end | ||||
| end | end | ||||
| events.sort_by!(&:timestamp) | events.sort_by!(&:timestamp) | ||||
| events.reverse! | events.reverse! | ||||
| render json: { changes: events.slice(offset, limit).as_json, count: events.size } | |||||
| render json: { changes: (events.slice(offset, limit) || []).as_json, count: events.size } | |||||
| end | end | ||||
| private | private | ||||
| @@ -4,10 +4,14 @@ class TagsController < ApplicationController | |||||
| tags = | tags = | ||||
| if post_id.present? | if post_id.present? | ||||
| Tag.joins(:posts).where(posts: { id: post_id }) | |||||
| Tag.joins(:posts, :tag_name) | |||||
| else | else | ||||
| Tag.all | |||||
| Tag.joins(:tag_name) | |||||
| end | end | ||||
| .includes(:tag_name, tag_name: :wiki_page) | |||||
| if post_id.present? | |||||
| tags = tags.where(posts: { id: post_id }) | |||||
| end | |||||
| render json: tags.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki]) | render json: tags.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki]) | ||||
| end | end | ||||
| @@ -33,7 +37,8 @@ class TagsController < ApplicationController | |||||
| matched_alias_by_tag_name_id[canonical_id] ||= alias_name | matched_alias_by_tag_name_id[canonical_id] ||= alias_name | ||||
| end | end | ||||
| base = Tag.joins(:tag_name).includes(:tag_name) | |||||
| base = Tag.joins(:tag_name) | |||||
| .includes(:tag_name, tag_name: :wiki_page) | |||||
| base = base.where('tags.post_count > 0') if present_only | base = base.where('tags.post_count > 0') if present_only | ||||
| canonical_hit = | canonical_hit = | ||||
| @@ -58,7 +63,9 @@ class TagsController < ApplicationController | |||||
| end | end | ||||
| def show | def show | ||||
| tag = Tag.find_by(id: params[:id]) | |||||
| tag = Tag.joins(:tag_name) | |||||
| .includes(:tag_name, tag_name: :wiki_page) | |||||
| .find_by(id: params[:id]) | |||||
| if tag | if tag | ||||
| render json: tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki]) | render json: tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki]) | ||||
| else | else | ||||
| @@ -70,7 +77,9 @@ class TagsController < ApplicationController | |||||
| name = params[:name].to_s.strip | name = params[:name].to_s.strip | ||||
| return head :bad_request if name.blank? | return head :bad_request if name.blank? | ||||
| tag = Tag.joins(:tag_name).includes(:tag_name).find_by(tag_names: { name: }) | |||||
| tag = Tag.joins(:tag_name) | |||||
| .includes(:tag_name, tag_name: :wiki_page) | |||||
| .find_by(tag_names: { name: }) | |||||
| if tag | if tag | ||||
| render json: tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki]) | render json: tag.as_json(only: [:id, :category, :post_count], methods: [:name, :has_wiki]) | ||||
| else | else | ||||
| @@ -3,7 +3,11 @@ class WikiPagesController < ApplicationController | |||||
| def index | def index | ||||
| title = params[:title].to_s.strip | title = params[:title].to_s.strip | ||||
| return render json: WikiPage.all.as_json(methods: [:title]) if title.blank? | |||||
| if title.blank? | |||||
| return render json: WikiPage.joins(:tag_name) | |||||
| .includes(:tag_name) | |||||
| .as_json(methods: [:title]) | |||||
| end | |||||
| q = WikiPage.joins(:tag_name).includes(:tag_name) | q = WikiPage.joins(:tag_name).includes(:tag_name) | ||||
| .where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") | .where('tag_names.name LIKE ?', "%#{ WikiPage.sanitize_sql_like(title) }%") | ||||
| @@ -11,7 +15,9 @@ class WikiPagesController < ApplicationController | |||||
| end | end | ||||
| def show | def show | ||||
| page = WikiPage.find_by(id: params[:id]) | |||||
| page = WikiPage.joins(:tag_name) | |||||
| .includes(:tag_name) | |||||
| .find_by(id: params[:id]) | |||||
| render_wiki_page_or_404 page | render_wiki_page_or_404 page | ||||
| end | end | ||||
| @@ -19,7 +25,7 @@ class WikiPagesController < ApplicationController | |||||
| title = params[:title].to_s.strip | title = params[:title].to_s.strip | ||||
| page = WikiPage.joins(:tag_name) | page = WikiPage.joins(:tag_name) | ||||
| .includes(:tag_name) | .includes(:tag_name) | ||||
| .find_by(tag_names: { name: title }) | |||||
| .find_by(tag_name: { name: title }) | |||||
| render_wiki_page_or_404 page | render_wiki_page_or_404 page | ||||
| end | end | ||||
| @@ -47,7 +53,7 @@ class WikiPagesController < ApplicationController | |||||
| from = params[:from].presence | from = params[:from].presence | ||||
| to = params[:to].presence | to = params[:to].presence | ||||
| page = WikiPage.find(id) | |||||
| page = WikiPage.joins(:tag_name).includes(:tag_name).find(id) | |||||
| from_rev = from && page.wiki_revisions.find(from) | from_rev = from && page.wiki_revisions.find(from) | ||||
| to_rev = to ? page.wiki_revisions.find(to) : page.current_revision | to_rev = to ? page.wiki_revisions.find(to) : page.current_revision | ||||
| @@ -131,7 +137,9 @@ class WikiPagesController < ApplicationController | |||||
| def changes | def changes | ||||
| id = params[:id].presence | id = params[:id].presence | ||||
| q = WikiRevision.includes(:wiki_page, :created_user).order(id: :desc) | |||||
| q = WikiRevision.joins(wiki_page: :tag_name) | |||||
| .includes(:created_user, wiki_page: :tag_name) | |||||
| .order(id: :desc) | |||||
| q = q.where(wiki_page_id: id) if id | q = q.where(wiki_page_id: id) if id | ||||
| render json: q.limit(200).map { |rev| | render json: q.limit(200).map { |rev| | ||||
| @@ -139,7 +147,7 @@ class WikiPagesController < ApplicationController | |||||
| pred: rev.base_revision_id, | pred: rev.base_revision_id, | ||||
| succ: nil, | succ: nil, | ||||
| wiki_page: { id: rev.wiki_page_id, title: rev.wiki_page.title }, | wiki_page: { id: rev.wiki_page_id, title: rev.wiki_page.title }, | ||||
| user: { id: rev.created_user.id, name: rev.created_user.name }, | |||||
| user: rev.created_user && { id: rev.created_user.id, name: rev.created_user.name }, | |||||
| kind: rev.kind, | kind: rev.kind, | ||||
| message: rev.message, | message: rev.message, | ||||
| timestamp: rev.created_at } | timestamp: rev.created_at } | ||||
| @@ -30,13 +30,15 @@ class Post < ApplicationRecord | |||||
| super(options).merge(thumbnail: nil) | super(options).merge(thumbnail: nil) | ||||
| end | end | ||||
| def related(limit: nil) | |||||
| ids = post_similarities.select(:target_post_id).order(cos: :desc) | |||||
| def related limit: nil | |||||
| ids = post_similarities.order(cos: :desc) | |||||
| ids = ids.limit(limit) if limit | ids = ids.limit(limit) if limit | ||||
| ids = ids.pluck(:target_post_id) | ids = ids.pluck(:target_post_id) | ||||
| return [] if ids.empty? | |||||
| return Post.none if ids.empty? | |||||
| Post.where(id: ids).order(Arel.sql("FIELD(id, #{ ids.join(',') })")) | |||||
| Post.where(id: ids) | |||||
| .with_attached_thumbnail | |||||
| .order(Arel.sql("FIELD(posts.id, #{ ids.join(',') })")) | |||||
| end | end | ||||
| def resized_thumbnail! | def resized_thumbnail! | ||||
| @@ -25,6 +25,8 @@ class Tag < ApplicationRecord | |||||
| has_many :tag_similarities, dependent: :delete_all | has_many :tag_similarities, dependent: :delete_all | ||||
| belongs_to :tag_name | belongs_to :tag_name | ||||
| delegate :wiki_page, to: :tag_name | |||||
| delegate :name, to: :tag_name, allow_nil: true | delegate :name, to: :tag_name, allow_nil: true | ||||
| validates :tag_name, presence: true | validates :tag_name, presence: true | ||||
| @@ -56,7 +58,7 @@ class Tag < ApplicationRecord | |||||
| end | end | ||||
| def has_wiki | def has_wiki | ||||
| tag_name&.wiki_page.present? | |||||
| wiki_page.present? | |||||
| end | end | ||||
| def self.tagme | def self.tagme | ||||
| @@ -1,5 +1,3 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import { AnimatePresence, LayoutGroup } from 'framer-motion' | import { AnimatePresence, LayoutGroup } from 'framer-motion' | ||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { BrowserRouter, | import { BrowserRouter, | ||||
| @@ -11,7 +9,7 @@ import { BrowserRouter, | |||||
| import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' | import RouteBlockerOverlay from '@/components/RouteBlockerOverlay' | ||||
| import TopNav from '@/components/TopNav' | import TopNav from '@/components/TopNav' | ||||
| import { Toaster } from '@/components/ui/toaster' | import { Toaster } from '@/components/ui/toaster' | ||||
| import { API_BASE_URL } from '@/config' | |||||
| import { apiPost, isApiError } from '@/lib/api' | |||||
| 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' | ||||
| @@ -75,12 +73,11 @@ export default (() => { | |||||
| useEffect (() => { | useEffect (() => { | ||||
| const createUser = async () => { | const createUser = async () => { | ||||
| const res = await axios.post (`${ API_BASE_URL }/users`) | |||||
| const data = res.data as { code: string; user: any } | |||||
| const data = await apiPost<{ code: string; user: User }> ('/users') | |||||
| if (data.code) | if (data.code) | ||||
| { | { | ||||
| localStorage.setItem ('user_code', data.code) | localStorage.setItem ('user_code', data.code) | ||||
| setUser (toCamel (data.user, { deep: true }) as User) | |||||
| setUser (data.user) | |||||
| } | } | ||||
| } | } | ||||
| @@ -90,17 +87,16 @@ export default (() => { | |||||
| void (async () => { | void (async () => { | ||||
| try | try | ||||
| { | { | ||||
| const res = await axios.post (`${ API_BASE_URL }/users/verify`, { code }) | |||||
| const data = res.data as { valid: boolean, user: any } | |||||
| const data = await apiPost<{ valid: boolean; user: User }> ('/users/verify', { code }) | |||||
| if (data.valid) | if (data.valid) | ||||
| setUser (toCamel (data.user, { deep: true })) | |||||
| setUser (data.user) | |||||
| else | else | ||||
| await createUser () | await createUser () | ||||
| } | } | ||||
| catch (err) | catch (err) | ||||
| { | { | ||||
| if (axios.isAxiosError (err)) | |||||
| setStatus (err.status ?? 200) | |||||
| if (isApiError (err)) | |||||
| setStatus (err.response?.status ?? 200) | |||||
| } | } | ||||
| }) () | }) () | ||||
| } | } | ||||
| @@ -1,12 +1,10 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import PostFormTagsArea from '@/components/PostFormTagsArea' | import PostFormTagsArea from '@/components/PostFormTagsArea' | ||||
| import PostOriginalCreatedTimeField from '@/components/PostOriginalCreatedTimeField' | 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 { apiPut } from '@/lib/api' | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| @@ -41,14 +39,11 @@ export default (({ post, onSave }: Props) => { | |||||
| const [tags, setTags] = useState<string> ('') | const [tags, setTags] = useState<string> ('') | ||||
| const handleSubmit = async () => { | const handleSubmit = async () => { | ||||
| const res = await axios.put ( | |||||
| `${ API_BASE_URL }/posts/${ post.id }`, | |||||
| { title, tags, | |||||
| original_created_from: originalCreatedFrom, | |||||
| const data = await apiPut<Post> ( | |||||
| `/posts/${ post.id }`, | |||||
| { title, tags, original_created_from: originalCreatedFrom, | |||||
| original_created_before: originalCreatedBefore }, | original_created_before: originalCreatedBefore }, | ||||
| { headers: { 'Content-Type': 'multipart/form-data', | |||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | |||||
| const data = toCamel (res.data as any, { deep: true }) as Post | |||||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||||
| onSave ({ ...post, | onSave ({ ...post, | ||||
| title: data.title, | title: data.title, | ||||
| tags: data.tags, | tags: data.tags, | ||||
| @@ -1,11 +1,9 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import { useRef, useState } from 'react' | import { useRef, useState } from 'react' | ||||
| import TagSearchBox from '@/components/TagSearchBox' | import TagSearchBox from '@/components/TagSearchBox' | ||||
| import Label from '@/components/common/Label' | import Label from '@/components/common/Label' | ||||
| import TextArea from '@/components/common/TextArea' | import TextArea from '@/components/common/TextArea' | ||||
| import { API_BASE_URL } from '@/config' | |||||
| import { apiGet } from '@/lib/api' | |||||
| import type { FC, SyntheticEvent } from 'react' | import type { FC, SyntheticEvent } from 'react' | ||||
| @@ -59,8 +57,7 @@ export default (({ tags, setTags }: Props) => { | |||||
| const recompute = async (pos: number, v: string = tags) => { | const recompute = async (pos: number, v: string = tags) => { | ||||
| const { start, end, token } = getTokenAt (v, pos) | const { start, end, token } = getTokenAt (v, pos) | ||||
| setBounds ({ start, end }) | setBounds ({ start, end }) | ||||
| const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q: token } }) | |||||
| const data = toCamel (res.data as any, { deep: true }) as Tag[] | |||||
| const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q: token } }) | |||||
| setSuggestions (data.filter (t => t.postCount > 0)) | setSuggestions (data.filter (t => t.postCount > 0)) | ||||
| setSuggestionsVsbl (suggestions.length > 0) | setSuggestionsVsbl (suggestions.length > 0) | ||||
| } | } | ||||
| @@ -21,53 +21,50 @@ export default (({ posts, onClick }: Props) => { | |||||
| const cardRef = useRef<HTMLDivElement> (null) | const cardRef = useRef<HTMLDivElement> (null) | ||||
| return ( | return ( | ||||
| <> | |||||
| <div className="flex flex-wrap gap-6 p-4"> | |||||
| {posts.map ((post, i) => { | |||||
| const id2 = `page-${ post.id }` | |||||
| const layoutId = id2 | |||||
| <div className="flex flex-wrap gap-6 p-4"> | |||||
| {posts.map ((post, i) => { | |||||
| const sharedId = `page-${ post.id }` | |||||
| const layoutId = sharedId | |||||
| return ( | |||||
| <PrefetchLink | |||||
| to={`/posts/${ post.id }`} | |||||
| key={post.id} | |||||
| className="w-40 h-40" | |||||
| state={{ sharedId: `page-${ post.id }` }} | |||||
| onClick={e => { | |||||
| const sharedId = `page-${ post.id }` | |||||
| setForLocationKey (location.key, sharedId) | |||||
| onClick?.(e) | |||||
| }}> | |||||
| <motion.div | |||||
| ref={cardRef} | |||||
| layoutId={layoutId} | |||||
| className="w-full h-full overflow-hidden rounded-xl shadow | |||||
| transform-gpu will-change-transform" | |||||
| whileHover={{ scale: 1.02 }} | |||||
| onLayoutAnimationStart={() => { | |||||
| if (cardRef.current) | |||||
| { | |||||
| cardRef.current.style.position = 'relative' | |||||
| cardRef.current.style.zIndex = '9999' | |||||
| } | |||||
| }} | |||||
| onLayoutAnimationComplete={() => { | |||||
| if (cardRef.current) | |||||
| { | |||||
| cardRef.current.style.zIndex = '' | |||||
| cardRef.current.style.position = '' | |||||
| } | |||||
| }} | |||||
| transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}> | |||||
| <img src={post.thumbnail || post.thumbnailBase || undefined} | |||||
| alt={post.title || post.url} | |||||
| title={post.title || post.url || undefined} | |||||
| loading={i < 12 ? 'eager' : 'lazy'} | |||||
| decoding="async" | |||||
| className="object-cover w-full h-full"/> | |||||
| </motion.div> | |||||
| </PrefetchLink>) | |||||
| })} | |||||
| </div> | |||||
| </>) | |||||
| return ( | |||||
| <PrefetchLink | |||||
| to={`/posts/${ post.id }`} | |||||
| key={post.id} | |||||
| className="w-40 h-40" | |||||
| state={{ sharedId }} | |||||
| onClick={e => { | |||||
| setForLocationKey (location.key, sharedId) | |||||
| onClick?.(e) | |||||
| }}> | |||||
| <motion.div | |||||
| ref={cardRef} | |||||
| layoutId={layoutId} | |||||
| className="w-full h-full overflow-hidden rounded-xl shadow | |||||
| transform-gpu will-change-transform" | |||||
| whileHover={{ scale: 1.02 }} | |||||
| onLayoutAnimationStart={() => { | |||||
| if (!(cardRef.current)) | |||||
| return | |||||
| cardRef.current.style.position = 'relative' | |||||
| cardRef.current.style.zIndex = '9999' | |||||
| }} | |||||
| onLayoutAnimationComplete={() => { | |||||
| if (!(cardRef.current)) | |||||
| return | |||||
| cardRef.current.style.zIndex = '' | |||||
| cardRef.current.style.position = '' | |||||
| }} | |||||
| transition={{ type: 'spring', stiffness: 500, damping: 40, mass: .5 }}> | |||||
| <img src={post.thumbnail || post.thumbnailBase || undefined} | |||||
| alt={post.title || post.url} | |||||
| title={post.title || post.url || undefined} | |||||
| loading={i < 12 ? 'eager' : 'lazy'} | |||||
| decoding="async" | |||||
| className="object-cover w-full h-full"/> | |||||
| </motion.div> | |||||
| </PrefetchLink>) | |||||
| })} | |||||
| </div>) | |||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -6,21 +6,19 @@ import { DndContext, | |||||
| useSensor, | useSensor, | ||||
| useSensors } from '@dnd-kit/core' | useSensors } from '@dnd-kit/core' | ||||
| import { restrictToWindowEdges } from '@dnd-kit/modifiers' | import { restrictToWindowEdges } from '@dnd-kit/modifiers' | ||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import { AnimatePresence, motion } from 'framer-motion' | import { AnimatePresence, motion } from 'framer-motion' | ||||
| import { useEffect, useRef, useState } from 'react' | import { useEffect, useRef, useState } from 'react' | ||||
| import { Link } from 'react-router-dom' | |||||
| import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow' | import DraggableDroppableTagRow from '@/components/DraggableDroppableTagRow' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | |||||
| import TagLink from '@/components/TagLink' | import TagLink from '@/components/TagLink' | ||||
| import TagSearch from '@/components/TagSearch' | import TagSearch from '@/components/TagSearch' | ||||
| import SectionTitle from '@/components/common/SectionTitle' | import SectionTitle from '@/components/common/SectionTitle' | ||||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | import SubsectionTitle from '@/components/common/SubsectionTitle' | ||||
| import SidebarComponent from '@/components/layout/SidebarComponent' | import SidebarComponent from '@/components/layout/SidebarComponent' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { API_BASE_URL } from '@/config' | |||||
| import { CATEGORIES } from '@/consts' | import { CATEGORIES } from '@/consts' | ||||
| import { apiDelete, apiGet, apiPatch, apiPost } from '@/lib/api' | |||||
| import type { DragEndEvent } from '@dnd-kit/core' | import type { DragEndEvent } from '@dnd-kit/core' | ||||
| import type { FC, MutableRefObject, ReactNode } from 'react' | import type { FC, MutableRefObject, ReactNode } from 'react' | ||||
| @@ -132,10 +130,7 @@ const changeCategory = async ( | |||||
| tagId: number, | tagId: number, | ||||
| category: Category, | category: Category, | ||||
| ): Promise<void> => { | ): Promise<void> => { | ||||
| await axios.patch ( | |||||
| `${ API_BASE_URL }/tags/${ tagId }`, | |||||
| { category }, | |||||
| { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | |||||
| await apiPatch (`/tags/${ tagId }`, { category }) | |||||
| } | } | ||||
| @@ -170,12 +165,7 @@ export default (({ post }: Props) => { | |||||
| if (!(post)) | if (!(post)) | ||||
| return | return | ||||
| const res = await axios.get ( | |||||
| `${ API_BASE_URL }/posts/${ post.id }`, | |||||
| { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | |||||
| const data = toCamel (res.data as any, { deep: true }) as Post | |||||
| setTags (buildTagByCategory (data)) | |||||
| setTags (buildTagByCategory (await apiGet<Post> (`/posts/${ post.id }`))) | |||||
| } | } | ||||
| const onDragEnd = async (e: DragEndEvent) => { | const onDragEnd = async (e: DragEndEvent) => { | ||||
| @@ -216,16 +206,9 @@ export default (({ post }: Props) => { | |||||
| return | return | ||||
| if (fromParentId != null) | if (fromParentId != null) | ||||
| { | |||||
| await axios.delete ( | |||||
| `${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`, | |||||
| { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | |||||
| } | |||||
| await apiDelete (`/tags/${ fromParentId }/children/${ childId }`) | |||||
| await axios.post ( | |||||
| `${ API_BASE_URL }/tags/${ parentId }/children/${ childId }`, | |||||
| { }, | |||||
| { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | |||||
| await apiPost (`/tags/${ parentId }/children/${ childId }`, { }) | |||||
| await reloadTags () | await reloadTags () | ||||
| toast ({ | toast ({ | ||||
| @@ -245,11 +228,7 @@ export default (({ post }: Props) => { | |||||
| await changeCategory (childId, cat) | await changeCategory (childId, cat) | ||||
| if (fromParentId != null) | if (fromParentId != null) | ||||
| { | |||||
| await axios.delete ( | |||||
| `${ API_BASE_URL }/tags/${ fromParentId }/children/${ childId }`, | |||||
| { headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | |||||
| } | |||||
| await apiDelete (`/tags/${ fromParentId }/children/${ childId }`) | |||||
| const fromParent = fromParentId == null ? null : findTag (tags, fromParentId) | const fromParent = fromParentId == null ? null : findTag (tags, fromParentId) | ||||
| @@ -358,9 +337,9 @@ export default (({ post }: Props) => { | |||||
| <>耕作者: </> | <>耕作者: </> | ||||
| {post.uploadedUser | {post.uploadedUser | ||||
| ? ( | ? ( | ||||
| <Link to={`/users/${ post.uploadedUser.id }`}> | |||||
| <PrefetchLink to={`/users/${ post.uploadedUser.id }`}> | |||||
| {post.uploadedUser.name || '名もなきニジラー'} | {post.uploadedUser.name || '名もなきニジラー'} | ||||
| </Link>) | |||||
| </PrefetchLink>) | |||||
| : 'bot操作'} | : 'bot操作'} | ||||
| </li> | </li> | ||||
| */} | */} | ||||
| @@ -389,7 +368,7 @@ export default (({ post }: Props) => { | |||||
| </>)} | </>)} | ||||
| </li> | </li> | ||||
| <li> | <li> | ||||
| <Link to={`/posts/changes?id=${ post.id }`}>履歴</Link> | |||||
| <PrefetchLink to={`/posts/changes?id=${ post.id }`}>履歴</PrefetchLink> | |||||
| </li> | </li> | ||||
| </ul> | </ul> | ||||
| </div>)} | </div>)} | ||||
| @@ -1,29 +1,34 @@ | |||||
| import axios from 'axios' | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Link } from 'react-router-dom' | |||||
| import PrefetchLink from '@/components/PrefetchLink' | import PrefetchLink from '@/components/PrefetchLink' | ||||
| import { API_BASE_URL } from '@/config' | |||||
| import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' | import { LIGHT_COLOUR_SHADE, DARK_COLOUR_SHADE, TAG_COLOUR } from '@/consts' | ||||
| import { apiGet } from '@/lib/api' | |||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| import type { ComponentProps, FC, HTMLAttributes } from 'react' | import type { ComponentProps, FC, HTMLAttributes } from 'react' | ||||
| import type { Tag } from '@/types' | import type { Tag } from '@/types' | ||||
| type CommonProps = { tag: Tag | |||||
| nestLevel?: number | |||||
| withWiki?: boolean | |||||
| withCount?: boolean | |||||
| prefetch?: boolean } | |||||
| type CommonProps = { | |||||
| tag: Tag | |||||
| nestLevel?: number | |||||
| withWiki?: boolean | |||||
| withCount?: boolean | |||||
| prefetch?: boolean } | |||||
| type PropsWithLink = | type PropsWithLink = | ||||
| CommonProps & { linkFlg?: true } & Partial<ComponentProps<typeof Link>> | |||||
| & CommonProps | |||||
| & { linkFlg?: true } | |||||
| & Partial<ComponentProps<typeof PrefetchLink>> | |||||
| type PropsWithoutLink = | type PropsWithoutLink = | ||||
| CommonProps & { linkFlg: false } & Partial<HTMLAttributes<HTMLSpanElement>> | |||||
| & CommonProps | |||||
| & { linkFlg: false } | |||||
| & Partial<HTMLAttributes<HTMLSpanElement>> | |||||
| type Props = PropsWithLink | PropsWithoutLink | |||||
| type Props = | |||||
| | PropsWithLink | |||||
| | PropsWithoutLink | |||||
| export default (({ tag, | export default (({ tag, | ||||
| @@ -46,7 +51,7 @@ export default (({ tag, | |||||
| try | try | ||||
| { | { | ||||
| await axios.get (`${ API_BASE_URL }/wiki/title/${ encodeURIComponent (tagName) }/exists`) | |||||
| await apiGet (`/wiki/title/${ encodeURIComponent (tagName) }/exists`) | |||||
| setHavingWiki (true) | setHavingWiki (true) | ||||
| } | } | ||||
| catch | catch | ||||
| @@ -76,17 +81,17 @@ export default (({ tag, | |||||
| <span className="mr-1"> | <span className="mr-1"> | ||||
| {havingWiki | {havingWiki | ||||
| ? ( | ? ( | ||||
| <Link to={`/wiki/${ encodeURIComponent (tag.name) }`} | |||||
| <PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`} | |||||
| className={linkClass}> | className={linkClass}> | ||||
| ? | ? | ||||
| </Link>) | |||||
| </PrefetchLink>) | |||||
| : ( | : ( | ||||
| <Link to={`/wiki/${ encodeURIComponent (tag.name) }`} | |||||
| <PrefetchLink to={`/wiki/${ encodeURIComponent (tag.name) }`} | |||||
| className="animate-[wiki-blink_.25s_steps(2,end)_infinite] | className="animate-[wiki-blink_.25s_steps(2,end)_infinite] | ||||
| dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]" | dark:animate-[wiki-blink-dark_.25s_steps(2,end)_infinite]" | ||||
| title={`${ tag.name } Wiki が存在しません.`}> | title={`${ tag.name } Wiki が存在しません.`}> | ||||
| ! | ! | ||||
| </Link>)} | |||||
| </PrefetchLink>)} | |||||
| </span>)} | </span>)} | ||||
| {nestLevel > 0 && ( | {nestLevel > 0 && ( | ||||
| <span | <span | ||||
| @@ -110,12 +115,12 @@ export default (({ tag, | |||||
| {...props}> | {...props}> | ||||
| {tag.name} | {tag.name} | ||||
| </PrefetchLink> | </PrefetchLink> | ||||
| : <Link | |||||
| : <PrefetchLink | |||||
| to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} | to={`/posts?${ (new URLSearchParams ({ tags: tag.name })).toString () }`} | ||||
| className={linkClass} | className={linkClass} | ||||
| {...props}> | {...props}> | ||||
| {tag.name} | {tag.name} | ||||
| </Link>) | |||||
| </PrefetchLink>) | |||||
| : ( | : ( | ||||
| <span className={spanClass} | <span className={spanClass} | ||||
| {...props}> | {...props}> | ||||
| @@ -1,13 +1,11 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import React, { useEffect, useState } from 'react' | |||||
| import { useEffect, useState } from 'react' | |||||
| import { useNavigate, useLocation } from 'react-router-dom' | import { useNavigate, useLocation } from 'react-router-dom' | ||||
| import { API_BASE_URL } from '@/config' | |||||
| import { apiGet } from '@/lib/api' | |||||
| import TagSearchBox from './TagSearchBox' | import TagSearchBox from './TagSearchBox' | ||||
| import type { FC } from 'react' | |||||
| import type { ChangeEvent, FC, KeyboardEvent } from 'react' | |||||
| import type { Tag } from '@/types' | import type { Tag } from '@/types' | ||||
| @@ -21,7 +19,7 @@ export default (() => { | |||||
| const [suggestions, setSuggestions] = useState<Tag[]> ([]) | const [suggestions, setSuggestions] = useState<Tag[]> ([]) | ||||
| const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | const [suggestionsVsbl, setSuggestionsVsbl] = useState (false) | ||||
| const whenChanged = async (ev: React.ChangeEvent<HTMLInputElement>) => { | |||||
| const whenChanged = async (ev: ChangeEvent<HTMLInputElement>) => { | |||||
| setSearch (ev.target.value) | setSearch (ev.target.value) | ||||
| const q = ev.target.value.trim ().split (' ').at (-1) | const q = ev.target.value.trim ().split (' ').at (-1) | ||||
| @@ -31,14 +29,13 @@ export default (() => { | |||||
| return | return | ||||
| } | } | ||||
| const res = await axios.get (`${ API_BASE_URL }/tags/autocomplete`, { params: { q } }) | |||||
| const data = toCamel (res.data, { deep: true }) as Tag[] | |||||
| const data = await apiGet<Tag[]> ('/tags/autocomplete', { params: { q } }) | |||||
| setSuggestions (data.filter (t => t.postCount > 0)) | setSuggestions (data.filter (t => t.postCount > 0)) | ||||
| if (suggestions.length > 0) | if (suggestions.length > 0) | ||||
| setSuggestionsVsbl (true) | setSuggestionsVsbl (true) | ||||
| } | } | ||||
| const handleKeyDown = (ev: React.KeyboardEvent<HTMLInputElement>) => { | |||||
| const handleKeyDown = (ev: KeyboardEvent<HTMLInputElement>) => { | |||||
| switch (ev.key) | switch (ev.key) | ||||
| { | { | ||||
| case 'ArrowDown': | case 'ArrowDown': | ||||
| @@ -1,4 +1,3 @@ | |||||
| import axios from 'axios' | |||||
| import { AnimatePresence, motion } from 'framer-motion' | 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' | ||||
| @@ -7,8 +6,8 @@ import TagLink from '@/components/TagLink' | |||||
| import TagSearch from '@/components/TagSearch' | import TagSearch from '@/components/TagSearch' | ||||
| import SectionTitle from '@/components/common/SectionTitle' | 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 { CATEGORIES } from '@/consts' | import { CATEGORIES } from '@/consts' | ||||
| import { apiGet } from '@/lib/api' | |||||
| import type { FC, MouseEvent } from 'react' | import type { FC, MouseEvent } from 'react' | ||||
| @@ -77,10 +76,10 @@ export default (({ posts, onClick }: Props) => { | |||||
| void ((async () => { | void ((async () => { | ||||
| try | try | ||||
| { | { | ||||
| const { data } = await axios.get (`${ API_BASE_URL }/posts/random`, | |||||
| { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), | |||||
| const data = await apiGet<Post> ('/posts/random', | |||||
| { params: { tags: tagsQuery.split (' ').filter (e => e !== '').join (' '), | |||||
| match: (anyFlg ? 'any' : 'all') } }) | match: (anyFlg ? 'any' : 'all') } }) | ||||
| navigate (`/posts/${ (data as Post).id }`) | |||||
| navigate (`/posts/${ data.id }`) | |||||
| } | } | ||||
| catch | catch | ||||
| { | { | ||||
| @@ -1,3 +1,4 @@ | |||||
| import { useQuery } from '@tanstack/react-query' | |||||
| import { AnimatePresence, motion } from 'framer-motion' | import { AnimatePresence, motion } from 'framer-motion' | ||||
| import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' | import { Fragment, useEffect, useLayoutEffect, useRef, useState } from 'react' | ||||
| import { useLocation } from 'react-router-dom' | import { useLocation } from 'react-router-dom' | ||||
| @@ -6,6 +7,7 @@ import Separator from '@/components/MenuSeparator' | |||||
| import PrefetchLink from '@/components/PrefetchLink' | import PrefetchLink from '@/components/PrefetchLink' | ||||
| import TopNavUser from '@/components/TopNavUser' | import TopNavUser from '@/components/TopNavUser' | ||||
| import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' | import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' | ||||
| import { tagsKeys, wikiKeys } from '@/lib/queryKeys' | |||||
| import { fetchTagByName } from '@/lib/tags' | import { fetchTagByName } from '@/lib/tags' | ||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| import { fetchWikiPage } from '@/lib/wiki' | import { fetchWikiPage } from '@/lib/wiki' | ||||
| @@ -44,11 +46,26 @@ export default (({ user }: Props) => { | |||||
| visible: false }) | 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 [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ()) | const [wikiId, setWikiId] = useState<number | null> (WikiIdBus.get ()) | ||||
| const wikiIdStr = String (wikiId ?? '') | |||||
| const { data: wikiPage } = useQuery ({ | |||||
| enabled: Boolean (wikiIdStr), | |||||
| queryKey: wikiKeys.show (wikiIdStr, { }), | |||||
| queryFn: () => fetchWikiPage (wikiIdStr, { }) }) | |||||
| const effectiveTitle = wikiPage?.title ?? '' | |||||
| const { data: tag } = useQuery ({ | |||||
| enabled: Boolean (effectiveTitle), | |||||
| queryKey: tagsKeys.show (effectiveTitle), | |||||
| queryFn: () => fetchTagByName (effectiveTitle) }) | |||||
| const postCount = tag?.postCount ?? 0 | |||||
| const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (location.pathname) && wikiId) | const wikiPageFlg = Boolean (/^\/wiki\/(?!new|changes)[^\/]+/.test (location.pathname) && wikiId) | ||||
| const wikiTitle = location.pathname.split ('/')[2] | |||||
| const wikiTitle = location.pathname.split ('/')[2] ?? '' | |||||
| const menu: Menu = [ | const menu: Menu = [ | ||||
| { name: '広場', to: '/posts', subMenu: [ | { name: '広場', to: '/posts', subMenu: [ | ||||
| { name: '一覧', to: '/posts' }, | { name: '一覧', to: '/posts' }, | ||||
| @@ -113,26 +130,6 @@ export default (({ user }: Props) => { | |||||
| location.pathname.startsWith (item.base || item.to)))) | location.pathname.startsWith (item.base || item.to)))) | ||||
| }, [location]) | }, [location]) | ||||
| useEffect (() => { | |||||
| if (!(wikiId)) | |||||
| return | |||||
| const fetchPostCount = async () => { | |||||
| try | |||||
| { | |||||
| const wikiPage = await fetchWikiPage (String (wikiId ?? '')) | |||||
| const tag = await fetchTagByName (wikiPage.title) | |||||
| setPostCount (tag.postCount) | |||||
| } | |||||
| catch | |||||
| { | |||||
| setPostCount (0) | |||||
| } | |||||
| } | |||||
| fetchPostCount () | |||||
| }, [wikiId]) | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| <nav className="px-3 flex justify-between items-center w-full min-h-[48px] | <nav className="px-3 flex justify-between items-center w-full min-h-[48px] | ||||
| @@ -1,6 +1,5 @@ | |||||
| import { Link } from 'react-router-dom' | |||||
| import Separator from '@/components/MenuSeparator' | import Separator from '@/components/MenuSeparator' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | |||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| @@ -24,9 +23,9 @@ export default (({ user, sp }: Props) => { | |||||
| return ( | return ( | ||||
| <> | <> | ||||
| {sp && <Separator/>} | {sp && <Separator/>} | ||||
| <Link to="/users/settings" | |||||
| <PrefetchLink to="/users/settings" | |||||
| className={className}> | className={className}> | ||||
| {user.name || '名もなきニジラー'} | {user.name || '名もなきニジラー'} | ||||
| </Link> | |||||
| </PrefetchLink> | |||||
| </>) | </>) | ||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -1,20 +1,18 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import { useEffect, useMemo, useState } from 'react' | |||||
| import { useQuery } from '@tanstack/react-query' | |||||
| import { useMemo } from 'react' | |||||
| import ReactMarkdown from 'react-markdown' | import ReactMarkdown from 'react-markdown' | ||||
| import { Link } from 'react-router-dom' | |||||
| import remarkGFM from 'remark-gfm' | import remarkGFM from 'remark-gfm' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | |||||
| import SectionTitle from '@/components/common/SectionTitle' | import SectionTitle from '@/components/common/SectionTitle' | ||||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | import SubsectionTitle from '@/components/common/SubsectionTitle' | ||||
| import { API_BASE_URL } from '@/config' | |||||
| import { wikiKeys } from '@/lib/queryKeys' | |||||
| import remarkWikiAutoLink from '@/lib/remark-wiki-autolink' | import remarkWikiAutoLink from '@/lib/remark-wiki-autolink' | ||||
| import { fetchWikiPages } from '@/lib/wiki' | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import type { Components } from 'react-markdown' | import type { Components } from 'react-markdown' | ||||
| import type { WikiPage } from '@/types' | |||||
| type Props = { title: string | type Props = { title: string | ||||
| body?: string } | body?: string } | ||||
| @@ -24,7 +22,7 @@ const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionT | |||||
| ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>, | ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>, | ||||
| a: (({ href, children }) => ( | a: (({ href, children }) => ( | ||||
| ['/', '.'].some (e => href?.startsWith (e)) | ['/', '.'].some (e => href?.startsWith (e)) | ||||
| ? <Link to={href!}>{children}</Link> | |||||
| ? <PrefetchLink to={href!}>{children}</PrefetchLink> | |||||
| : ( | : ( | ||||
| <a href={href} | <a href={href} | ||||
| target="_blank" | target="_blank" | ||||
| @@ -34,26 +32,15 @@ const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionT | |||||
| export default (({ title, body }: Props) => { | export default (({ title, body }: Props) => { | ||||
| const [pageNames, setPageNames] = useState<string[]> ([]) | |||||
| const { data } = useQuery ({ | |||||
| enabled: Boolean (body), | |||||
| queryKey: wikiKeys.index ({ }), | |||||
| queryFn: () => fetchWikiPages ({ }) }) | |||||
| const pageNames = (data ?? []).map (page => page.title).sort ((a, b) => b.length - a.length) | |||||
| const remarkPlugins = useMemo ( | const remarkPlugins = useMemo ( | ||||
| () => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames]) | () => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames]) | ||||
| useEffect (() => { | |||||
| void (async () => { | |||||
| try | |||||
| { | |||||
| const res = await axios.get (`${ API_BASE_URL }/wiki`) | |||||
| const data: WikiPage[] = toCamel (res.data as any, { deep: true }) | |||||
| setPageNames (data.map (page => page.title).sort ((a, b) => b.length - a.length)) | |||||
| } | |||||
| catch | |||||
| { | |||||
| setPageNames ([]) | |||||
| } | |||||
| }) () | |||||
| }, []) | |||||
| return ( | return ( | ||||
| <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> | <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> | ||||
| {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | ||||
| @@ -1,5 +1,3 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import { useState } from 'react' | import { useState } from 'react' | ||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| @@ -8,7 +6,7 @@ import { Dialog, | |||||
| DialogTitle } from '@/components/ui/dialog' | DialogTitle } from '@/components/ui/dialog' | ||||
| import { Input } from '@/components/ui/input' | import { Input } from '@/components/ui/input' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { API_BASE_URL } from '@/config' | |||||
| import { apiPost } from '@/lib/api' | |||||
| import type { User } from '@/types' | import type { User } from '@/types' | ||||
| @@ -26,12 +24,12 @@ export default ({ visible, onVisibleChange, setUser }: Props) => { | |||||
| try | try | ||||
| { | { | ||||
| const res = await axios.post (`${ API_BASE_URL }/users/verify`, { code: inputCode }) | |||||
| const data = res.data as { valid: boolean; user: any } | |||||
| const data = await apiPost<{ valid: boolean; user: User }> ( | |||||
| '/users/verify', { code: inputCode }) | |||||
| if (data.valid) | if (data.valid) | ||||
| { | { | ||||
| localStorage.setItem ('user_code', inputCode) | localStorage.setItem ('user_code', inputCode) | ||||
| setUser (toCamel (data.user, { deep: true })) | |||||
| setUser (data.user) | |||||
| toast ({ title: '引継ぎ成功!' }) | toast ({ title: '引継ぎ成功!' }) | ||||
| onVisibleChange (false) | onVisibleChange (false) | ||||
| } | } | ||||
| @@ -1,11 +1,9 @@ | |||||
| import axios from 'axios' | |||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { Dialog, | import { Dialog, | ||||
| DialogContent, | DialogContent, | ||||
| DialogTitle } from '@/components/ui/dialog' | DialogTitle } from '@/components/ui/dialog' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { API_BASE_URL } from '@/config' | |||||
| import { apiPost } from '@/lib/api' | |||||
| import type { User } from '@/types' | import type { User } from '@/types' | ||||
| @@ -23,10 +21,8 @@ export default ({ visible, onVisibleChange, user, setUser }: Props) => { | |||||
| if (!(confirm ('引継ぎコードを再発行しますか?\n再発行するとほかのブラウザからはログアウトされます.'))) | if (!(confirm ('引継ぎコードを再発行しますか?\n再発行するとほかのブラウザからはログアウトされます.'))) | ||||
| return | return | ||||
| const res = await axios.post (`${ API_BASE_URL }/users/code/renew`, { }, { headers: { | |||||
| 'Content-Type': 'multipart/form-data', | |||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | |||||
| const data = res.data as { code: string } | |||||
| const data = await apiPost<{ code: string }> ('/users/code/renew', { }, | |||||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||||
| if (data.code) | if (data.code) | ||||
| { | { | ||||
| localStorage.setItem ('user_code', data.code) | localStorage.setItem ('user_code', data.code) | ||||
| @@ -3,9 +3,12 @@ import toCamel from 'camelcase-keys' | |||||
| import { API_BASE_URL } from '@/config' | import { API_BASE_URL } from '@/config' | ||||
| import type { AxiosError, AxiosRequestConfig } from 'axios' | |||||
| type Opt = { | type Opt = { | ||||
| params?: Record<string, unknown> | |||||
| headers?: Record<string, string> } | |||||
| params?: AxiosRequestConfig['params'] | |||||
| headers?: Record<string, string> | |||||
| responseType?: 'blob' } | |||||
| const client = axios.create ({ baseURL: API_BASE_URL }) | const client = axios.create ({ baseURL: API_BASE_URL }) | ||||
| @@ -23,6 +26,8 @@ const apiP = async <T> ( | |||||
| opt?: Opt, | opt?: Opt, | ||||
| ): Promise<T> => { | ): Promise<T> => { | ||||
| const res = await client[method] (path, body ?? { }, withUserCode (opt)) | const res = await client[method] (path, body ?? { }, withUserCode (opt)) | ||||
| if (opt?.responseType === 'blob') | |||||
| return res.data as T | |||||
| return toCamel (res.data as any, { deep: true }) as T | return toCamel (res.data as any, { deep: true }) as T | ||||
| } | } | ||||
| @@ -32,6 +37,8 @@ export const apiGet = async <T> ( | |||||
| opt?: Opt, | opt?: Opt, | ||||
| ): Promise<T> => { | ): Promise<T> => { | ||||
| const res = await client.get (path, withUserCode (opt)) | const res = await client.get (path, withUserCode (opt)) | ||||
| if (opt?.responseType === 'blob') | |||||
| return res.data as T | |||||
| return toCamel (res.data as any, { deep: true }) as T | return toCamel (res.data as any, { deep: true }) as T | ||||
| } | } | ||||
| @@ -63,3 +70,6 @@ export const apiDelete = async ( | |||||
| ): Promise<void> => { | ): Promise<void> => { | ||||
| await client.delete (path, withUserCode (opt)) | await client.delete (path, withUserCode (opt)) | ||||
| } | } | ||||
| export const isApiError = (err: unknown): err is AxiosError => axios.isAxiosError (err) | |||||
| @@ -1,6 +1,6 @@ | |||||
| import { apiDelete, apiGet, apiPost } from '@/lib/api' | import { apiDelete, apiGet, apiPost } from '@/lib/api' | ||||
| import type { Post } from '@/types' | |||||
| import type { Post, PostTagChange } from '@/types' | |||||
| export const fetchPosts = async ( | export const fetchPosts = async ( | ||||
| @@ -13,8 +13,8 @@ export const fetchPosts = async ( | |||||
| ): Promise<{ | ): Promise<{ | ||||
| posts: Post[] | posts: Post[] | ||||
| count: number | count: number | ||||
| nextCursor: string }> => await apiGet ('/posts', { | |||||
| params: { | |||||
| nextCursor: string }> => | |||||
| await apiGet ('/posts', { params: { | |||||
| tags, | tags, | ||||
| match, | match, | ||||
| ...(page && { page }), | ...(page && { page }), | ||||
| @@ -25,6 +25,17 @@ export const fetchPosts = async ( | |||||
| export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`) | export const fetchPost = async (id: string): Promise<Post> => await apiGet (`/posts/${ id }`) | ||||
| export const fetchPostChanges = async ( | |||||
| { id, page, limit }: { | |||||
| id?: string | |||||
| page: number | |||||
| limit: number }, | |||||
| ): Promise<{ | |||||
| changes: PostTagChange[] | |||||
| count: number }> => | |||||
| await apiGet ('/posts/changes', { params: { ...(id && { id }), page, limit } }) | |||||
| export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => { | export const toggleViewedFlg = async (id: string, viewed: boolean): Promise<void> => { | ||||
| await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`) | await (viewed ? apiPost : apiDelete) (`/posts/${ id }/viewed`) | ||||
| } | } | ||||
| @@ -1,12 +1,69 @@ | |||||
| import { QueryClient } from '@tanstack/react-query' | import { QueryClient } from '@tanstack/react-query' | ||||
| import { match } from 'path-to-regexp' | import { match } from 'path-to-regexp' | ||||
| import { fetchPost, fetchPosts } from '@/lib/posts' | |||||
| import { postsKeys } from '@/lib/queryKeys' | |||||
| import { fetchPost, fetchPosts, fetchPostChanges } from '@/lib/posts' | |||||
| import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' | |||||
| import { fetchTagByName } from '@/lib/tags' | |||||
| import { fetchWikiPage, | |||||
| fetchWikiPageByTitle, | |||||
| fetchWikiPages } from '@/lib/wiki' | |||||
| type Prefetcher = (qc: QueryClient, url: URL) => Promise<void> | type Prefetcher = (qc: QueryClient, url: URL) => Promise<void> | ||||
| const mPost = match<{ id: string }> ('/posts/:id') | const mPost = match<{ id: string }> ('/posts/:id') | ||||
| const mWiki = match<{ title: string }> ('/wiki/:title') | |||||
| const prefetchWikiPagesIndex: Prefetcher = async (qc, url) => { | |||||
| const title = url.searchParams.get ('title') ?? '' | |||||
| await qc.prefetchQuery ({ | |||||
| queryKey: wikiKeys.index ({ title }), | |||||
| queryFn: () => fetchWikiPages ({ title }) }) | |||||
| } | |||||
| const prefetchWikiPageShow: Prefetcher = async (qc, url) => { | |||||
| const m = mWiki (url.pathname) | |||||
| if (!(m)) | |||||
| return | |||||
| const title = decodeURIComponent (m.params.title) | |||||
| const version = url.searchParams.get ('version') || undefined | |||||
| const wikiPage = await qc.fetchQuery ({ | |||||
| queryKey: wikiKeys.show (title, { version }), | |||||
| queryFn: () => fetchWikiPageByTitle (title, { version }) }) | |||||
| if (wikiPage) | |||||
| { | |||||
| const effectiveId = String (wikiPage.id ?? '') | |||||
| await qc.prefetchQuery ({ | |||||
| queryKey: wikiKeys.show (effectiveId, { }), | |||||
| queryFn: () => fetchWikiPage (effectiveId, { } ) }) | |||||
| if (wikiPage.body) | |||||
| { | |||||
| await qc.prefetchQuery ({ | |||||
| queryKey: wikiKeys.index ({ }), | |||||
| queryFn: () => fetchWikiPages ({ }) }) | |||||
| } | |||||
| } | |||||
| const effectiveTitle = wikiPage?.title ?? title | |||||
| await qc.prefetchQuery ({ | |||||
| queryKey: tagsKeys.show (effectiveTitle), | |||||
| queryFn: () => fetchTagByName (effectiveTitle) }) | |||||
| if (version) | |||||
| return | |||||
| const p = { tags: effectiveTitle, match: 'all', page: 1, limit: 8 } as const | |||||
| await qc.prefetchQuery ({ | |||||
| queryKey: postsKeys.index (p), | |||||
| queryFn: () => fetchPosts (p) }) | |||||
| } | |||||
| const prefetchPostsIndex: Prefetcher = async (qc, url) => { | const prefetchPostsIndex: Prefetcher = async (qc, url) => { | ||||
| @@ -14,6 +71,7 @@ const prefetchPostsIndex: Prefetcher = async (qc, url) => { | |||||
| const m = url.searchParams.get ('match') === 'any' ? 'any' : 'all' | const m = url.searchParams.get ('match') === 'any' ? 'any' : 'all' | ||||
| const page = Number (url.searchParams.get ('page') || 1) | const page = Number (url.searchParams.get ('page') || 1) | ||||
| const limit = Number (url.searchParams.get ('limit') || 20) | const limit = Number (url.searchParams.get ('limit') || 20) | ||||
| await qc.prefetchQuery ({ | await qc.prefetchQuery ({ | ||||
| queryKey: postsKeys.index ({ tags, match: m, page, limit }), | queryKey: postsKeys.index ({ tags, match: m, page, limit }), | ||||
| queryFn: () => fetchPosts ({ tags, match: m, page, limit }) }) | queryFn: () => fetchPosts ({ tags, match: m, page, limit }) }) | ||||
| @@ -26,24 +84,40 @@ const prefetchPostShow: Prefetcher = async (qc, url) => { | |||||
| return | return | ||||
| const { id } = m.params | const { id } = m.params | ||||
| await qc.prefetchQuery ({ | await qc.prefetchQuery ({ | ||||
| queryKey: postsKeys.show (id), | queryKey: postsKeys.show (id), | ||||
| queryFn: () => fetchPost (id) }) | queryFn: () => fetchPost (id) }) | ||||
| } | } | ||||
| export const routePrefetchers: { | |||||
| test: (u: URL) => boolean | |||||
| run: Prefetcher }[] = [ | |||||
| const prefetchPostChanges: Prefetcher = async (qc, url) => { | |||||
| const id = url.searchParams.get ('id') | |||||
| const page = Number (url.searchParams.get ('page') || 1) | |||||
| const limit = Number (url.searchParams.get ('limit') || 20) | |||||
| await qc.prefetchQuery ({ | |||||
| queryKey: postsKeys.changes ({ ...(id && { id }), page, limit }), | |||||
| queryFn: () => fetchPostChanges ({ ...(id && { id }), page, limit }) }) | |||||
| } | |||||
| export const routePrefetchers: { test: (u: URL) => boolean; run: Prefetcher }[] = [ | |||||
| { test: u => u.pathname === '/' || u.pathname === '/posts', run: prefetchPostsIndex }, | { test: u => u.pathname === '/' || u.pathname === '/posts', run: prefetchPostsIndex }, | ||||
| { test: u => Boolean (mPost (u.pathname)), run: prefetchPostShow }] | |||||
| { test: u => (!(['/posts/new', '/posts/changes'].includes (u.pathname)) | |||||
| && Boolean (mPost (u.pathname))), | |||||
| run: prefetchPostShow }, | |||||
| { test: u => u.pathname === '/posts/changes', run: prefetchPostChanges }, | |||||
| { test: u => u.pathname === '/wiki', run: prefetchWikiPagesIndex }, | |||||
| { test: u => (!(['/wiki/new', '/wiki/changes'].includes (u.pathname)) | |||||
| && Boolean (mWiki (u.pathname))), | |||||
| run: prefetchWikiPageShow }] | |||||
| export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => { | export const prefetchForURL = async (qc: QueryClient, urlLike: string): Promise<void> => { | ||||
| const u = new URL (urlLike, location.origin) | const u = new URL (urlLike, location.origin) | ||||
| const jobs = routePrefetchers.filter (r => r.test (u)).map (r => r.run (qc, u)) | |||||
| if (jobs.length === 0) | |||||
| const r = routePrefetchers.find (x => x.test (u)) | |||||
| if (!(r)) | |||||
| return | return | ||||
| await Promise.all (jobs) | |||||
| await r.run (qc, u) | |||||
| } | } | ||||
| @@ -3,8 +3,15 @@ export const postsKeys = { | |||||
| index: (p: { tags: string; match: 'any' | 'all'; page: number; limit: number }) => | index: (p: { tags: string; match: 'any' | 'all'; page: number; limit: number }) => | ||||
| ['posts', 'index', p] as const, | ['posts', 'index', p] as const, | ||||
| show: (id: string) => ['posts', id] as const, | show: (id: string) => ['posts', id] as const, | ||||
| related: (id: string) => ['related', id] as const } | |||||
| related: (id: string) => ['related', id] as const, | |||||
| changes: (p: { id?: string; page: number; limit: number }) => | |||||
| ['posts', 'changes', p] as const } | |||||
| export const tagsKeys = { | |||||
| root: ['tags'] as const, | |||||
| show: (name: string) => ['tags', name] as const } | |||||
| export const wikiKeys = { | export const wikiKeys = { | ||||
| root: ['wiki'] as const, | |||||
| show: (title: string, p: { version: string }) => ['wiki', title, p] as const } | |||||
| root: ['wiki'] as const, | |||||
| index: (p: { title?: string }) => ['wiki', 'index', p] as const, | |||||
| show: (title: string, p: { version?: string }) => ['wiki', title, p] as const } | |||||
| @@ -3,5 +3,13 @@ import { apiGet } from '@/lib/api' | |||||
| import type { Tag } from '@/types' | import type { Tag } from '@/types' | ||||
| export const fetchTagByName = async (name: string): Promise<Tag> => | |||||
| await apiGet (`/tags/name/${ name }`) | |||||
| export const fetchTagByName = async (name: string): Promise<Tag | null> => { | |||||
| try | |||||
| { | |||||
| return await apiGet (`/tags/name/${ name }`) | |||||
| } | |||||
| catch | |||||
| { | |||||
| return null | |||||
| } | |||||
| } | |||||
| @@ -3,12 +3,29 @@ import { apiGet } from '@/lib/api' | |||||
| import type { WikiPage } from '@/types' | import type { WikiPage } from '@/types' | ||||
| export const fetchWikiPage = async (id: string): Promise<WikiPage> => | |||||
| await apiGet (`/wiki/${ id }`) | |||||
| export const fetchWikiPages = async ( | |||||
| { title }: { title?: string }, | |||||
| ): Promise<WikiPage[]> => | |||||
| await apiGet ('/wiki', { params: { title } }) | |||||
| export const fetchWikiPage = async ( | |||||
| id: string, | |||||
| { version }: { version?: string }, | |||||
| ): Promise<WikiPage> => | |||||
| await apiGet (`/wiki/${ id }`, { params: version ? { version } : { } }) | |||||
| export const fetchWikiPageByTitle = async ( | export const fetchWikiPageByTitle = async ( | ||||
| title: string, | title: string, | ||||
| { version }: { version?: string }, | { version }: { version?: string }, | ||||
| ): Promise<WikiPage> => | |||||
| await apiGet (`/wiki/title/${ title }`, { params: version ? { version } : { } }) | |||||
| ): Promise<WikiPage | null> => { | |||||
| try | |||||
| { | |||||
| return await apiGet (`/wiki/title/${ encodeURIComponent (title) }`, { params: { version } }) | |||||
| } | |||||
| catch | |||||
| { | |||||
| return null | |||||
| } | |||||
| } | |||||
| @@ -91,67 +91,65 @@ export default (({ user }: Props) => { | |||||
| : 'bg-gray-500 hover:bg-gray-600') | : 'bg-gray-500 hover:bg-gray-600') | ||||
| return ( | return ( | ||||
| <> | |||||
| <div className="md:flex md:flex-1"> | |||||
| <Helmet> | |||||
| {(post?.thumbnail || post?.thumbnailBase) && ( | |||||
| <meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)} | |||||
| {post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>} | |||||
| </Helmet> | |||||
| <div className="hidden md:block"> | |||||
| <TagDetailSidebar post={post ?? null}/> | |||||
| </div> | |||||
| <MainArea className="relative"> | |||||
| {post | |||||
| ? ( | |||||
| <> | |||||
| {(post.thumbnail || post.thumbnailBase) && ( | |||||
| <motion.div | |||||
| layoutId={`page-${ id }`} | |||||
| className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px] | |||||
| overflow-hidden rounded-xl pointer-events-none z-50" | |||||
| initial={{ opacity: 1 }} | |||||
| animate={{ opacity: 0 }} | |||||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||||
| <img src={post.thumbnail || post.thumbnailBase} | |||||
| alt={post.title || post.url} | |||||
| title={post.title || post.url || undefined} | |||||
| className="object-cover w-full h-full"/> | |||||
| </motion.div>)} | |||||
| <PostEmbed post={post}/> | |||||
| <Button onClick={() => changeViewedFlg.mutate ()} | |||||
| disabled={changeViewedFlg.isPending} | |||||
| className={cn ('text-white', viewedClass)}> | |||||
| {post.viewed ? '閲覧済' : '未閲覧'} | |||||
| </Button> | |||||
| <TabGroup> | |||||
| <Tab name="関聯"> | |||||
| {post.related.length > 0 | |||||
| ? <PostList posts={post.related}/> | |||||
| : 'まだないよ(笑)'} | |||||
| </Tab> | |||||
| {['admin', 'member'].some (r => user?.role === r) && ( | |||||
| <Tab name="編輯"> | |||||
| <PostEditForm | |||||
| post={post} | |||||
| onSave={newPost => { | |||||
| qc.setQueryData (postsKeys.show (postId), | |||||
| (prev: any) => newPost ?? prev) | |||||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||||
| toast ({ description: '更新しました.' }) | |||||
| }}/> | |||||
| </Tab>)} | |||||
| </TabGroup> | |||||
| </>) | |||||
| : 'Loading...'} | |||||
| </MainArea> | |||||
| <div className="md:hidden"> | |||||
| <TagDetailSidebar post={post ?? null}/> | |||||
| </div> | |||||
| <div className="md:flex md:flex-1"> | |||||
| <Helmet> | |||||
| {(post?.thumbnail || post?.thumbnailBase) && ( | |||||
| <meta name="thumbnail" content={post.thumbnail || post.thumbnailBase}/>)} | |||||
| {post && <title>{`${ post.title || post.url } | ${ SITE_TITLE }`}</title>} | |||||
| </Helmet> | |||||
| <div className="hidden md:block"> | |||||
| <TagDetailSidebar post={post ?? null}/> | |||||
| </div> | </div> | ||||
| </>) | |||||
| <MainArea className="relative"> | |||||
| {post | |||||
| ? ( | |||||
| <> | |||||
| {(post.thumbnail || post.thumbnailBase) && ( | |||||
| <motion.div | |||||
| layoutId={`page-${ id }`} | |||||
| className="absolute top-4 left-4 w-[min(640px,calc(100vw-2rem))] h-[360px] | |||||
| overflow-hidden rounded-xl pointer-events-none z-50" | |||||
| initial={{ opacity: 1 }} | |||||
| animate={{ opacity: 0 }} | |||||
| transition={{ duration: .2, ease: 'easeOut' }}> | |||||
| <img src={post.thumbnail || post.thumbnailBase} | |||||
| alt={post.title || post.url} | |||||
| title={post.title || post.url || undefined} | |||||
| className="object-cover w-full h-full"/> | |||||
| </motion.div>)} | |||||
| <PostEmbed post={post}/> | |||||
| <Button onClick={() => changeViewedFlg.mutate ()} | |||||
| disabled={changeViewedFlg.isPending} | |||||
| className={cn ('text-white', viewedClass)}> | |||||
| {post.viewed ? '閲覧済' : '未閲覧'} | |||||
| </Button> | |||||
| <TabGroup> | |||||
| <Tab name="関聯"> | |||||
| {post.related.length > 0 | |||||
| ? <PostList posts={post.related}/> | |||||
| : 'まだないよ(笑)'} | |||||
| </Tab> | |||||
| {['admin', 'member'].some (r => user?.role === r) && ( | |||||
| <Tab name="編輯"> | |||||
| <PostEditForm | |||||
| post={post} | |||||
| onSave={newPost => { | |||||
| qc.setQueryData (postsKeys.show (postId), | |||||
| (prev: any) => newPost ?? prev) | |||||
| qc.invalidateQueries ({ queryKey: postsKeys.root }) | |||||
| toast ({ description: '更新しました.' }) | |||||
| }}/> | |||||
| </Tab>)} | |||||
| </TabGroup> | |||||
| </>) | |||||
| : 'Loading...'} | |||||
| </MainArea> | |||||
| <div className="md:hidden"> | |||||
| <TagDetailSidebar post={post ?? null}/> | |||||
| </div> | |||||
| </div>) | |||||
| }) satisfies FC<Props> | }) satisfies FC<Props> | ||||
| @@ -1,24 +1,20 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import { useEffect, useState } from 'react' | |||||
| import { useQuery } from '@tanstack/react-query' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { Link, useLocation } from 'react-router-dom' | |||||
| import { useLocation } from 'react-router-dom' | |||||
| import TagLink from '@/components/TagLink' | import TagLink from '@/components/TagLink' | ||||
| import PrefetchLink from '@/components/PrefetchLink' | |||||
| import PageTitle from '@/components/common/PageTitle' | import PageTitle from '@/components/common/PageTitle' | ||||
| import Pagination from '@/components/common/Pagination' | import Pagination from '@/components/common/Pagination' | ||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||||
| import { SITE_TITLE } from '@/config' | |||||
| import { fetchPostChanges } from '@/lib/posts' | |||||
| import { postsKeys } from '@/lib/queryKeys' | |||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| import type { PostTagChange } from '@/types' | |||||
| export default (() => { | export default (() => { | ||||
| const [changes, setChanges] = useState<PostTagChange[]> ([]) | |||||
| const [totalPages, setTotalPages] = useState<number> (0) | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const query = new URLSearchParams (location.search) | const query = new URLSearchParams (location.search) | ||||
| const id = query.get ('id') | const id = query.get ('id') | ||||
| @@ -28,17 +24,11 @@ export default (() => { | |||||
| // 投稿列の結合で使用 | // 投稿列の結合で使用 | ||||
| let rowsCnt: number | let rowsCnt: number | ||||
| useEffect (() => { | |||||
| void (async () => { | |||||
| const res = await axios.get (`${ API_BASE_URL }/posts/changes`, | |||||
| { params: { ...(id && { id }), page, limit } }) | |||||
| const data = toCamel (res.data as any, { deep: true }) as { | |||||
| changes: PostTagChange[] | |||||
| count: number } | |||||
| setChanges (data.changes) | |||||
| setTotalPages (Math.ceil (data.count / limit)) | |||||
| }) () | |||||
| }, [id, page, limit]) | |||||
| const { data, isLoading: loading } = useQuery ({ | |||||
| queryKey: postsKeys.changes ({ ...(id && { id }), page, limit }), | |||||
| queryFn: () => fetchPostChanges ({ ...(id && { id }), page, limit }) }) | |||||
| const changes = data?.changes ?? [] | |||||
| const totalPages = data ? Math.ceil (data.count / limit) : 0 | |||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| @@ -48,57 +38,60 @@ export default (() => { | |||||
| <PageTitle> | <PageTitle> | ||||
| 耕作履歴 | 耕作履歴 | ||||
| {id && <>: 投稿 {<Link to={`/posts/${ id }`}>#{id}</Link>}</>} | |||||
| {id && <>: 投稿 {<PrefetchLink to={`/posts/${ id }`}>#{id}</PrefetchLink>}</>} | |||||
| </PageTitle> | </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, i) => { | |||||
| let withPost = i === 0 || change.post.id !== changes[i - 1].post.id | |||||
| if (withPost) | |||||
| { | |||||
| rowsCnt = 1 | |||||
| for (let j = i + 1; | |||||
| (j < changes.length | |||||
| && change.post.id === changes[j].post.id); | |||||
| ++j) | |||||
| ++rowsCnt | |||||
| } | |||||
| return ( | |||||
| <tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}> | |||||
| {withPost && ( | |||||
| <td className="align-top" rowSpan={rowsCnt}> | |||||
| <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> | |||||
| {loading ? 'Loading...' : ( | |||||
| <> | |||||
| <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, i) => { | |||||
| let withPost = i === 0 || change.post.id !== changes[i - 1].post.id | |||||
| if (withPost) | |||||
| { | |||||
| rowsCnt = 1 | |||||
| for (let j = i + 1; | |||||
| (j < changes.length | |||||
| && change.post.id === changes[j].post.id); | |||||
| ++j) | |||||
| ++rowsCnt | |||||
| } | |||||
| return ( | |||||
| <tr key={`${ change.timestamp }-${ change.post.id }-${ change.tag.id }`}> | |||||
| {withPost && ( | |||||
| <td className="align-top" rowSpan={rowsCnt}> | |||||
| <PrefetchLink 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"/> | |||||
| </PrefetchLink> | |||||
| </td>)} | |||||
| <td> | |||||
| <TagLink tag={change.tag} withWiki={false} withCount={false}/> | |||||
| {`を${ change.changeType === 'add' ? '記載' : '消除' }`} | |||||
| </td> | |||||
| <td> | |||||
| {change.user ? ( | |||||
| <PrefetchLink to={`/users/${ change.user.id }`}> | |||||
| {change.user.name} | |||||
| </PrefetchLink>) : 'bot 操作'} | |||||
| <br/> | |||||
| {change.timestamp} | |||||
| </td> | |||||
| </tr>) | |||||
| })} | |||||
| </tbody> | |||||
| </table> | |||||
| <Pagination page={page} totalPages={totalPages}/> | |||||
| <Pagination page={page} totalPages={totalPages}/> | |||||
| </>)} | |||||
| </MainArea>) | </MainArea>) | ||||
| }) satisfies FC | }) satisfies FC | ||||
| @@ -1,4 +1,3 @@ | |||||
| import axios from 'axios' | |||||
| import { useEffect, useState, useRef } from 'react' | import { useEffect, useState, useRef } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { useNavigate } from 'react-router-dom' | import { useNavigate } from 'react-router-dom' | ||||
| @@ -11,7 +10,8 @@ import PageTitle from '@/components/common/PageTitle' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||||
| import { SITE_TITLE } from '@/config' | |||||
| import { apiGet, apiPost } from '@/lib/api' | |||||
| import Forbidden from '@/pages/Forbidden' | import Forbidden from '@/pages/Forbidden' | ||||
| import type { FC } from 'react' | import type { FC } from 'react' | ||||
| @@ -55,9 +55,7 @@ export default (({ user }: Props) => { | |||||
| try | try | ||||
| { | { | ||||
| await axios.post (`${ API_BASE_URL }/posts`, formData, { headers: { | |||||
| 'Content-Type': 'multipart/form-data', | |||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | |||||
| await apiPost ('/posts', formData, { headers: { 'Content-Type': 'multipart/form-data' } }) | |||||
| toast ({ title: '投稿成功!' }) | toast ({ title: '投稿成功!' }) | ||||
| navigate ('/posts') | navigate ('/posts') | ||||
| } | } | ||||
| @@ -91,10 +89,7 @@ export default (({ user }: Props) => { | |||||
| const fetchTitle = async () => { | const fetchTitle = async () => { | ||||
| setTitle ('') | setTitle ('') | ||||
| setTitleLoading (true) | setTitleLoading (true) | ||||
| const res = await axios.get (`${ API_BASE_URL }/preview/title`, { | |||||
| params: { url }, | |||||
| headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | |||||
| const data = res.data as { title: string } | |||||
| const data = await apiGet<{ title: string }> ('/preview/title', { params: { url } }) | |||||
| setTitle (data.title || '') | setTitle (data.title || '') | ||||
| setTitleLoading (false) | setTitleLoading (false) | ||||
| } | } | ||||
| @@ -105,11 +100,8 @@ export default (({ user }: Props) => { | |||||
| setThumbnailLoading (true) | setThumbnailLoading (true) | ||||
| if (thumbnailPreview) | if (thumbnailPreview) | ||||
| URL.revokeObjectURL (thumbnailPreview) | URL.revokeObjectURL (thumbnailPreview) | ||||
| const res = await axios.get (`${ API_BASE_URL }/preview/thumbnail`, { | |||||
| params: { url }, | |||||
| headers: { 'X-Transfer-Code': localStorage.getItem ('user_code') || '' }, | |||||
| responseType: 'blob' }) | |||||
| const data = res.data as Blob | |||||
| const data = await apiGet<Blob> ('/preview/thumbnail', | |||||
| { params: { url }, responseType: 'blob' }) | |||||
| const imageURL = URL.createObjectURL (data) | const imageURL = URL.createObjectURL (data) | ||||
| setThumbnailPreview (imageURL) | setThumbnailPreview (imageURL) | ||||
| setThumbnailFile (new File ([data], | setThumbnailFile (new File ([data], | ||||
| @@ -1,5 +1,3 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import { useEffect, useRef, useState } from 'react' | import { useEffect, useRef, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| @@ -8,7 +6,8 @@ import SectionTitle from '@/components/common/SectionTitle' | |||||
| import TextArea from '@/components/common/TextArea' | import TextArea from '@/components/common/TextArea' | ||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||||
| import { SITE_TITLE } from '@/config' | |||||
| import { apiGet, apiPut } from '@/lib/api' | |||||
| import type { NicoTag, Tag, User } from '@/types' | import type { NicoTag, Tag, User } from '@/types' | ||||
| @@ -29,10 +28,8 @@ export default ({ user }: Props) => { | |||||
| const loadMore = async (withCursor: boolean) => { | const loadMore = async (withCursor: boolean) => { | ||||
| setLoading (true) | setLoading (true) | ||||
| const res = await axios.get (`${ API_BASE_URL }/tags/nico`, { | |||||
| params: { ...(withCursor ? { cursor } : { }) } }) | |||||
| const data = toCamel (res.data as any, { deep: true }) as { tags: NicoTag[] | |||||
| nextCursor: string } | |||||
| const data = await apiGet<{ tags: NicoTag[]; nextCursor: string }> ( | |||||
| '/tags/nico', { params: withCursor ? { cursor } : { } }) | |||||
| setNicoTags (tags => [...(withCursor ? tags : []), ...data.tags]) | setNicoTags (tags => [...(withCursor ? tags : []), ...data.tags]) | ||||
| setCursor (data.nextCursor) | setCursor (data.nextCursor) | ||||
| @@ -53,10 +50,8 @@ export default ({ user }: Props) => { | |||||
| const formData = new FormData | const formData = new FormData | ||||
| formData.append ('tags', rawTags[id]) | formData.append ('tags', rawTags[id]) | ||||
| const res = await axios.put (`${ API_BASE_URL }/tags/nico/${ id }`, formData, { headers: { | |||||
| 'Content-Type': 'multipart/form-data', | |||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | |||||
| const data = toCamel (res.data as any, { deep: true }) as Tag[] | |||||
| const data = await apiPut<Tag[]> (`/tags/nico/${ id }`, formData, | |||||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||||
| setNicoTags (nicoTags => { | setNicoTags (nicoTags => { | ||||
| nicoTags.find (t => t.id === id)!.linkedTags = data | nicoTags.find (t => t.id === id)!.linkedTags = data | ||||
| return [...nicoTags] | return [...nicoTags] | ||||
| @@ -1,4 +1,3 @@ | |||||
| import axios from 'axios' | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| @@ -10,7 +9,8 @@ import InheritDialogue from '@/components/users/InheritDialogue' | |||||
| import UserCodeDialogue from '@/components/users/UserCodeDialogue' | import UserCodeDialogue from '@/components/users/UserCodeDialogue' | ||||
| import { Button } from '@/components/ui/button' | import { Button } from '@/components/ui/button' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||||
| import { SITE_TITLE } from '@/config' | |||||
| import { apiPut } from '@/lib/api' | |||||
| import type { User } from '@/types' | import type { User } from '@/types' | ||||
| @@ -32,10 +32,9 @@ export default ({ user, setUser }: Props) => { | |||||
| try | try | ||||
| { | { | ||||
| const res = await axios.put (`${ API_BASE_URL }/users/${ user.id }`, formData, { | |||||
| headers: { 'Content-Type': 'multipart/form-data', | |||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | |||||
| const data = res.data as User | |||||
| const data = await apiPut<User> ( | |||||
| `/users/${ user.id }`, formData, | |||||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||||
| setUser (user => ({ ...user, ...data })) | setUser (user => ({ ...user, ...data })) | ||||
| toast ({ title: '設定を更新しました.' }) | toast ({ title: '設定を更新しました.' }) | ||||
| } | } | ||||
| @@ -1,4 +1,5 @@ | |||||
| import { useEffect, useState } from 'react' | |||||
| import { useQuery } from '@tanstack/react-query' | |||||
| import { useEffect, useMemo } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { useLocation, useNavigate, useParams } from 'react-router-dom' | import { useLocation, useNavigate, useParams } from 'react-router-dom' | ||||
| @@ -12,10 +13,11 @@ import MainArea from '@/components/layout/MainArea' | |||||
| import { SITE_TITLE } from '@/config' | import { SITE_TITLE } from '@/config' | ||||
| import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' | import { WikiIdBus } from '@/lib/eventBus/WikiIdBus' | ||||
| import { fetchPosts } from '@/lib/posts' | import { fetchPosts } from '@/lib/posts' | ||||
| import { postsKeys, tagsKeys, wikiKeys } from '@/lib/queryKeys' | |||||
| import { fetchTagByName } from '@/lib/tags' | import { fetchTagByName } from '@/lib/tags' | ||||
| import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki' | import { fetchWikiPage, fetchWikiPageByTitle } from '@/lib/wiki' | ||||
| import type { Post, Tag, WikiPage } from '@/types' | |||||
| import type { Tag } from '@/types' | |||||
| export default () => { | export default () => { | ||||
| @@ -25,76 +27,57 @@ export default () => { | |||||
| const location = useLocation () | const location = useLocation () | ||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| const defaultTag = { name: title, category: 'general' } as Tag | |||||
| const [posts, setPosts] = useState<Post[]> ([]) | |||||
| const [tag, setTag] = useState (defaultTag) | |||||
| const [wikiPage, setWikiPage] = useState<WikiPage | null | undefined> (undefined) | |||||
| const defaultTag = useMemo (() => ({ name: title, category: 'general' } as Tag), [title]) | |||||
| const query = new URLSearchParams (location.search) | const query = new URLSearchParams (location.search) | ||||
| const version = query.get ('version') | |||||
| const version = query.get ('version') || undefined | |||||
| const { data: wikiPage, isLoading: loading } = useQuery ({ | |||||
| enabled: Boolean (title) && !(/^\d+$/.test (title)), | |||||
| queryKey: wikiKeys.show (title, { version }), | |||||
| queryFn: () => fetchWikiPageByTitle (title, { version }) }) | |||||
| const effectiveTitle = wikiPage?.title ?? title | |||||
| const { data: tag } = useQuery ({ | |||||
| enabled: Boolean (effectiveTitle), | |||||
| queryKey: tagsKeys.show (effectiveTitle), | |||||
| queryFn: () => fetchTagByName (effectiveTitle) }) | |||||
| const { data } = useQuery ({ | |||||
| enabled: Boolean (effectiveTitle) && !(version), | |||||
| queryKey: postsKeys.index ({ tags: effectiveTitle, match: 'all', page: 1, limit: 8 }), | |||||
| queryFn: () => fetchPosts ({ tags: effectiveTitle, match: 'all', page: 1, limit: 8 }) }) | |||||
| const posts = data?.posts || [] | |||||
| useEffect (() => { | useEffect (() => { | ||||
| if (/^\d+$/.test (title)) | |||||
| { | |||||
| void (async () => { | |||||
| setWikiPage (undefined) | |||||
| try | |||||
| { | |||||
| const data = await fetchWikiPage (title) | |||||
| navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) | |||||
| } | |||||
| catch | |||||
| { | |||||
| ; | |||||
| } | |||||
| }) () | |||||
| return | |||||
| } | |||||
| if (!(wikiPage)) | |||||
| return | |||||
| void (async () => { | |||||
| setWikiPage (undefined) | |||||
| try | |||||
| { | |||||
| const data = await fetchWikiPageByTitle (title, version ? { version } : { }) | |||||
| if (data.title !== title) | |||||
| navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) | |||||
| setWikiPage (data) | |||||
| WikiIdBus.set (data.id) | |||||
| } | |||||
| catch | |||||
| { | |||||
| setWikiPage (null) | |||||
| } | |||||
| }) () | |||||
| WikiIdBus.set (wikiPage.id) | |||||
| setPosts ([]) | |||||
| void (async () => { | |||||
| try | |||||
| { | |||||
| const data = await fetchPosts ({ tags: title, match: 'all', limit: 8 }) | |||||
| setPosts (data.posts) | |||||
| } | |||||
| catch | |||||
| { | |||||
| ; | |||||
| } | |||||
| }) () | |||||
| if (wikiPage.title !== title) | |||||
| navigate (`/wiki/${ encodeURIComponent(wikiPage.title) }`, { replace: true }) | |||||
| return () => WikiIdBus.set (null) | |||||
| }, [wikiPage, title, navigate]) | |||||
| useEffect (() => { | |||||
| if (!(/^\d+$/.test (title))) | |||||
| return | |||||
| void (async () => { | void (async () => { | ||||
| try | try | ||||
| { | { | ||||
| setTag (await fetchTagByName (title)) | |||||
| const data = await fetchWikiPage (title, { }) | |||||
| navigate (`/wiki/${ encodeURIComponent(data.title) }`, { replace: true }) | |||||
| } | } | ||||
| catch | catch | ||||
| { | { | ||||
| setTag (defaultTag) | |||||
| ; | |||||
| } | } | ||||
| }) () | }) () | ||||
| return () => WikiIdBus.set (null) | |||||
| }, [title, location.search]) | |||||
| }, [title, navigate]) | |||||
| return ( | return ( | ||||
| <MainArea> | <MainArea> | ||||
| @@ -104,7 +87,8 @@ export default () => { | |||||
| </Helmet> | </Helmet> | ||||
| {(wikiPage && version) && ( | {(wikiPage && version) && ( | ||||
| <div className="text-sm flex gap-3 items-center justify-center border border-gray-700 rounded px-2 py-1 mb-4"> | |||||
| <div className="text-sm flex gap-3 items-center justify-center | |||||
| border border-gray-700 rounded px-2 py-1 mb-4"> | |||||
| {wikiPage.pred ? ( | {wikiPage.pred ? ( | ||||
| <PrefetchLink to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.pred }`}> | <PrefetchLink to={`/wiki/${ encodeURIComponent (title) }?version=${ wikiPage.pred }`}> | ||||
| < 古 | < 古 | ||||
| @@ -119,15 +103,13 @@ export default () => { | |||||
| </div>)} | </div>)} | ||||
| <PageTitle> | <PageTitle> | ||||
| <TagLink tag={tag} | |||||
| <TagLink tag={tag ?? defaultTag} | |||||
| withWiki={false} | withWiki={false} | ||||
| withCount={false} | withCount={false} | ||||
| {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> | {...(version && { to: `/wiki/${ encodeURIComponent (title) }` })}/> | ||||
| </PageTitle> | </PageTitle> | ||||
| <div className="prose mx-auto p-4"> | <div className="prose mx-auto p-4"> | ||||
| {wikiPage === undefined | |||||
| ? 'Loading...' | |||||
| : <WikiBody title={title} body={wikiPage?.body}/>} | |||||
| {loading ? 'Loading...' : <WikiBody title={title} body={wikiPage?.body}/>} | |||||
| </div> | </div> | ||||
| {(!(version) && posts.length > 0) && ( | {(!(version) && posts.length > 0) && ( | ||||
| @@ -1,12 +1,11 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { useLocation, useParams } from 'react-router-dom' | import { useLocation, useParams } from 'react-router-dom' | ||||
| import PageTitle from '@/components/common/PageTitle' | import PageTitle from '@/components/common/PageTitle' | ||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||||
| import { SITE_TITLE } from '@/config' | |||||
| import { apiGet } from '@/lib/api' | |||||
| import { cn } from '@/lib/utils' | import { cn } from '@/lib/utils' | ||||
| import type { WikiPageDiff } from '@/types' | import type { WikiPageDiff } from '@/types' | ||||
| @@ -25,8 +24,7 @@ export default () => { | |||||
| useEffect (() => { | useEffect (() => { | ||||
| void (async () => { | void (async () => { | ||||
| const res = await axios.get (`${ API_BASE_URL }/wiki/${ id }/diff`, { params: { from, to } }) | |||||
| setDiff (toCamel (res.data as any, { deep: true }) as WikiPageDiff) | |||||
| setDiff (await apiGet<WikiPageDiff> (`/wiki/${ id }/diff`, { params: { from, to } })) | |||||
| }) () | }) () | ||||
| }, []) | }, []) | ||||
| @@ -1,4 +1,4 @@ | |||||
| import axios from 'axios' | |||||
| import { useQueryClient } from '@tanstack/react-query' | |||||
| import MarkdownIt from 'markdown-it' | import MarkdownIt from 'markdown-it' | ||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| @@ -7,7 +7,9 @@ import { useParams, useNavigate } from 'react-router-dom' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||||
| import { SITE_TITLE } from '@/config' | |||||
| import { apiGet, apiPut } from '@/lib/api' | |||||
| import { wikiKeys } from '@/lib/queryKeys' | |||||
| import Forbidden from '@/pages/Forbidden' | import Forbidden from '@/pages/Forbidden' | ||||
| import 'react-markdown-editor-lite/lib/index.css' | import 'react-markdown-editor-lite/lib/index.css' | ||||
| @@ -29,6 +31,8 @@ export default (({ user }: Props) => { | |||||
| const navigate = useNavigate () | const navigate = useNavigate () | ||||
| const qc = useQueryClient () | |||||
| const [body, setBody] = useState ('') | const [body, setBody] = useState ('') | ||||
| const [loading, setLoading] = useState (true) | const [loading, setLoading] = useState (true) | ||||
| const [title, setTitle] = useState ('') | const [title, setTitle] = useState ('') | ||||
| @@ -40,9 +44,11 @@ export default (({ user }: Props) => { | |||||
| try | try | ||||
| { | { | ||||
| await axios.put (`${ API_BASE_URL }/wiki/${ id }`, formData, { headers: { | |||||
| 'Content-Type': 'multipart/form-data', | |||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') ?? '' } }) | |||||
| await apiPut (`/wiki/${ id }`, formData, | |||||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||||
| qc.setQueryData (wikiKeys.show (title, { }), | |||||
| (prev: WikiPage) => ({ ...prev, title, body })) | |||||
| qc.invalidateQueries ({ queryKey: wikiKeys.root }) | |||||
| toast ({ title: '投稿成功!' }) | toast ({ title: '投稿成功!' }) | ||||
| navigate (`/wiki/${ title }`) | navigate (`/wiki/${ title }`) | ||||
| } | } | ||||
| @@ -55,8 +61,7 @@ export default (({ user }: Props) => { | |||||
| useEffect (() => { | useEffect (() => { | ||||
| void (async () => { | void (async () => { | ||||
| setLoading (true) | setLoading (true) | ||||
| const res = await axios.get (`${ API_BASE_URL }/wiki/${ id }`) | |||||
| const data = res.data as WikiPage | |||||
| const data = await apiGet<WikiPage> (`/wiki/${ id }`) | |||||
| setTitle (data.title) | setTitle (data.title) | ||||
| setBody (data.body) | setBody (data.body) | ||||
| setLoading (false) | setLoading (false) | ||||
| @@ -1,11 +1,11 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import { useEffect, useState } from 'react' | import { useEffect, useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { Link, useLocation } from 'react-router-dom' | |||||
| import { useLocation } from 'react-router-dom' | |||||
| import PrefetchLink from '@/components/PrefetchLink' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||||
| import { SITE_TITLE } from '@/config' | |||||
| import { apiGet } from '@/lib/api' | |||||
| import type { WikiPageChange } from '@/types' | import type { WikiPageChange } from '@/types' | ||||
| @@ -19,9 +19,7 @@ export default () => { | |||||
| useEffect (() => { | useEffect (() => { | ||||
| void (async () => { | void (async () => { | ||||
| const res = await axios.get (`${ API_BASE_URL }/wiki/changes`, | |||||
| { params: { ...(id ? { id } : { }) } }) | |||||
| setChanges (toCamel (res.data as any, { deep: true }) as WikiPageChange[]) | |||||
| setChanges (await apiGet<WikiPageChange[]> ('/wiki/changes', { params: id ? { id } : { } })) | |||||
| }) () | }) () | ||||
| }, [location.search]) | }, [location.search]) | ||||
| @@ -44,22 +42,24 @@ export default () => { | |||||
| <tr key={change.revisionId}> | <tr key={change.revisionId}> | ||||
| <td> | <td> | ||||
| {change.pred != null && ( | {change.pred != null && ( | ||||
| <Link to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.revisionId }`}> | |||||
| <PrefetchLink | |||||
| to={`/wiki/${ change.wikiPage.id }/diff?from=${ change.pred }&to=${ change.revisionId }`}> | |||||
| 差分 | 差分 | ||||
| </Link>)} | |||||
| </PrefetchLink>)} | |||||
| </td> | </td> | ||||
| <td className="p-2"> | <td className="p-2"> | ||||
| <Link to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}> | |||||
| <PrefetchLink | |||||
| to={`/wiki/${ encodeURIComponent (change.wikiPage.title) }?version=${ change.revisionId }`}> | |||||
| {change.wikiPage.title} | {change.wikiPage.title} | ||||
| </Link> | |||||
| </PrefetchLink> | |||||
| </td> | </td> | ||||
| <td className="p-2"> | <td className="p-2"> | ||||
| {change.pred == null ? '新規' : '更新'} | {change.pred == null ? '新規' : '更新'} | ||||
| </td> | </td> | ||||
| <td className="p-2"> | <td className="p-2"> | ||||
| <Link to={`/users/${ change.user.id }`}> | |||||
| <PrefetchLink to={`/users/${ change.user.id }`}> | |||||
| {change.user.name} | {change.user.name} | ||||
| </Link> | |||||
| </PrefetchLink> | |||||
| <br/> | <br/> | ||||
| {change.timestamp} | {change.timestamp} | ||||
| </td> | </td> | ||||
| @@ -1,4 +1,3 @@ | |||||
| import axios from 'axios' | |||||
| import MarkdownIt from 'markdown-it' | import MarkdownIt from 'markdown-it' | ||||
| import { useState } from 'react' | import { useState } from 'react' | ||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| @@ -7,7 +6,8 @@ import { useLocation, useNavigate } from 'react-router-dom' | |||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { toast } from '@/components/ui/use-toast' | import { toast } from '@/components/ui/use-toast' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||||
| import { SITE_TITLE } from '@/config' | |||||
| import { apiPost } from '@/lib/api' | |||||
| import Forbidden from '@/pages/Forbidden' | import Forbidden from '@/pages/Forbidden' | ||||
| import 'react-markdown-editor-lite/lib/index.css' | import 'react-markdown-editor-lite/lib/index.css' | ||||
| @@ -39,10 +39,8 @@ export default ({ user }: Props) => { | |||||
| try | try | ||||
| { | { | ||||
| const res = await axios.post (`${ API_BASE_URL }/wiki`, formData, { headers: { | |||||
| 'Content-Type': 'multipart/form-data', | |||||
| 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) | |||||
| const data = res.data as WikiPage | |||||
| const data = await apiPost<WikiPage> ('/wiki', formData, | |||||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||||
| toast ({ title: '投稿成功!' }) | toast ({ title: '投稿成功!' }) | ||||
| navigate (`/wiki/${ data.title }`) | navigate (`/wiki/${ data.title }`) | ||||
| } | } | ||||
| @@ -1,12 +1,13 @@ | |||||
| import axios from 'axios' | |||||
| import toCamel from 'camelcase-keys' | |||||
| import React, { useEffect, useState } from 'react' | |||||
| import { useEffect, useState } from 'react' | |||||
| import { Helmet } from 'react-helmet-async' | import { Helmet } from 'react-helmet-async' | ||||
| import { Link } from 'react-router-dom' | |||||
| import PrefetchLink from '@/components/PrefetchLink' | |||||
| import SectionTitle from '@/components/common/SectionTitle' | import SectionTitle from '@/components/common/SectionTitle' | ||||
| import MainArea from '@/components/layout/MainArea' | import MainArea from '@/components/layout/MainArea' | ||||
| import { API_BASE_URL, SITE_TITLE } from '@/config' | |||||
| import { SITE_TITLE } from '@/config' | |||||
| import { apiGet } from '@/lib/api' | |||||
| import type { FormEvent } from 'react' | |||||
| import type { WikiPage } from '@/types' | import type { WikiPage } from '@/types' | ||||
| @@ -17,11 +18,10 @@ export default () => { | |||||
| const [results, setResults] = useState<WikiPage[]> ([]) | const [results, setResults] = useState<WikiPage[]> ([]) | ||||
| const search = async () => { | const search = async () => { | ||||
| const res = await axios.get (`${ API_BASE_URL }/wiki/search`, { params: { title } }) | |||||
| setResults (toCamel (res.data as any, { deep: true }) as WikiPage[]) | |||||
| setResults (await apiGet ('/wiki', { params: { title } })) | |||||
| } | } | ||||
| const handleSearch = (ev: React.FormEvent) => { | |||||
| const handleSearch = (ev: FormEvent) => { | |||||
| ev.preventDefault () | ev.preventDefault () | ||||
| search () | search () | ||||
| } | } | ||||
| @@ -78,9 +78,9 @@ export default () => { | |||||
| {results.map (page => ( | {results.map (page => ( | ||||
| <tr key={page.id}> | <tr key={page.id}> | ||||
| <td className="p-2"> | <td className="p-2"> | ||||
| <Link to={`/wiki/${ encodeURIComponent (page.title) }`}> | |||||
| <PrefetchLink to={`/wiki/${ encodeURIComponent (page.title) }`}> | |||||
| {page.title} | {page.title} | ||||
| </Link> | |||||
| </PrefetchLink> | |||||
| </td> | </td> | ||||
| <td className="p-2 text-gray-100 text-sm"> | <td className="p-2 text-gray-100 text-sm"> | ||||
| {page.updatedAt} | {page.updatedAt} | ||||