--- title: "アプリケーションのフロントエンドを設定する" url: https://docs.catalyst.zoho.com/ja/tutorials/photo-store-app/nodejs/config-frontend/ description: "Stratusのバケットに画像をアップロードし、他の登録ユーザーとこれらの画像を共有するReactギャラリーアプリを構築します。AppSailでアプリをホストし、GitHubと同期してPipelinesを使用してアプリをデプロイします。" last_updated: 2026-08-28T07:59:38Z service: All Services type: tutorial related_links: - name: "Catalyst Stratus" url: /ja/cloud-scale/help/stratus/introduction/ - name: "Path Functionality" url: /ja/cloud-scale/help/stratus/objects/introduction/#path - name: "ホスト型認証" url: /ja/cloud-scale/help/authentication/native-catalyst-authentication/hosted-authentication-type/introduction/ - name: "Stratus Web SDK" url: /ja/sdk/web/v4/cloud-scale/stratus/overview/ - name: "Upload Web SDK" url: /ja/sdk/web/v4/cloud-scale/stratus/upload-object/ - name: "Download Web SDK" url: /ja/sdk/web/v4/cloud-scale/stratus/download-object/ - name: "Delete Web SDK" url: /ja/sdk/web/v4/cloud-scale/stratus/delete-object/ - name: "Stratus REST API" url: /ja/api/code-reference/cloud-scale/stratus/get-all-buckets/#GetAllBuckets - name: "クライアントディレクトリ構造" url: /ja/cli/v1/project-directory-structure/client-directory/ - name: "Webクライアントホスティング" url: /ja/cloud-scale/help/web-client-hosting/introduction/ --- # アプリケーションのフロントエンドを設定する 次に、アプリケーションのフロントエンドを設定します。 ### Tailwind CSSフレームワークのインストールと設定 コードの追加を開始する前に、[Tailwindフレームワーク](https://tailwindcss.com/)がHTMLファイルとJSコンポーネントをスキャンし、必要な静的CSSファイルを生成できるビルド済みCSSクラスを実装できるようにする必要があります。 必要なフレームワークをインストールするには: 1. `PhotoStoreApp/photo-store-app/`ディレクトリに移動し、以下のコマンドを実行します 必要なTailwind CSSフレームワークのインストールを完了します。 ```sh npm install -D tailwindcss@3.4.17 ``` ![catalyst_tutorials_photostore_stratus_frontend_tailwind_inst_cmpltd](https://catalyst-docs-development.zohostratus.in/images/tutorials/photo-store/catalyst_tutorials_photostore_stratus_frontend_tailwind_inst_cmpltd.webp) 2. 以下のコマンドを実行して、`PhotoStoreApp/photo-store-app/`ディレクトリに`tailwind.config.js`ファイルを作成します。 ```sh npx tailwindcss init ``` `tailwind.config.js`ファイルが作成されます。プロジェクトディレクトリは以下の画像のようになります: ![catalyst_tutorials_photostore_stratus_tailwind_front_end_dir](https://catalyst-docs-development.zohostratus.in/images/tutorials/photo-store/catalyst_tutorials_photostore_stratus_tailwind_front_end_dir.webp) 3. 以下のコードをコピーして、`tailwind.config.js`ファイルに貼り付けます。 **tailwind.config.js** ```js /** @type {import('tailwindcss').Config} */ module.exports = { content: [ "./src/**/*.{js,jsx,ts,tsx}" ], theme: { extend: {}, }, plugins: [], }; ``` `tailwind.config.js`ファイルがアプリケーション用に設定されました。 ### 必要なパッケージのインストール すべてのフロントエンド依存関係を満たすために、以下のパッケージをインストールする必要があります: * `axios`: このパッケージはHTTPリクエストを処理できます。 * `hamburger-react`: このパッケージはハンバーガーメニューアイコンを作成できます。 * `react-toastify`: このパッケージはトースト通知を表示できます。 * `react-router-dom`: このパッケージはクライアントサイドルーティングを処理できます。 * `react-icons`: このパッケージは人気のあるアイコンライブラリを処理できます。 `PhotoStoreApp/photo-store-app/`ディレクトリに移動し、ターミナルで以下のコマンドを実行して必要なパッケージをインストールします。 ```sh npm install axios hamburger-react react-toastify react-router-dom react-icons ``` ![catalyst_tutorials_photostore_stratus_install_react_packs](https://catalyst-docs-development.zohostratus.in/images/tutorials/photo-store/catalyst_tutorials_photostore_stratus_install_react_packs.webp) ### フロントエンドのコーディング 以下のファイルにコードを追加します: - `PhotoStoreApp/photo-store-app/src/`ディレクトリ内: - `App.js`: このファイルには、ログインコンポーネントの表示、すべてのアップロード関連操作、共有機能のロジックが含まれます。コードには、レスポンシブナビゲーションバー、トーストメッセージ、ハンバーガーメニューのロジックも含まれています。 - 以下のフォルダとそれぞれのファイルを作成します: - `service` - `ImageService.js`: 以下の機能を処理するロジックを定義するヘルパー関数が含まれます: - `fetchImages()`: 必要な画像を取得します。 - `fetchSharedImages()`: 必要な共有画像を取得します。 - `handleDelete()`: アプリケーションとバケットから必要な画像を削除するロジックが含まれます。 - `handleDownload()`: バケットからローカルシステムに必要な画像をダウンロードするロジックが含まれます。 - `handleShareAction()`: `/shareDetails` APIを呼び出して、必要な画像を別の登録ユーザーと共有するロジックが含まれます。 - `handleUpdateSharedDetails()`: 共有権限と画像の詳細を更新するロジックが含まれます。 - `ImageThumbnail.js`: アップロードされた画像をサムネイル、フルスケール、またはリストビューで表示するロジックが含まれます。 - `pages` - `Home.js`: 画像のグリッド/リストビューを表示するロジックが含まれます。画像に対するCRUD操作を許可し、バケットにアップロードされたすべての画像を表示するためのページネーションビューをサポートします。 - `ImageGrid.js`: アップロードされたすべての画像のグリッドを、ダウンロード、プレビュー、共有、削除のオプション付きで表示するロジックが含まれます。 - `ImageList.js`: アップロードされたすべての画像のリストビューを、ダウンロード、プレビュー、共有、削除のオプション付きで表示するロジックが含まれます。 - `Login.js`: エンドユーザーを[Catalyst Authentication](https://docs.catalyst.zoho.com/ja/cloud-scale/help/authentication/introduction/)のログインコンポーネントに誘導するロジックが含まれます。 - `Logout.js`: エンドユーザーをアプリケーションからサインアウトさせるロジックが含まれます。 - `SharedDetails.js`: 他のユーザーと共有されている画像をページネーション形式で表示するロジックが含まれます。また、必要に応じて画像のアクセス権限の取り消しや更新を行うロジックも定義されています。 - `SharedImageGrid.js`: 必要な権限が付与されている場合に、必要な画像をダウンロードするオプション付きのレスポンシブグリッドで画像を表示するロジックが含まれます。 - `SharedImageList.js`: `SharedImageGrid.js`と同じロジックが含まれますが、画像はサムネイルとして表示されるリストビューで表示されます。 - `SharedImages.js`: 共有画像をページネーションを通じてグリッドビューまたはリストビューで表示するロジックが含まれます。このコードファイルのロジックにより、登録済みで権限を持つエンドユーザーは共有画像に対してCRUD操作を実行できます。 - `Upload.js`: 画像をアップロードするロジックが含まれます。このコードのロジックは、重複を防ぐために別の画像が存在するかどうかを確認するように定義されています。画像のアップロード中に`/convertToThumbnailAndUpload` APIが呼び出され、画像がサムネイルに変換され、アップロードが完了します。 - `index.js`: アプリケーションのエントリーポイントとして機能します。 - `index.css`: アプリケーションでTailwindを使用するために必要です。 - `PhotoStoreApp/photo-store-app/public/`ディレクトリ内: - `index.html`: このファイルはアプリケーション全体のメインHTMLテンプレートです。 すべてのファイルがアプリケーション用に作成されると、プロジェクトディレクトリは以下のようになります: ![catalyst_tutorials_photostore_stratus_final_dir](https://catalyst-docs-development.zohostratus.in/images/tutorials/photo-store/catalyst_tutorials_photostore_stratus_final_dir.webp) ファイルへのコードの追加を開始しましょう。 > **注:** このセクションのコードを確認して、十分に理解していることを確認してください。 以下のコードをコピーして、IDEを使用してプロジェクトのそれぞれのファイルに貼り付け、ファイルを保存してください。 **App.js** ```js import { useEffect, useState } from "react"; import { Route, Routes, useNavigate } from "react-router-dom"; import Upload from "./pages/Upload"; import "./App.css"; import Login from "./pages/Login"; import Logout from "./pages/Logout"; import { ToastContainer } from "react-toastify"; import { Turn as Hamburger } from "hamburger-react"; import SharedDetails from "./pages/SharedDetails"; import Home from "./pages/Home"; import SharedImages from "./pages/SharedImages"; function HeaderClick() { const navigate = useNavigate(); return ( <h1 className="text-2xl font-bold px-4 cursor-pointer hover:text-gray-300 transition duration-300" onClick={() => navigate("/")}> Photo Store App </h1> ); } function App() { const [isUserAuthenticated, setIsUserAuthenticated] = useState(false); const [isFetching, setIsFetching] = useState(true); const [userId, setUserId] = useState(null); const [isOpen, setOpen] = useState(false); const navigate = useNavigate(); useEffect(() => { const authenticateUser = async () => { try { const result = await window.catalyst.auth.isUserAuthenticated(); setUserId(result.content.zuid); setIsUserAuthenticated(true); } catch (err) { console.log("UNAUTHENTICATED"); } finally { setIsFetching(false); } }; authenticateUser(); }, []); return ( <div className="bg-black text-white min-h-screen flex flex-col overflow-hidden"> <nav className="flex justify-between items-center px-6 py-4 shadow-lg relative"> <div className="flex items-center relative"> <Hamburger toggled={isOpen} toggle={setOpen} /> {isOpen && ( <div className="absolute left-0 top-full mt-2 bg-gray-800 text-white w-64 h-screen shadow-lg p-4 z-50 overflow-y-auto flex flex-col justify-between text-lg"> <ul className="flex flex-col space-y-4"> <li className="py-2 px-4 cursor-pointer hover:bg-blue-700" onClick={() => { setOpen(false); navigate("/upload"); }} > Upload Image </li> <li className="py-2 px-4 cursor-pointer hover:bg-blue-700" onClick={() => { setOpen(false); navigate("/"); }} > Your Gallery </li> <li className="py-2 px-4 cursor-pointer hover:bg-blue-700" onClick={() => { setOpen(false); navigate("/sharedImages"); }} > Shared Gallery </li> <li className="py-2 px-4 cursor-pointer hover:bg-blue-700" onClick={() => { setOpen(false); navigate("/sharedDetails"); }} > Manage Shared Details </li> </ul> <ul className="pb-16 mt-auto"> <li className="py-2 px-4 cursor-pointer hover:bg-red-700" onClick={() => { setOpen(false); navigate("/logout"); }} > LogOut </li> </ul> </div> )} <HeaderClick /> </div> <ul className="flex space-x-32"> <li> <button onClick={() => navigate("/upload")} className="bg-blue-800 text-white px-4 py-2 rounded-md hover:bg-blue-900 transition duration-300" > Upload </button> </li> </ul> </nav> <div className="flex-grow flex justify-center items-center overflow-hidden"> {isFetching ? ( <div className="w-10 h-10 border-4 border-gray-300 border-t-transparent rounded-full animate-spin"></div> ) : isUserAuthenticated ? ( <Routes> <Route path="/upload" element=<Upload userId={userId} /> /> <Route path="/sharedDetails" element=<SharedDetails userId={userId} /> /> <Route path="/logout" element=<Logout /> /> <Route path="/" element=<Home userId={userId} /> /> <Route path="/sharedImages" element=<SharedImages userId={userId} /> /> <Route path="*" element=<Home userId={userId} /> /> </Routes> ) : ( <Login /> )} </div> <ToastContainer /> </div> ); } export default App; ``` **ImageService.js** ```js import { toast } from 'react-toastify'; export const fetchImages = async (userId, setImageDetails, setLoading) => { try { const response = await fetch(`/fetchAllImages?id=${userId}`); if (!response.ok) { throw new Error("Failed to fetch data"); } const data = await response.json(); data.map((image) => { image.object_url = image.object_url + "?responseCacheControl=max-age=3600"; }); setTimeout(() => { setLoading(false); }, 1000); setImageDetails(data || []); } catch (err) { console.error(err); toast.error("Error Occurred", { theme: "colored", }); } }; export const fetchUsers = async (setUsers) => { try { const response = await fetch("/getAllUsers"); const data = await response.json(); setUsers(data); } catch (error) { console.error("Error fetching users:", error); toast.error("Error Occurred", { theme: "colored", }); } }; export const handleDelete = async (imageKey, setImageDetails, setOpenMenuIndex) => { try { const stratus = window.catalyst.stratus; const bucket = stratus.bucket("YOUR_BUCKET_NAME"); await bucket.deleteObject(imageKey); const pathParts = imageKey.split("/"); const fileName = pathParts.pop(); const folderName = pathParts.pop(); const newFileName = fileName.replace(/\.[^/.]+$/, ".jpeg"); const thumbnailPath = ["photos", "thumbnails", folderName, newFileName].join("/"); await bucket.deleteObject(thumbnailPath); let zcql = window.catalyst.ZCatalystQL; let query = `DELETE FROM ImageShareDetails WHERE BucketPath = '${imageKey}'`; console.log("QUERY: " + query); await zcql.executeQuery(query) .then((response) => { console.log("ZCQL Response: " + JSON.stringify(response.content)); }) .catch((err) => { console.log("ZCQL Error: " + err); }); setImageDetails(prev => prev.filter(image => image.key !== imageKey)); if (setOpenMenuIndex != null) { setOpenMenuIndex(null); } toast.success("Image Deleted Successfully", { theme: "colored", }); } catch (error) { console.error("Error deleting image:", error); toast.error("Error deleting the file. Please try again.", { theme: "colored", }); } }; export const handleDownload = async (imageKey, setOpenMenuIndex) => { try { const stratus = window.catalyst.stratus; const bucket = stratus.bucket("YOUR_BUCKET_NAME"); const getObject = await bucket.getObject(imageKey); const getObjectStart = getObject.start(); if (setOpenMenuIndex != null) { setOpenMenuIndex(null); } await getObjectStart.then((blob) => { const url = URL.createObjectURL(blob.content); const a = document.createElement("a"); a.href = url; a.download = imageKey; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); }).catch((error) => { console.error("Error downloading file:", error); }); setTimeout(() => { toast.success("File Downloaded Successfully", { theme: "colored", }); }, 2000); } catch (error) { console.error("Error downloading image:", error); toast.error("Error in downloading the file", { theme: "colored", }); } }; export const handleShareAction = async (name, id, path, action, userId) => { try { const response = await fetch(`/shareDetails`, { method: "POST", headers: { "Content-Type": "application/json", }, body: JSON.stringify({ userName: name, imagePath: path, zuid: id, isUpdate: action, sharedBy: userId }), }); const data = await response.json(); if (data.message === "Access Provided") { toast.success("Image Shared Successfully", { theme: "colored", }); } else { toast.error(data.message, { theme: "colored", }); } console.log("API Response:", data); } catch (error) { console.error("Error calling API:", error); } }; export const fetchSharedImages = async (userId, setImageDetails, setLoading) => { try { const response = await fetch(`/getSharedImages?id=${userId}`); if (!response.ok) { throw new Error("Failed to fetch data"); } const data = await response.json(); const flattenedImages = data.flatMap(item => item || []); setTimeout(() => { setLoading(false); }, 1000); setImageDetails(flattenedImages || []); } catch (err) { console.error(err); toast.error("Error Occurred", { theme: "colored", }); } }; export const fetchSharedDetails = async (userId, setDetails, setLoading) => { try { const response = await fetch(`/getSharedDetails?id=${userId}`); const data = await response.json(); setDetails(data); setLoading(false); } catch (error) { console.error("Error fetching details:", error); setLoading(false); } }; export const handleUpdateSharedDetails = async (updatedItem, navigate) => { try { const response = await fetch("/updateSharedDetails", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(updatedItem), }); await response.json(); toast.success("Details Updated Successfully", { theme: "colored" }); navigate(0); } catch (error) { console.error("Error updating details:", error); toast.error("Error updating details", { theme: "colored" }); } }; ``` > **注:** **38**行目と**73**行目にバケット名を指定してください。 **ImageThumbnail.js** ```js import { useState, useEffect } from "react"; export default function ImageThumbnail({ imageUrl, listStyle }) { console.log("Image URL: " + imageUrl); const [thumbnail, setThumbnail] = useState(null); const bucketUrl = "YOUR_BUCKET_URL"; const pathParts = imageUrl.split("/"); const fileName = pathParts.pop(); const folderName = pathParts.pop(); const newFileName = fileName.replace(/\.[^/.]+$/, ".jpeg"); const newFilePath = ["photos", "thumbnails", folderName, newFileName].join("/"); console.log(newFilePath); const thumbnailUrl = bucketUrl + newFilePath; console.log("ThumbNail URL: " + thumbnailUrl); useEffect(() => { setThumbnail(newFilePath); }, [imageUrl]); return ( <> {thumbnail ? ( listStyle ? ( <img src={thumbnailUrl} alt="Thumbnail" style={{ width: "1rem", height: "1rem" }} /> ) : ( <img src={thumbnailUrl} alt="Thumbnail image" className="w-40 h-40 object-cover rounded-lg transition-all duration-500 ease-in-out" /> ) ) : ( <p>Loading...</p> )} </> ); } ``` > **注:** **5**行目にバケットのURLを指定してください。 **Home.js** ```js import { useState, useEffect } from 'react'; import { FiX, FiList, FiGrid } from "react-icons/fi"; import { fetchImages, handleDelete, handleDownload, fetchUsers, handleShareAction } from "../service/ImageService"; import ImageGrid from './ImageGrid'; import ImageList from './ImageList'; export default function Home({ userId }) { const [imageDetails, setImageDetails] = useState([]); const [currentPage, setCurrentPage] = useState(1); const [openMenuIndex, setOpenMenuIndex] = useState(null); const [loading, setLoading] = useState(true); const [users, setUsers] = useState([]); const [selectedUserIndex, setSelectedUserIndex] = useState(null); const [openShareIndex, setOpenShareIndex] = useState(null); const [selectedImage, setSelectedImage] = useState(null); const [viewMode, setViewMode] = useState("grid"); const imagesPerPage = 9; useEffect(() => { fetchImages(userId, setImageDetails, setLoading); }, [userId]); useEffect(() => { const handleClickOutside = (event) => { if (openShareIndex !== null || selectedUserIndex !== null) { setOpenShareIndex(null); setSelectedUserIndex(null); } if (openMenuIndex !== null) { setOpenMenuIndex(null); } }; document.addEventListener("click", handleClickOutside); return () => document.removeEventListener("click", handleClickOutside); }, [openShareIndex, selectedUserIndex, openMenuIndex]); const handleShareClick = async (index, e) => { e.preventDefault(); e.stopPropagation(); if (openShareIndex === index) { setOpenShareIndex(null); setSelectedUserIndex(null); return; } await fetchUsers(setUsers); setOpenShareIndex(index); }; const toggleUserOptions = (index, e) => { e.preventDefault(); e.stopPropagation(); setSelectedUserIndex(selectedUserIndex === index ? null : index); }; const openPreview = (imageUrl) => setSelectedImage(imageUrl); const closePreview = () => setSelectedImage(null); const indexOfLastImage = currentPage * imagesPerPage; const indexOfFirstImage = indexOfLastImage - imagesPerPage; const currentImages = imageDetails.slice(indexOfFirstImage, indexOfLastImage); const nextPage = () => { if (indexOfLastImage < imageDetails.length) setCurrentPage(currentPage + 1); }; const prevPage = () => { if (currentPage > 1) setCurrentPage(currentPage - 1); }; const toggleMenu = (index, e) => { e.preventDefault(); e.stopPropagation(); setOpenMenuIndex(openMenuIndex === index ? null : index); }; return ( <div className="min-h-screen bg-black flex flex-col items-center p-6 relative"> {loading ? ( <div className="flex justify-center items-center h-screen"> <div className="w-10 h-10 border-4 border-gray-300 border-t-transparent rounded-full animate-spin"></div> </div> ) : ( <> {imageDetails.length === 0 ? ( <div className="flex items-center justify-center h-screen"> <p className="text-white text-lg font-semibold text-center"> No files found. Click the Upload button to upload your photos. </p> </div> ) : ( <> <button className="absolute top-4 right-4 bg-blue-700 text-white px-4 py-2 rounded shadow-lg flex items-center space-x-2" onClick={() => setViewMode(viewMode === "grid" ? "list" : "grid")} > {viewMode === "grid" ? <FiList size={20} /> : <FiGrid size={20} />} </button> <div className="max-w-screen-lg w-full mt-12"> {viewMode === "grid" ? ( <ImageGrid currentImages={currentImages} openShareIndex={openShareIndex} handleShareClick={handleShareClick} users={users} selectedUserIndex={selectedUserIndex} toggleUserOptions={toggleUserOptions} handleShareAction={handleShareAction} openPreview={openPreview} openMenuIndex={openMenuIndex} toggleMenu={toggleMenu} handleDelete={handleDelete} handleDownload={handleDownload} setImageDetails={setImageDetails} setOpenMenuIndex={setOpenMenuIndex} setOpenShareIndex={setOpenShareIndex} setSelectedUserIndex={setSelectedUserIndex} userId={userId} /> ) : ( <ImageList currentImages={currentImages} openShareIndex={openShareIndex} handleShareClick={handleShareClick} users={users} selectedUserIndex={selectedUserIndex} toggleUserOptions={toggleUserOptions} handleShareAction={handleShareAction} handleDelete={handleDelete} handleDownload={handleDownload} setImageDetails={setImageDetails} openPreview={openPreview} setOpenShareIndex={setOpenShareIndex} setSelectedUserIndex={setSelectedUserIndex} userId={userId} /> )} </div> {imageDetails.length > imagesPerPage && ( <div className="flex mt-6 justify-center items-center space-x-4"> <button onClick={prevPage} disabled={currentPage === 1} className="px-4 py-2 bg-blue-700 text-white rounded disabled:opacity-50 disabled:bg-blue-200" > Previous </button> <span className="text-lg font-semibold text-center w-20"> Page {currentPage} </span> <button onClick={nextPage} disabled={indexOfLastImage >= imageDetails.length} className="px-4 py-2 bg-blue-700 text-white rounded disabled:opacity-50 disabled:bg-blue-200" > Next </button> </div> )} </> )} </> )} {selectedImage && ( <div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-80 z-50"> <div className="relative"> <button className="absolute top-2 right-2 bg-white p-2 rounded-full shadow-md hover:bg-gray-100" onClick={closePreview} > <FiX size={20} className="text-gray-600" /> </button> <img src={selectedImage} alt="Preview" className="max-w-full max-h-[50vh] rounded-lg shadow-lg" /> </div> </div> )} </div> ); } ``` **ImageGrid.js** ```js import { FiShare2, FiChevronRight, FiMoreVertical } from "react-icons/fi"; import ImageThumbnail from "../service/ImageThumbnail"; export default function ImageGrid({ currentImages, openShareIndex, handleShareClick, users, selectedUserIndex, toggleUserOptions, handleShareAction, openPreview, openMenuIndex, toggleMenu, handleDelete, handleDownload, setImageDetails, setOpenMenuIndex, setOpenShareIndex, setSelectedUserIndex, userId }) { return ( <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 place-items-center"> {currentImages.map((image, index) => ( <div key={image.key} className={`flex flex-col items-center w-full max-w-xs relative cursor-pointer ${ openShareIndex === index ? "" : "transition-transform hover:scale-110" }`} onClick={() => openPreview(image.object_url)} > <div className="max-w-xs h-auto flex items-center justify-center bg-gray-200 rounded-lg shadow-lg relative"> {/* Share Button */} <button className="absolute top-2 left-2 bg-white p-2 rounded-full shadow-md hover:bg-gray-100" onClick={(e) => handleShareClick(index, e)} > <FiShare2 size={15} className="text-gray-600" /> </button> {/* Share Dropdown */} {openShareIndex === index && users.length > 0 && ( <div className="absolute top-10 left-2 bg-white text-black border rounded shadow-lg w-40 text-sm z-50"> {users.map((user, idx) => ( <div key={user.id} className="relative"> <button className="flex justify-between items-center w-full text-left text-black px-4 py-2 hover:bg-gray-200" onClick={(e) => toggleUserOptions(idx, e)} > {user.name} <FiChevronRight className="text-gray-600" /> </button> {selectedUserIndex === idx && ( <div className="absolute top-0 left-full ml-2 bg-white text-black border rounded shadow-lg w-32 text-sm z-50" style={{ zIndex: 1000, pointerEvents: "auto" }} > <button className="block w-full px-4 py-2 text-left hover:bg-gray-200" onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleShareAction(user.name, user.zuid, image.key, false, userId); setSelectedUserIndex(null); setOpenShareIndex(null); }} > View Access </button> <button className="block w-full px-4 py-2 text-left hover:bg-gray-200" onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleShareAction(user.name, user.zuid, image.key, true, userId); setSelectedUserIndex(null); setOpenShareIndex(null); }} > Edit Access </button> </div> )} </div> ))} </div> )} {/* Image Thumbnail */} <ImageThumbnail imageUrl={image.object_url} /> {/* Options Menu Button */} <button className="absolute bottom-2 right-2 bg-white p-2 rounded-full shadow-md hover:bg-gray-100" onClick={(e) => toggleMenu(index, e)} > <FiMoreVertical size={15} className="text-gray-600" /> </button> {/* Options Menu Dropdown */} {openMenuIndex === index && ( <div className="absolute bottom-10 right-2 text-blue-600 bg-white border rounded shadow-lg w-32 text-sm z-50"> <button className="block w-full px-4 py-2 text-left hover:bg-gray-100" onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleDownload(image.key, setOpenMenuIndex); }} > Download </button> <button className="block w-full px-4 py-2 text-left text-red-600 hover:bg-gray-100" onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleDelete(image.key, setImageDetails, setOpenMenuIndex); }} > Delete </button> </div> )} </div> {/* Image Name */} <div className="w-full max-w-xs mt-3"> <p className="text-xl font-medium text-white text-center break-words"> {image.key.split("/").pop()} </p> </div> </div> ))} </div> ); }; ``` **ImageList.js** ```js import ImageThumbnail from '../service/ImageThumbnail'; import { FiDownload, FiTrash2, FiEye, FiChevronRight, FiShare2 } from "react-icons/fi"; export default function ImageList({ currentImages, openShareIndex, handleShareClick, users, selectedUserIndex, toggleUserOptions, handleShareAction, handleDelete, handleDownload, setImageDetails, openPreview, setOpenShareIndex, setSelectedUserIndex, userId }) { return ( <div className="flex flex-col space-y-2"> {currentImages.map((image, index) => ( <div key={image.key} className={`flex items-center w-full bg-gray-800 p-4 rounded-md ${ openShareIndex === index ? '' : 'transition-transform hover:scale-110' }`} > <ImageThumbnail imageUrl={image.object_url} listStyle={1} /> <div className="ml-2 flex-1"> <p className="text-white text-sm font-medium break-words"> {image.key.split('/').pop()} </p> </div> <button className="bg-white p-1 rounded-full shadow-md hover:bg-gray-100 mx-1" onClick={() => openPreview(image.object_url)} > <FiEye size={16} className="text-gray-600" /> </button> <button className="bg-white p-1 rounded-full shadow-md hover:bg-gray-100 mx-1" onClick={() => handleDownload(image.key, null)} > <FiDownload size={16} className="text-gray-600" /> </button> <button className="bg-white p-1 rounded-full shadow-md hover:bg-red-100" onClick={() => handleDelete(image.key, setImageDetails, null)} > <FiTrash2 size={16} className="text-red-600" /> </button> <button className="bg-white p-1 rounded-full shadow-md hover:bg-gray-100 mx-1" onClick={(e) => handleShareClick(index, e)} > <FiShare2 size={15} className="text-gray-600" /> </button> {openShareIndex === index && users.length > 0 && ( <div className="block top-10 right-4 bg-white text-black border rounded shadow-lg w-40 text-sm z-50"> {users.map((user, idx) => ( <div key={user.id} className="relative"> <button className="flex justify-between items-center w-full text-left text-black px-4 py-2 hover:bg-gray-200" onClick={(e) => toggleUserOptions(idx, e)} > {user.name} <FiChevronRight className="text-gray-600" /> </button> {selectedUserIndex === idx && ( <div className="absolute top-0 left-full ml-2 bg-white text-black border rounded shadow-lg w-32 text-sm z-50" style={{ zIndex: 1000, pointerEvents: "auto" }} > <button className="block w-full px-4 py-2 text-left hover:bg-gray-200" onMouseEnter={() => (document.body.style.pointerEvents = "none")} onMouseLeave={() => (document.body.style.pointerEvents = "auto")} onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleShareAction(user.name, user.zuid, image.key, false, userId); setSelectedUserIndex(null); setOpenShareIndex(null); }} > View Access </button> <button className="block w-full px-4 py-2 text-left hover:bg-gray-200" onMouseEnter={() => (document.body.style.pointerEvents = "none")} onMouseLeave={() => (document.body.style.pointerEvents = "auto")} onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleShareAction(user.name, user.zuid, image.key, true, userId); setSelectedUserIndex(null); setOpenShareIndex(null); }} > Edit Access </button> </div> )} </div> ))} </div> )} </div> ))} </div> ); } ``` **Login.js** ```js import { useEffect } from "react"; function Login() { useEffect(() => { window.location.href = `${window.origin}/__catalyst/auth/login`; }, []); return null; } export default Login; ``` **Logout.js** ```js import { useEffect } from "react"; export default function Logout() { useEffect(() => { const redirectURL = `${window.origin}/__catalyst/auth/login`; const auth = window.catalyst.auth; auth.signOut(redirectURL); }, []); return null; } ``` **SharedDetails.js** ```js import React, { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { fetchSharedDetails, handleUpdateSharedDetails } from "../service/ImageService"; export default function SharedDetails({ userId }) { const [details, setDetails] = useState([]); const [loading, setLoading] = useState(true); const [currentPage, setCurrentPage] = useState(1); const rowsPerPage = 10; const navigate = useNavigate(); useEffect(() => { fetchSharedDetails(userId, setDetails, setLoading); }, [userId]); const handleChange = (index, key, value) => { const updatedData = [...details]; updatedData[index][key] = value; setDetails(updatedData); }; const indexOfLastRow = currentPage * rowsPerPage; const indexOfFirstRow = indexOfLastRow - rowsPerPage; const currentRows = details.slice(indexOfFirstRow, indexOfLastRow); const totalPages = Math.ceil(details.length / rowsPerPage); return ( <div className="p-6"> {loading ? ( <div className="w-10 h-10 border-4 border-gray-300 border-t-transparent rounded-full animate-spin"></div> ) : details.length === 0 ? ( <p className="text-white text-center text-lg font-bold">Nothing Shared</p> ) : ( <> <table className="w-full border-collapse border border-red-400"> <thead> <tr className="bg-purple-700"> <th className="border text-white border-white px-4 py-2">Username</th> <th className="border text-white border-white px-4 py-2">Image Name</th> <th className="border text-white border-white px-4 py-2">Access Type</th> <th className="border text-white border-white px-4 py-2">Revoke Access</th> <th className="border text-white border-white px-4 py-2">Action</th> </tr> </thead> <tbody> {currentRows.map((item, index) => ( <tr key={index} className="text-center"> <td className="border text-white border-white px-4 py-2">{item.UserName}</td> <td className="border text-white border-white px-4 py-2"> {item.BucketPath.split("/").pop()} </td> <td className="border text-white border-white px-4 py-2"> <select value={item.IsUpdate} onChange={(e) => handleChange(index, "IsUpdate", e.target.value === "true") } className="border bg-green-700 rounded px-2 py-1" > <option value="true">Edit Access</option> <option value="false">View Access</option> </select> </td> <td className="border p-2"> <label className="flex items-center justify-center cursor-pointer"> <input type="checkbox" className="sr-only" checked={item.RevokeAccess === "yes"} onChange={() => handleChange(index, "RevokeAccess", item.RevokeAccess === "yes" ? "no" : "yes") } /> <div className={`relative w-12 h-6 rounded-full transition ${ item.RevokeAccess === "yes" ? "bg-red-500" : "bg-gray-300" }`} > <div className={`absolute left-1 top-1 w-4 h-4 bg-white rounded-full shadow-md transition-transform ${ item.RevokeAccess === "yes" ? "translate-x-6" : "translate-x-0" }`} ></div> </div> <span className="ml-2 text-sm font-medium"> {item.RevokeAccess === "yes" ? "Yes" : "No"} </span> </label> </td> <td className="border border-white px-4 py-2"> <button onClick={() => handleUpdateSharedDetails(details[index], navigate)} className="bg-blue-700 text-white px-4 py-1 rounded hover:bg-blue-900" > Update </button> </td> </tr> ))} </tbody> </table> {details.length > rowsPerPage && ( <div className="flex justify-center mt-4"> <button onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))} disabled={currentPage === 1} className="bg-gray-600 text-white px-3 py-1 mx-2 rounded disabled:opacity-50" > Previous </button> <span className="text-white mx-2"> Page {currentPage} of {totalPages} </span> <button onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))} disabled={currentPage === totalPages} className="bg-gray-600 text-white px-3 py-1 mx-2 rounded disabled:opacity-50" > Next </button> </div> )} </> )} </div> ); } ``` **SharedImageGrid.js** ```js import ImageThumbnail from "../service/ImageThumbnail"; import { FiMoreVertical } from "react-icons/fi"; export default function SharedImageGrid({ currentImages, toggleMenu, handleDelete, handleDownload, setImageDetails, setOpenMenuIndex, openMenuIndex, openPreview }) { return ( <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6 place-items-center"> {currentImages.map((image, index) => ( <div key={image.key} className="flex flex-col items-center w-full max-w-xs relative cursor-pointer transition-transform hover:scale-110" onClick={() => openPreview(image.object_url)} > <div className="max-w-xs h-auto flex items-center justify-center bg-gray-200 rounded-lg shadow-lg relative"> <ImageThumbnail imageUrl={image.object_url} /> <button className="absolute bottom-2 right-2 bg-white p-2 rounded-full shadow-md hover:bg-gray-100" onClick={(e) => toggleMenu(index, e)} > <FiMoreVertical size={15} className="text-gray-600" /> </button> {openMenuIndex === index && ( <div className="absolute bottom-10 right-2 text-blue-600 bg-white border rounded shadow-lg w-32 text-sm z-50"> {image.isEditAccess && ( <button className="block w-full px-4 py-2 text-left text-red-600 hover:bg-gray-100" onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleDelete(image.key, setImageDetails, setOpenMenuIndex); }} > Delete </button> )} <button className="block w-full px-4 py-2 text-left hover:bg-gray-100" onClick={(e) => { e.preventDefault(); e.stopPropagation(); handleDownload(image.key, setOpenMenuIndex); }} > Download </button> </div> )} </div> <div className="w-full max-w-xs mt-3"> <p className="text-xl font-medium text-white text-center break-words"> {image.key.split("/").pop()} </p> </div> </div> ))} </div> ); } ``` **SharedImageList.js** ```js import ImageThumbnail from '../service/ImageThumbnail'; import { FiDownload, FiTrash2, FiEye } from "react-icons/fi"; export default function SharedImageList({ currentImages, handleDelete, handleDownload, setImageDetails, openPreview }) { return ( <div className="flex flex-col space-y-2"> {currentImages.map((image) => ( <div key={image.key} className="flex items-center w-full bg-gray-800 p-4 rounded-md transition-transform hover:scale-110" > <ImageThumbnail imageUrl={image.object_url} listStyle={1} /> <div className="ml-2 flex-1"> <p className="text-white text-sm font-medium break-words"> {image.key.split('/').pop()} </p> </div> <button className="bg-white p-1 rounded-full shadow-md hover:bg-gray-100 mx-1" onClick={() => openPreview(image.object_url)} > <FiEye size={16} className="text-gray-600" /> </button> <button className="bg-white p-1 rounded-full shadow-md hover:bg-gray-100 mx-1" onClick={() => handleDownload(image.key, null)} > <FiDownload size={16} className="text-gray-600" /> </button> {image.isEditAccess && ( <button className="bg-white p-1 rounded-full shadow-md hover:bg-red-100" onClick={() => handleDelete(image.key, setImageDetails, null)} > <FiTrash2 size={16} className="text-red-600" /> </button> )} </div> ))} </div> ); } ``` **SharedImages.js** ```js import { useState, useEffect } from 'react'; import { FiList, FiGrid, FiX } from "react-icons/fi"; import { fetchSharedImages, handleDelete, handleDownload } from "../service/ImageService"; import SharedImageGrid from './SharedImageGrid'; import SharedImageList from './SharedImageList'; export default function SharedImages({ userId }) { const [imageDetails, setImageDetails] = useState([]); const [currentPage, setCurrentPage] = useState(1); const [openMenuIndex, setOpenMenuIndex] = useState(null); const [loading, setLoading] = useState(true); const [selectedImage, setSelectedImage] = useState(null); const [viewMode, setViewMode] = useState("grid"); const imagesPerPage = 9; useEffect(() => { fetchSharedImages(userId, setImageDetails, setLoading); }, [userId]); const indexOfLastImage = currentPage * imagesPerPage; const indexOfFirstImage = indexOfLastImage - imagesPerPage; const currentImages = imageDetails.slice(indexOfFirstImage, indexOfLastImage); const nextPage = () => { if (indexOfLastImage < imageDetails.length) setCurrentPage(currentPage + 1); }; const prevPage = () => { if (currentPage > 1) setCurrentPage(currentPage - 1); }; const toggleMenu = (index, e) => { e.preventDefault(); e.stopPropagation(); setOpenMenuIndex(openMenuIndex === index ? null : index); }; const openPreview = (imageUrl) => setSelectedImage(imageUrl); const closePreview = () => setSelectedImage(null); return ( <div className="min-h-screen bg-black flex flex-col items-center p-6 relative"> {loading ? ( <div className="flex justify-center items-center h-screen"> <div className="w-10 h-10 border-4 border-gray-300 border-t-transparent rounded-full animate-spin"></div> </div> ) : ( <> {imageDetails.length === 0 ? ( <div className="flex items-center justify-center h-screen"> <p className="text-white text-lg font-semibold text-center"> No Shared files found. </p> </div> ) : ( <> <button className="absolute top-4 right-4 bg-blue-700 text-white px-4 py-2 rounded shadow-lg flex items-center space-x-2" onClick={() => setViewMode(viewMode === "grid" ? "list" : "grid")} > {viewMode === "grid" ? <FiList size={20} /> : <FiGrid size={20} />} </button> <div className="max-w-screen-lg w-full mt-12"> {viewMode === "grid" ? ( <SharedImageGrid currentImages={currentImages} toggleMenu={toggleMenu} handleDelete={handleDelete} handleDownload={handleDownload} setImageDetails={setImageDetails} setOpenMenuIndex={setOpenMenuIndex} openMenuIndex={openMenuIndex} openPreview={openPreview} /> ) : ( <SharedImageList currentImages={currentImages} handleDelete={handleDelete} handleDownload={handleDownload} setImageDetails={setImageDetails} openPreview={openPreview} /> )} </div> {imageDetails.length > imagesPerPage && ( <div className="flex mt-6 justify-center items-center space-x-4"> <button onClick={prevPage} disabled={currentPage === 1} className="px-4 py-2 bg-blue-700 text-white rounded disabled:opacity-50 disabled:bg-blue-200" > Previous </button> <span className="text-lg font-semibold text-center w-20"> Page {currentPage} </span> <button onClick={nextPage} disabled={indexOfLastImage >= imageDetails.length} className="px-4 py-2 bg-blue-700 text-white rounded disabled:opacity-50 disabled:bg-blue-200" > Next </button> </div> )} </> )} </> )} {selectedImage && ( <div className="fixed inset-0 flex items-center justify-center bg-black bg-opacity-80 z-50"> <div className="relative"> <button className="absolute top-2 right-2 bg-white p-2 rounded-full shadow-md hover:bg-gray-100" onClick={closePreview} > <FiX size={20} className="text-gray-600" /> </button> <img src={selectedImage} alt="Preview" className="max-w-full max-h-[50vh] rounded-lg shadow-lg" /> </div> </div> )} </div> ); } ``` **Upload.js** ```js import React, { useState } from "react"; import { useNavigate } from "react-router-dom"; import axios from "axios"; import { toast } from 'react-toastify'; export default function Upload({ userId }) { const [file, setFile] = useState(null); const [isUploading, setIsUploading] = useState(false); const navigate = useNavigate(); const zuid = userId; const handleFileChange = (e) => { const selectedFile = e.target.files[0]; setFile(selectedFile); }; const handleSubmit = async (e) => { e.preventDefault(); try { setIsUploading(true); const stratus = window.catalyst.stratus; const bucket = stratus.bucket("YOUR_BUCKET_NAME"); const checkObjectAvailability = await bucket.headObject(`photos/${zuid}/${file.name}`); console.log("Availability: " + JSON.stringify(checkObjectAvailability)); if (checkObjectAvailability.content === true) { toast.error(`File named ${file.name} already exists`, { theme: "colored" }); setIsUploading(false); setFile(null); return; } const putObject = await bucket.putObject(`photos/${zuid}/${file.name}`, file); const response = await putObject.start(); console.log(JSON.stringify(response)); const formData = new FormData(); formData.append("image", file); formData.append("id", zuid); try { console.log("Thumbnail API Started"); const response = await axios.post("/convertToThumbnailAndUpload", formData); console.log("Response: " + JSON.stringify(response)); } catch (error) { console.error("Thumbnail Upload failed", error); } toast.success(`File uploaded: ${file.name}`, { theme: "colored" }); navigate("/"); } catch (error) { console.error("Error during upload:", error); toast.error("Error uploading the file. Please try again.", { theme: "colored" }); } finally { setIsUploading(false); setFile(null); } }; return ( <div className="flex items-center justify-center min-h-screen bg-black"> <div className="bg-white p-8 rounded-lg shadow-lg w-96 text-center flex flex-col items-center text-black"> <h1 className="text-2xl font-semibold mb-4 text-black">Upload Image</h1> <p className="text-sm text-black mb-4"> NOTE: Only .png, .jpg, and .jpeg files are allowed. </p> <form onSubmit={handleSubmit} className="flex flex-col items-center space-y-4 w-full"> <div className="flex justify-center w-full"> <input type="file" onChange={handleFileChange} className="border p-2 rounded w-full text-center" accept="image/png, image/jpg, image/jpeg" /> </div> <button type="submit" disabled={isUploading} className={`px-4 py-2 text-white rounded w-full ${ isUploading ? "bg-gray-400 cursor-not-allowed" : "bg-blue-500 hover:bg-blue-600" }`} > {isUploading ? "Uploading..." : "Upload"} </button> </form> </div> </div> ); } ``` > **注:** **19**行目にバケット名を指定してください。 **index.js** ```js import React from 'react'; import ReactDOM from 'react-dom/client'; import './index.css'; import App from './App'; import reportWebVitals from './reportWebVitals'; import { HashRouter as Router } from "react-router-dom"; // Routerをここに移動 const root = ReactDOM.createRoot(document.getElementById('root')); root.render( <React.StrictMode> <Router> <App /> </Router> </React.StrictMode> ); // アプリのパフォーマンスを計測するには、結果をログに記録する関数を渡してください // (例:reportWebVitals(console.log)) // または分析エンドポイントに送信してください。詳細:https://bit.ly/CRA-vitals reportWebVitals(); ``` **index.css** ```css @tailwind base; @tailwind components; @tailwind utilities; ``` **index.html** ```xml <!DOCTYPE html> <html lang="en"> <head> <meta charset="utf-8" /> <link rel="icon" href="%PUBLIC_URL%/favicon.ico" /> <meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="theme-color" content="#000000" /> <meta name="description" content="Web site created using create-react-app" /> <link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" /> <!-- manifest.json provides metadata used when your web app is installed on a user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/ --> <link rel="manifest" href="%PUBLIC_URL%/manifest.json" /> <!-- Notice the use of %PUBLIC_URL% in the tags above. It will be replaced with the URL of the `public` folder during the build. Only files inside the `public` folder can be referenced from the HTML. Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will work correctly both with client-side routing and a non-root public URL. Learn how to configure a non-root public URL by running `npm run build`. --> <title>PhotoStore App</title> <script src="https://static.zohocdn.com/catalyst/sdk/js/4.6.2/catalystWebSDK.js"></script> <script src="/__catalyst/sdk/init.js"></script> </head> <body> <noscript>You need to enable JavaScript to run this app.</noscript> <div id="root"></div> <!-- This HTML file is a template. If you open it directly in the browser, you will see an empty page. You can add webfonts, meta tags, or analytics to this file. The build step will place the bundled scripts into the <body> tag. To begin the development, run `npm start` or `yarn start`. To create a production bundle, use `npm run build` or `yarn build`. --> </body> </html> ``` アプリケーションのフロントエンドコンポーネントが設定されました。 アプリケーションのファンクションとクライアントコンポーネントの動作を簡単に確認しましょう: 1. アプリケーションURLがトリガーされると、ユーザーを*Login*ページに誘導します。認証要件とすべてのログイン機能は、Catalystの[Hosted Authentication](https://docs.catalyst.zoho.com/ja/cloud-scale/help/authentication/native-catalyst-authentication/hosted-authentication-type/introduction/)によって処理されます。エンドユーザーが正常に認証されると、アプリケーションのメインページにリダイレクトされます。 2. エンドユーザーがアプリケーションのメインページに移動すると、`/fetchAllImages` APIエンドポイントが呼び出されます。このAPIはバケットに保存されているすべての画像を返し、クライアントにサムネイルとして表示します。 3. エンドユーザーがアプリケーションに画像をアップロードしようとすると、[アップロードWeb SDKメソッド](https://docs.catalyst.zoho.com/ja/sdk/web/v4/cloud-scale/stratus/upload-object/)を使用して画像のアップロードアクションが完了します。画像のアップロード中に`/convertToThumbnailAndUpload` APIエンドポイントが呼び出され、画像がサムネイルに変換されてクライアントに表示され、アップロードされた画像は「`YOUR_BUCKET_NAME/photos/thumbnails/ログインしたエンドユーザーのzuid`」の[パス](https://docs.catalyst.zoho.com/ja/cloud-scale/help/stratus/objects/introduction/#path)に保存されます。 4. [削除](https://docs.catalyst.zoho.com/ja/cloud-scale/help/stratus/objects/manage-object/delete-object/)および[ダウンロード](https://docs.catalyst.zoho.com/ja/cloud-scale/help/stratus/objects/manage-object/download-object/)操作を処理するロジックは、`handleDownload()`および`handleDelete()`関数で定義されています。エンドユーザーがダウンロード操作を開始すると、[ダウンロードWeb SDKメソッド](https://docs.catalyst.zoho.com/ja/sdk/nodejs/v2/cloud-scale/stratus/download-object/)を使用して画像がローカルシステムにダウンロードされます。画像がアプリケーションから削除された場合、[削除Web SDKメソッド](https://docs.catalyst.zoho.com/ja/sdk/nodejs/v2/cloud-scale/stratus/delete-objects/)を使用してStratusからも削除されます。 5. 共有機能を処理するロジックは`handleShareAction()`で定義されており、`/shareDetails` APIを呼び出して必要な画像を別の登録ユーザーと共有します。エンドユーザーが共有機能を開始すると、`/getAllUsers` APIエンドポイントが呼び出され、アプリケーションのすべての登録エンドユーザーのリストが返されます。登録ユーザーのリストを使用して、エンドユーザーは必要な画像を**View**または**Edit**アクセスで共有することを選択できます。**View**アクセスでは共有メンバーは画像のダウンロードのみが許可され、**Edit**アクセスでは更新、ダウンロード、または画像の削除が許可されます。