From 970cd308441b24ad98650f87ba007e914bf7dcaf Mon Sep 17 00:00:00 2001 From: miteruzo Date: Tue, 3 Jun 2025 01:56:49 +0900 Subject: [PATCH] =?UTF-8?q?#14=20=E3=81=BC=E3=81=A1=E3=81=BC=E3=81=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/controllers/posts_controller.rb | 26 ++- .../20250602162353_add_title_to_posts.rb | 5 + frontend/src/App.tsx | 7 +- frontend/src/components/TopNav.tsx | 82 +++++++-- frontend/src/pages/PostNewPage.tsx | 157 ++++++++++++++++++ 5 files changed, 253 insertions(+), 24 deletions(-) create mode 100644 backend/db/migrate/20250602162353_add_title_to_posts.rb create mode 100644 frontend/src/pages/PostNewPage.tsx diff --git a/backend/app/controllers/posts_controller.rb b/backend/app/controllers/posts_controller.rb index cfd2100..8573472 100644 --- a/backend/app/controllers/posts_controller.rb +++ b/backend/app/controllers/posts_controller.rb @@ -32,12 +32,30 @@ class PostsController < ApplicationController # POST /posts def create - @post = Post.new(post_params) + # TODO: current_user.role が 'admin' もしくは 'member' でなければ 403 - if @post.save - render json: @post, status: :created, location: @post + title = params[:title] + unless title.present? + # TODO: + # 既知サイトなら決まったフォーマットで, + # 未知サイトならページ名をセットする. + end + post = Post.new(title: title, url: params[:url], thumbnail_base: '', uploaded_user: current_user) + if params[:thumbnail].present? + post.thumbnail.attach(params[:thumbnail]) else - render json: @post.errors, status: :unprocessable_entity + # TODO: + # 既知ドメインであれば,指定のアドレスからサムネール取得, + # それ以外なら URL のスクショ・イメージをサムネールに登録. + end + if post.save + if params[:tags].present? + tag_ids = JSON.parse(params[:tags]) + post.tags = Tag.where(id: tag_ids) + end + render json: post, status: :created + else + render json: { errors: post.errors.full_messages }, status: :unprocessable_entity end end diff --git a/backend/db/migrate/20250602162353_add_title_to_posts.rb b/backend/db/migrate/20250602162353_add_title_to_posts.rb new file mode 100644 index 0000000..13436b8 --- /dev/null +++ b/backend/db/migrate/20250602162353_add_title_to_posts.rb @@ -0,0 +1,5 @@ +class AddTitleToPosts < ActiveRecord::Migration[8.0] + def change + add_column :posts, :title, :string, after: :id, null: false + end +end diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 610a8d5..63be0ce 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,10 +1,11 @@ import React, { useEffect, useState } from 'react' -import { BrowserRouter as Router, Route, Routes } from 'react-router-dom' +import { BrowserRouter as Router, Route, Routes, Navigate } from 'react-router-dom' import HomePage from './pages/HomePage' import TagPage from './pages/TagPage' import TopNav from './components/TopNav' import TagSidebar from './components/TagSidebar' import PostPage from './pages/PostPage' +import PostNewPage from './pages/PostNewPage' import PostDetailPage from './pages/PostDetailPage' import { API_BASE_URL } from './config' import axios from 'axios' @@ -70,9 +71,11 @@ const App = () => {
+ } /> + } /> + } /> } /> } /> - } />
diff --git a/frontend/src/components/TopNav.tsx b/frontend/src/components/TopNav.tsx index a6f745d..01ce35a 100644 --- a/frontend/src/components/TopNav.tsx +++ b/frontend/src/components/TopNav.tsx @@ -1,7 +1,8 @@ -import React, { useState } from "react" -import { Link } from 'react-router-dom' +import React, { useState, useEffect } from "react" +import { Link, useLocation } from 'react-router-dom' import SettingsDialogue from './SettingsDialogue' import { Button } from './ui/button' +import clsx from 'clsx' type User = { id: number name: string | null @@ -11,27 +12,72 @@ type User = { id: number type Props = { user: User setUser: (user: User) => void } +const enum Menu { None, + Post, + Deerjikist, + Tag, + Wiki } + const TopNav: React.FC = ({ user, setUser }: Props) => { + const location = useLocation () + const [settingsVisible, setSettingsVisible] = useState (false) + const [selectedMenu, setSelectedMenu] = useState (Menu.None) + + const MyLink = ({ to, title, menu }: { to: string; title: string; menu?: Menu }) => ( + + {title} + ) + + useEffect (() => { + if (location.pathname.startsWith ('/posts')) + setSelectedMenu (Menu.Post) + else if (location.pathname.startsWith ('/deerjikists')) + setSelectedMenu (Menu.Deerjikist) + else if (location.pathname.startsWith ('/tags')) + setSelectedMenu (Menu.Tag) + else if (location.pathname.startsWith ('/wiki')) + setSelectedMenu (Menu.Wiki) + else + setSelectedMenu (Menu.None) + }, [location]) return ( - ) + <> + + {(() => { + const className = 'bg-gray-700 text-white px-3 flex items-center w-full min-h-[40px]' + const subClass = 'hover:text-orange-500 h-full flex items-center px-4' + switch (selectedMenu) + { + case Menu.Post: + return ( +
+ 一覧 + 投稿 +
) + } + }) ()} + ) } diff --git a/frontend/src/pages/PostNewPage.tsx b/frontend/src/pages/PostNewPage.tsx new file mode 100644 index 0000000..5701101 --- /dev/null +++ b/frontend/src/pages/PostNewPage.tsx @@ -0,0 +1,157 @@ +import React, { useEffect, useState } from 'react' +import { Link, useLocation, useParams, useNavigate } from 'react-router-dom' +import axios from 'axios' +import { API_BASE_URL, SITE_TITLE } from '../config' +import NicoViewer from '../components/NicoViewer' +import { Button } from '@/components/ui/button' +import { toast } from '@/components/ui/use-toast' +import { cn } from '@/lib/utils' + +type Tag = { id: number + name: string + category: string } + +type Post = { id: number + url: string + title: string + thumbnail: string + tags: Tag[] + viewed: boolean } + +type Props = { posts: Post[] + setPosts: (posts: Post[]) => void } + + +const PostNewPage = () => { + const location = useLocation () + const navigate = useNavigate () + + const [title, setTitle] = useState ('') + const [titleAutoFlg, setTitleAutoFlg] = useState (true) + const [url, setURL] = useState ('') + const [thumbnailFile, setThumbnailFile] = useState (null) + const [thumbnailPreview, setThumbnailPreview] = useState ('') + const [thumbnailAutoFlg, setThumbnailAutoFlg] = useState (true) + const [tags, setTags] = useState ([]) + const [tagIds, setTagIds] = useState ([]) + + const handleSubmit = () => { + const formData = new FormData () + formData.append ('title', title || null) + formData.append ('url', url) + formData.append ('tags', JSON.stringify (tagIds)) + formData.append ('thumbnail', thumbnailAutoFlg ? null : thumbnailFile) + + void (axios.post (`${ API_BASE_URL }/posts`, formData, { headers: { + 'Content-Type': 'multipart/form-data', + 'X-Transfer-Code': localStorage.getItem ('user_code') || '' } }) + .then (() => { + toast ({ title: '投稿成功!' }) + navigate ('/posts') + }) + .catch (e => toast ({ title: '投稿失敗', + description: '入力を確認してください。' }))) + } + + document.title = `広場に投稿を追加 | ${ SITE_TITLE }` + + useEffect (() => { + void (axios.get ('/api/tags') + .then (res => setTags (res.data)) + .catch (() => toast ({ title: 'タグ一覧の取得失敗' }))) + }, []) + + return ( +
+

広場に投稿を追加する

+ + {/* URL */} +
+ + setURL (e.target.value)} + className="w-full border p-2 rounded" /> +
+ + {/* タイトル */} +
+
+ + +
+ setTitle (e.target.value)} + disabled={titleAutoFlg} /> +
+ + {/* サムネール */} +
+
+ + +
+ {thumbnailAutoFlg + ? ( +

+ URL から自動取得されます。 +

) + : ( + <> + { + const file = e.target.files?.[0] + if (file) + { + setThumbnailFile (file) + setThumbnailPreview (URL.createObjectURL (file)) + } + }} /> + {thumbnailPreview && ( + preview)} + )} +
+ + {/* タグ */} +
+ + +
+ + {/* 送信 */} + +
) +} + + +export default PostNewPage