| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
ef6219dcb1 | #47 | 7 hours ago |
|
|
04b01bf1c6 | #47 | 1 day ago |
|
|
4c474d2bdf | #47 | 1 day ago |
| @@ -0,0 +1,38 @@ | |||
| require 'digest' | |||
| class WikiAssetsController < ApplicationController | |||
| def index | |||
| page_id = params[:wiki_page_id].to_i | |||
| page = WikiPage.find_by(id: page_id) | |||
| return head :not_found unless page | |||
| render json: page.assets | |||
| end | |||
| def create | |||
| return head :unauthorized unless current_user | |||
| return head :forbidden unless current_user.gte_member? | |||
| wiki_page_id = params[:wiki_page_id].to_i | |||
| page = WikiPage.find_by(id: wiki_page_id) | |||
| return head :not_found unless page | |||
| file = params[:file] | |||
| return head :bad_request if file.blank? | |||
| page.with_lock do | |||
| no = page.next_asset_no | |||
| alt_text = params[:alt_text].presence | |||
| sha256 = Digest::SHA256.file(file.tempfile.path).digest | |||
| asset = WikiAsset.new(wiki_page_id:, no:, alt_text:, sha256:, created_by_user: current_user) | |||
| asset.file.attach(file) | |||
| asset.save! | |||
| page.update!(next_asset_no: no + 1) | |||
| end | |||
| render json: asset | |||
| end | |||
| end | |||
| @@ -96,7 +96,7 @@ class WikiPagesController < ApplicationController | |||
| message = params[:message].presence | |||
| Wiki::Commit.content!(page:, body:, created_user: current_user, message:) | |||
| render json: WikiPageRepr.base(page), status: :created | |||
| render json: WikiPageRepr.base(page).merge(body:), status: :created | |||
| else | |||
| render json: { errors: page.errors.full_messages }, | |||
| status: :unprocessable_entity | |||
| @@ -107,7 +107,7 @@ class WikiPagesController < ApplicationController | |||
| return head :unauthorized unless current_user | |||
| return head :forbidden unless current_user.gte_member? | |||
| title = params[:title]&.strip | |||
| title = params[:title].to_s.strip | |||
| body = params[:body].to_s | |||
| return head :unprocessable_entity if title.blank? || body.blank? | |||
| @@ -126,7 +126,7 @@ class WikiPagesController < ApplicationController | |||
| message:, | |||
| base_revision_id:) | |||
| head :ok | |||
| render json: WikiPageRepr.base(page).merge(body:) | |||
| end | |||
| def search | |||
| @@ -0,0 +1,10 @@ | |||
| class WikiAsset < ApplicationRecord | |||
| self.primary_key = :wiki_page_id, :no | |||
| belongs_to :wiki_page | |||
| belongs_to :created_by_user, class_name: 'User' | |||
| has_one_attached :file | |||
| validates :file, presence: true | |||
| end | |||
| @@ -15,6 +15,8 @@ class WikiPage < ApplicationRecord | |||
| foreign_key: :redirect_page_id, | |||
| dependent: :nullify | |||
| has_many :assets, class_name: 'WikiAsset', dependent: :destroy | |||
| belongs_to :tag_name | |||
| validates :tag_name, presence: true | |||
| @@ -41,6 +41,8 @@ Rails.application.routes.draw do | |||
| get :exists | |||
| get :diff | |||
| end | |||
| resources :assets, controller: :wiki_assets, only: [:index, :create] | |||
| end | |||
| resources :posts, only: [:index, :show, :create, :update] do | |||
| @@ -0,0 +1,16 @@ | |||
| class CreateWikiAssets < ActiveRecord::Migration[8.0] | |||
| def change | |||
| create_table :wiki_assets, primary_key: [:wiki_page_id, :no] do |t| | |||
| t.references :wiki_page, null: false, foreign_key: true, index: false | |||
| t.integer :no, null: false | |||
| t.string :alt_text | |||
| t.column :sha256, 'binary(32)', null: false | |||
| t.references :created_by_user, null: false, foreign_key: { to_table: :users } | |||
| t.timestamps | |||
| end | |||
| add_index :wiki_assets, [:wiki_page_id, :sha256], unique: true | |||
| add_column :wiki_pages, :next_asset_no, :integer, null: false, default: 1 | |||
| end | |||
| end | |||
| @@ -10,7 +10,7 @@ | |||
| # | |||
| # It's strongly recommended that you check this file into your version control system. | |||
| ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do | |||
| ActiveRecord::Schema[8.0].define(version: 2026_03_23_192300) do | |||
| create_table "active_storage_attachments", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.string "name", null: false | |||
| t.string "record_type", null: false | |||
| @@ -239,6 +239,18 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do | |||
| t.datetime "updated_at", null: false | |||
| end | |||
| create_table "wiki_assets", primary_key: ["wiki_page_id", "no"], charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.bigint "wiki_page_id", null: false | |||
| t.integer "no", null: false | |||
| t.string "alt_text" | |||
| t.binary "sha256", limit: 32, null: false | |||
| t.bigint "created_by_user_id", null: false | |||
| t.datetime "created_at", null: false | |||
| t.datetime "updated_at", null: false | |||
| t.index ["created_by_user_id"], name: "index_wiki_assets_on_created_by_user_id" | |||
| t.index ["wiki_page_id", "sha256"], name: "index_wiki_assets_on_wiki_page_id_and_sha256", unique: true | |||
| end | |||
| create_table "wiki_lines", charset: "utf8mb4", collation: "utf8mb4_0900_ai_ci", force: :cascade do |t| | |||
| t.string "sha256", limit: 64, null: false | |||
| t.text "body", null: false | |||
| @@ -254,6 +266,7 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do | |||
| t.datetime "created_at", null: false | |||
| t.datetime "updated_at", null: false | |||
| t.datetime "discarded_at" | |||
| t.integer "next_asset_no", default: 1, null: false | |||
| t.index ["created_user_id"], name: "index_wiki_pages_on_created_user_id" | |||
| t.index ["discarded_at"], name: "index_wiki_pages_on_discarded_at" | |||
| t.index ["tag_name_id"], name: "index_wiki_pages_on_tag_name_id", unique: true | |||
| @@ -320,6 +333,8 @@ ActiveRecord::Schema[8.0].define(version: 2026_03_17_015000) do | |||
| add_foreign_key "user_ips", "users" | |||
| add_foreign_key "user_post_views", "posts" | |||
| add_foreign_key "user_post_views", "users" | |||
| add_foreign_key "wiki_assets", "users", column: "created_by_user_id" | |||
| add_foreign_key "wiki_assets", "wiki_pages" | |||
| add_foreign_key "wiki_pages", "tag_names" | |||
| add_foreign_key "wiki_pages", "users", column: "created_user_id" | |||
| add_foreign_key "wiki_pages", "users", column: "updated_user_id" | |||
| @@ -4,6 +4,7 @@ import ReactMarkdown from 'react-markdown' | |||
| import remarkGFM from 'remark-gfm' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| import WikiMarkdown from '@/components/WikiMarkdown' | |||
| import SectionTitle from '@/components/common/SectionTitle' | |||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | |||
| import { wikiKeys } from '@/lib/queryKeys' | |||
| @@ -16,33 +17,6 @@ import type { Components } from 'react-markdown' | |||
| type Props = { title: string | |||
| body?: string } | |||
| const mdComponents = { h1: ({ children }) => <SectionTitle>{children}</SectionTitle>, | |||
| h2: ({ children }) => <SubsectionTitle>{children}</SubsectionTitle>, | |||
| ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>, | |||
| ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>, | |||
| a: (({ href, children }) => ( | |||
| ['/', '.'].some (e => href?.startsWith (e)) | |||
| ? <PrefetchLink to={href!}>{children}</PrefetchLink> | |||
| : ( | |||
| <a href={href} | |||
| target="_blank" | |||
| rel="noopener noreferrer"> | |||
| {children} | |||
| </a>))) } as const satisfies Components | |||
| export default (({ title, body }: Props) => { | |||
| 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 ( | |||
| () => [() => remarkWikiAutoLink (pageNames), remarkGFM], [pageNames]) | |||
| return ( | |||
| <ReactMarkdown components={mdComponents} remarkPlugins={remarkPlugins}> | |||
| {body || `このページは存在しません。[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`} | |||
| </ReactMarkdown>) | |||
| }) satisfies FC<Props> | |||
| export default (({ title, body }: Props) => | |||
| <WikiMarkdown title={title} body={body ?? ''}/>) satisfies FC<Props> | |||
| @@ -0,0 +1,75 @@ | |||
| import MarkdownIt from 'markdown-it' | |||
| import { useEffect, useState } from 'react' | |||
| import MdEditor from 'react-markdown-editor-lite' | |||
| import Label from '@/components/common/Label' | |||
| import type { FC } from 'react' | |||
| const mdParser = new MarkdownIt | |||
| type Props = { | |||
| title: string | |||
| body: string | |||
| onSubmit: (title: string, body: string) => void | |||
| id?: number | null } | |||
| export default (({ title: initTitle, body: initBody, onSubmit, id }: Props) => { | |||
| const forEdit = id != null | |||
| const [title, setTitle] = useState<string> (initTitle) | |||
| const [body, setBody] = useState<string> (initBody) | |||
| useEffect (() => { | |||
| setTitle (initTitle) | |||
| setBody (initBody) | |||
| }, [initTitle, initBody]) | |||
| const handleImageUpload = async (file: File) => { | |||
| if (!(forEdit)) | |||
| throw new Error ('画像は Wiki 作成前に追加することができません.') | |||
| const formData = new FormData | |||
| formData.append ('file', file) | |||
| const asset = await apiPost<WikiAsset> ( | |||
| `/wiki/${ id }/assets`, | |||
| formData, | |||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||
| return `{{img:${ asset.no }}}` | |||
| } | |||
| return ( | |||
| <> | |||
| {/* タイトル */} | |||
| {/* TODO: タグ補完 */} | |||
| <div> | |||
| <Label>タイトル</Label> | |||
| <input | |||
| type="text" | |||
| value={title} | |||
| onChange={e => setTitle (e.target.value)} | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* 本文 */} | |||
| <div> | |||
| <Label>本文</Label> | |||
| <MdEditor | |||
| value={body} | |||
| style={{ height: '500px' }} | |||
| renderHTML={text => mdParser.render (text)} | |||
| onChange={({ text }) => setBody (text)} | |||
| onImageUpload={handleImageUpload}/> | |||
| </div> | |||
| {/* 送信 */} | |||
| <button | |||
| onClick={() => onSubmit (title, body)} | |||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||
| {forEdit ? '編輯' : '追加'} | |||
| </button> | |||
| </>) | |||
| }) satisfies FC<Props> | |||
| @@ -0,0 +1,77 @@ | |||
| import { useQuery } from '@tanstack/react-query' | |||
| import { useMemo } from 'react' | |||
| import ReactMarkdown from 'react-markdown' | |||
| import remarkGFM from 'remark-gfm' | |||
| import PrefetchLink from '@/components/PrefetchLink' | |||
| import SectionTitle from '@/components/common/SectionTitle' | |||
| import SubsectionTitle from '@/components/common/SubsectionTitle' | |||
| import { wikiKeys } from '@/lib/queryKeys' | |||
| import remarkWikiAutoLink from '@/lib/remark-wiki-autolink' | |||
| import { fetchWikiPages } from '@/lib/wiki' | |||
| import type { FC } from 'react' | |||
| import type { Components } from 'react-markdown' | |||
| type Props = { | |||
| title?: string | |||
| body: string | |||
| preview?: boolean } | |||
| const makeComponents = (preview = false) => ( | |||
| { h1: ({ children }) => <SectionTitle>{children}</SectionTitle>, | |||
| h2: ({ children }) => <SubsectionTitle>{children}</SubsectionTitle>, | |||
| ol: ({ children }) => <ol className="list-decimal pl-6">{children}</ol>, | |||
| ul: ({ children }) => <ul className="list-disc pl-6">{children}</ul>, | |||
| a: ({ href, children }) => { | |||
| if (!(href)) | |||
| return <>{children}</> | |||
| if (!(preview) && ['/', '.'].some (e => href.startsWith (e))) | |||
| return <PrefetchLink to={href}>{children}</PrefetchLink> | |||
| const ext = /^(?:https?:)?\/\//.test (href) | |||
| return ( | |||
| <a href={href} | |||
| target={ext ? '_blank' : undefined} | |||
| rel={ext ? 'noopener noreferrer' : undefined}> | |||
| {children} | |||
| </a>) | |||
| }, | |||
| img: (({ src, alt }) => ( | |||
| <img src={src ?? ''} | |||
| alt={alt ?? ''} | |||
| className="max-w-full h-auto rounded"/>)), | |||
| } as const satisfies Components) | |||
| export default (({ title, body, preview = false }: Props) => { | |||
| const { data } = useQuery ({ | |||
| queryKey: wikiKeys.index ({ }), | |||
| queryFn: () => fetchWikiPages ({ }) }) | |||
| const pageNames = useMemo ( | |||
| () => (data ?? []).map ((page) => page.title).sort ((a, b) => b.length - a.length), | |||
| [data]) | |||
| const remarkPlugins = useMemo ( | |||
| () => [() => remarkWikiAutoLink (pageNames), remarkGFM], | |||
| [pageNames]) | |||
| const components = useMemo ( | |||
| () => makeComponents (preview), | |||
| [preview]) | |||
| return ( | |||
| <ReactMarkdown | |||
| components={components} | |||
| remarkPlugins={remarkPlugins}> | |||
| {body | |||
| || (title | |||
| ? ('このページは存在しません。' | |||
| +`[新規作成してください](/wiki/new?title=${ encodeURIComponent (title) })。`) | |||
| : '')} | |||
| </ReactMarkdown>) | |||
| }) satisfies FC<Props> | |||
| @@ -1,10 +1,9 @@ | |||
| import { useQueryClient } from '@tanstack/react-query' | |||
| import MarkdownIt from 'markdown-it' | |||
| import { useEffect, useState } from 'react' | |||
| import { Helmet } from 'react-helmet-async' | |||
| import MdEditor from 'react-markdown-editor-lite' | |||
| import { useParams, useNavigate } from 'react-router-dom' | |||
| import WikiEditForm from '@/components/WikiEditForm' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { toast } from '@/components/ui/use-toast' | |||
| import { SITE_TITLE } from '@/config' | |||
| @@ -18,8 +17,6 @@ import type { FC } from 'react' | |||
| import type { User, WikiPage } from '@/types' | |||
| const mdParser = new MarkdownIt | |||
| type Props = { user: User | null } | |||
| @@ -37,7 +34,7 @@ export default (({ user }: Props) => { | |||
| const [loading, setLoading] = useState (true) | |||
| const [title, setTitle] = useState ('') | |||
| const handleSubmit = async () => { | |||
| const handleSubmit = async (title: string, body: string) => { | |||
| const formData = new FormData () | |||
| formData.append ('title', title) | |||
| formData.append ('body', body) | |||
| @@ -46,8 +43,6 @@ export default (({ user }: Props) => { | |||
| { | |||
| 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: '投稿成功!' }) | |||
| navigate (`/wiki/${ title }`) | |||
| @@ -77,32 +72,11 @@ export default (({ user }: Props) => { | |||
| <h1 className="text-2xl font-bold mb-2">Wiki ページを編輯</h1> | |||
| {loading ? 'Loading...' : ( | |||
| <> | |||
| {/* タイトル */} | |||
| {/* TODO: タグ補完 */} | |||
| <div> | |||
| <label className="block font-semibold mb-1">タイトル</label> | |||
| <input type="text" | |||
| value={title} | |||
| onChange={e => setTitle (e.target.value)} | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* 本文 */} | |||
| <div> | |||
| <label className="block font-semibold mb-1">本文</label> | |||
| <MdEditor value={body} | |||
| style={{ height: '500px' }} | |||
| renderHTML={text => mdParser.render (text)} | |||
| onChange={({ text }) => setBody (text)}/> | |||
| </div> | |||
| {/* 送信 */} | |||
| <button onClick={handleSubmit} | |||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||
| 編輯 | |||
| </button> | |||
| </>)} | |||
| <WikiEditForm | |||
| title={title} | |||
| body={body} | |||
| onSubmit={handleSubmit} | |||
| id={Number (id)}/>)} | |||
| </div> | |||
| </MainArea>) | |||
| }) satisfies FC<Props> | |||
| @@ -1,21 +1,19 @@ | |||
| import MarkdownIt from 'markdown-it' | |||
| import { useState } from 'react' | |||
| import { useQueryClient } from '@tanstack/react-query' | |||
| import { Helmet } from 'react-helmet-async' | |||
| import MdEditor from 'react-markdown-editor-lite' | |||
| import { useLocation, useNavigate } from 'react-router-dom' | |||
| import WikiEditForm from '@/components/WikiEditForm' | |||
| import MainArea from '@/components/layout/MainArea' | |||
| import { toast } from '@/components/ui/use-toast' | |||
| import { SITE_TITLE } from '@/config' | |||
| import { apiPost } from '@/lib/api' | |||
| import { wikiKeys } from '@/lib/queryKeys' | |||
| import Forbidden from '@/pages/Forbidden' | |||
| import 'react-markdown-editor-lite/lib/index.css' | |||
| import type { User, WikiPage } from '@/types' | |||
| const mdParser = new MarkdownIt | |||
| type Props = { user: User | null } | |||
| @@ -26,13 +24,12 @@ export default ({ user }: Props) => { | |||
| const location = useLocation () | |||
| const navigate = useNavigate () | |||
| const qc = useQueryClient () | |||
| const query = new URLSearchParams (location.search) | |||
| const titleQuery = query.get ('title') ?? '' | |||
| const [title, setTitle] = useState (titleQuery) | |||
| const [body, setBody] = useState ('') | |||
| const handleSubmit = async () => { | |||
| const handleSubmit = async (title: string, body: string) => { | |||
| const formData = new FormData | |||
| formData.append ('title', title) | |||
| formData.append ('body', body) | |||
| @@ -40,7 +37,8 @@ export default ({ user }: Props) => { | |||
| try | |||
| { | |||
| const data = await apiPost<WikiPage> ('/wiki', formData, | |||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||
| { headers: { 'Content-Type': 'multipart/form-data' } }) | |||
| qc.invalidateQueries ({ queryKey: wikiKeys.root }) | |||
| toast ({ title: '投稿成功!' }) | |||
| navigate (`/wiki/${ data.title }`) | |||
| } | |||
| @@ -58,30 +56,10 @@ export default ({ user }: Props) => { | |||
| <div className="max-w-xl mx-auto p-4 space-y-4"> | |||
| <h1 className="text-2xl font-bold mb-2">新規 Wiki ページ</h1> | |||
| {/* タイトル */} | |||
| {/* TODO: タグ補完 */} | |||
| <div> | |||
| <label className="block font-semibold mb-1">タイトル</label> | |||
| <input type="text" | |||
| value={title} | |||
| onChange={e => setTitle (e.target.value)} | |||
| className="w-full border p-2 rounded"/> | |||
| </div> | |||
| {/* 本文 */} | |||
| <div> | |||
| <label className="block font-semibold mb-1">本文</label> | |||
| <MdEditor value={body} | |||
| style={{ height: '500px' }} | |||
| renderHTML={text => mdParser.render (text)} | |||
| onChange={({ text }) => setBody (text)}/> | |||
| </div> | |||
| {/* 送信 */} | |||
| <button onClick={handleSubmit} | |||
| className="px-4 py-2 bg-blue-600 text-white rounded disabled:bg-gray-400"> | |||
| 追加 | |||
| </button> | |||
| <WikiEditForm | |||
| title={titleQuery} | |||
| body="" | |||
| onSubmit={handleSubmit}/> | |||
| </div> | |||
| </MainArea>) | |||
| } | |||