-
Notifications
You must be signed in to change notification settings - Fork 0
feat: provide collection functionality #128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
f3a2918
feat: add collection type definitions and Firestore rules
rindrics d942ca4
feat: add collection CRUD library functions
rindrics 2fa46d4
feat: add collection UI components
rindrics 2948163
feat: add collection pages
rindrics f8c3cf0
feat: integrate collection button into content pages
rindrics 7139b32
fix: prevent userId mutation in publicCollections update rule
rindrics 1c8d76c
fix: prevent unauthenticated users from navigating to protected /coll…
rindrics dc306b4
fix: surface collection operation errors to user with inline messages
rindrics d644650
refactor: extract content display helpers to shared module
rindrics eb181bf
fix: prevent orphaned documents in deleteCollection with cleanup pass
rindrics 380516e
perf: eliminate N+1 reads in getCollections by denormalizing contentC…
rindrics 4525629
fix: add null-safe chaining to all Timestamp.toMillis() calls
rindrics 45c8692
perf: parallelize reads in getCollectionsForContent with Promise.all
rindrics 30d8e7d
fix: verify isPublic flag in getPublicCollectionWithContents
rindrics f061350
fix(collections): improve error handling
rindrics c96cc60
fix(collections): remove expensive subcollection read
rindrics f50fa61
fix(collections): make content add/remove operations atomic using tra…
rindrics d029015
fix(collections): eliminate TOCTOU race condition in updateCollection…
rindrics File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,7 +23,30 @@ service cloud.firestore { | |
| allow read, write: if request.auth != null && request.auth.uid == userId; | ||
| } | ||
| } | ||
|
|
||
|
|
||
| // Collections: users can only read/write their own collections | ||
| match /collections/{userId} { | ||
| allow read, write: if request.auth != null && request.auth.uid == userId; | ||
|
|
||
| // Collection items subcollection | ||
| match /items/{collectionId} { | ||
| allow read, write: if request.auth != null && request.auth.uid == userId; | ||
|
|
||
| // Contents within a collection | ||
| match /contents/{contentId} { | ||
| allow read, write: if request.auth != null && request.auth.uid == userId; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Public collections: anyone can read, only owner can write | ||
| match /publicCollections/{collectionId} { | ||
| allow read: if true; | ||
| allow create: if request.auth != null && request.resource.data.userId == request.auth.uid; | ||
| allow update: if request.auth != null && resource.data.userId == request.auth.uid && request.resource.data.userId == resource.data.userId; | ||
| allow delete: if request.auth != null && resource.data.userId == request.auth.uid; | ||
| } | ||
|
|
||
| // Legacy admin-only rules for other collections | ||
| match /{document=**} { | ||
| // Anyone can read (public content) | ||
|
Comment on lines
+27
to
52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 6. Catch-all read bypass • The new /collections/{userId} rules intend to restrict reads/writes to the owning user, but the
existing catch-all match /{document=**} still grants allow read: if true.
• Firestore allows are effectively OR’ed across matching rules, so this makes collections (and other
user data) publicly readable despite the new restrictions.
• This is a critical privacy/security issue and should be fixed before shipping collections.
Agent Prompt
|
||
|
|
||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,276 @@ | ||
| 'use client'; | ||
|
|
||
| import Link from 'next/link'; | ||
| import { useCallback, useEffect, useState } from 'react'; | ||
| import { CollectionDetailModal } from '@/components/CollectionDetailModal'; | ||
| import { CreateCollectionModal } from '@/components/CreateCollectionModal'; | ||
| import { PageWithSidebar } from '@/components/PageWithSidebar'; | ||
| import { useAuth } from '@/contexts/AuthContext'; | ||
| import { | ||
| deleteCollection, | ||
| getCollections, | ||
| updateCollection, | ||
| } from '@/lib/collections'; | ||
| import type { CollectionSummary } from '@/types/collection'; | ||
|
|
||
| export default function CollectionsPage() { | ||
| const { user, loading } = useAuth(); | ||
| const [collections, setCollections] = useState<CollectionSummary[]>([]); | ||
| const [isLoading, setIsLoading] = useState(false); | ||
| const [isCreating, setIsCreating] = useState(false); | ||
| const [viewingId, setViewingId] = useState<string | null>(null); | ||
| const [editingId, setEditingId] = useState<string | null>(null); | ||
| const [editingName, setEditingName] = useState(''); | ||
|
|
||
| const loadCollections = useCallback(async () => { | ||
| if (!user) return; | ||
|
|
||
| setIsLoading(true); | ||
| try { | ||
| const cols = await getCollections(user.uid); | ||
| setCollections(cols); | ||
| } catch (error) { | ||
| console.error('Failed to load collections:', error); | ||
| setCollections([]); | ||
| } finally { | ||
| setIsLoading(false); | ||
| } | ||
| }, [user]); | ||
|
|
||
| useEffect(() => { | ||
| if (!user || loading) { | ||
| setCollections([]); | ||
| return; | ||
| } | ||
|
|
||
| loadCollections(); | ||
|
|
||
| const handleCollectionsChanged = () => { | ||
| loadCollections(); | ||
| }; | ||
| window.addEventListener('collections-changed', handleCollectionsChanged); | ||
|
|
||
| return () => { | ||
| window.removeEventListener( | ||
| 'collections-changed', | ||
| handleCollectionsChanged, | ||
| ); | ||
| }; | ||
| }, [user, loading, loadCollections]); | ||
|
|
||
| const handleDelete = async (collectionId: string, collectionName: string) => { | ||
| if (!user) return; | ||
| if (!confirm(`「${collectionName}」を削除しますか?`)) return; | ||
|
|
||
| try { | ||
| await deleteCollection(user.uid, collectionId); | ||
| window.dispatchEvent(new CustomEvent('collections-changed')); | ||
| } catch (error) { | ||
| console.error('Failed to delete collection:', error); | ||
| alert('削除に失敗しました'); | ||
| } | ||
| }; | ||
|
|
||
| const handleStartEdit = (collection: CollectionSummary) => { | ||
| setEditingId(collection.id); | ||
| setEditingName(collection.name); | ||
| }; | ||
|
|
||
| const handleSaveEdit = async () => { | ||
| if (!user || !editingId || !editingName.trim()) return; | ||
|
|
||
| try { | ||
| await updateCollection(user.uid, editingId, { name: editingName.trim() }); | ||
| setEditingId(null); | ||
| setEditingName(''); | ||
| window.dispatchEvent(new CustomEvent('collections-changed')); | ||
| } catch (error) { | ||
| console.error('Failed to update collection:', error); | ||
| alert('更新に失敗しました'); | ||
| } | ||
| }; | ||
|
|
||
| const handleCancelEdit = () => { | ||
| setEditingId(null); | ||
| setEditingName(''); | ||
| }; | ||
|
|
||
| const handleTogglePublic = async (collection: CollectionSummary) => { | ||
| if (!user) return; | ||
|
|
||
| try { | ||
| await updateCollection(user.uid, collection.id, { | ||
| isPublic: !collection.isPublic, | ||
| }); | ||
| window.dispatchEvent(new CustomEvent('collections-changed')); | ||
| } catch (error) { | ||
| console.error('Failed to toggle public status:', error); | ||
| alert('公開設定の変更に失敗しました'); | ||
| } | ||
| }; | ||
|
|
||
| if (loading || isLoading) { | ||
| return ( | ||
| <PageWithSidebar maxWidth="4xl" showSidebar={false}> | ||
| <h1 className="mb-8 text-3xl font-bold text-black dark:text-white"> | ||
| マイコレクション | ||
| </h1> | ||
| <div className="text-zinc-500">読み込み中...</div> | ||
| </PageWithSidebar> | ||
| ); | ||
| } | ||
|
|
||
| if (!user) { | ||
| return ( | ||
| <PageWithSidebar maxWidth="4xl" showSidebar={false}> | ||
| <h1 className="mb-8 text-3xl font-bold text-black dark:text-white"> | ||
| マイコレクション | ||
| </h1> | ||
| <div className="text-zinc-500"> | ||
| コレクション機能をご利用いただくには、右上の「 | ||
| <span className="font-medium text-zinc-700 dark:text-zinc-300"> | ||
| 我入門也 | ||
| </span> | ||
| 」からログインしてください。 | ||
| </div> | ||
| </PageWithSidebar> | ||
| ); | ||
| } | ||
|
|
||
| return ( | ||
| <PageWithSidebar maxWidth="4xl" showSidebar={false}> | ||
| <div className="mb-8 flex items-center justify-between"> | ||
| <h1 className="text-3xl font-bold text-black dark:text-white"> | ||
| マイコレクション | ||
| </h1> | ||
| <button | ||
| type="button" | ||
| onClick={() => setIsCreating(true)} | ||
| className="rounded-lg bg-zinc-700 px-4 py-2 text-sm text-white hover:bg-zinc-800 dark:bg-zinc-600 dark:hover:bg-zinc-500" | ||
| > | ||
| 新規作成 | ||
| </button> | ||
| </div> | ||
|
|
||
| <div className="mb-4"> | ||
| <Link | ||
| href="/collections/public" | ||
| className="text-sm text-zinc-600 hover:text-zinc-800 dark:text-zinc-400 dark:hover:text-zinc-200" | ||
| > | ||
| 公開コレクションを見る → | ||
| </Link> | ||
| </div> | ||
|
|
||
| {collections.length === 0 ? ( | ||
| <div className="text-zinc-500"> | ||
| コレクションがありません。「新規作成」ボタンからコレクションを作成してください。 | ||
| </div> | ||
| ) : ( | ||
| <ul className="space-y-4"> | ||
| {collections.map((collection) => ( | ||
| <li | ||
| key={collection.id} | ||
| className="rounded-lg bg-white p-4 shadow-sm dark:bg-zinc-900" | ||
| > | ||
| {editingId === collection.id ? ( | ||
| <div className="flex items-center gap-2"> | ||
| <input | ||
| type="text" | ||
| value={editingName} | ||
| onChange={(e) => setEditingName(e.target.value)} | ||
| className="flex-1 rounded border border-zinc-300 px-2 py-1 text-sm dark:border-zinc-700 dark:bg-zinc-800 dark:text-white" | ||
| onKeyDown={(e) => { | ||
| if (e.key === 'Enter') handleSaveEdit(); | ||
| if (e.key === 'Escape') handleCancelEdit(); | ||
| }} | ||
| /> | ||
| <button | ||
| type="button" | ||
| onClick={handleSaveEdit} | ||
| className="rounded bg-zinc-700 px-3 py-1 text-sm text-white hover:bg-zinc-800" | ||
| > | ||
| 保存 | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={handleCancelEdit} | ||
| className="rounded bg-zinc-200 px-3 py-1 text-sm text-zinc-700 hover:bg-zinc-300 dark:bg-zinc-800 dark:text-zinc-300" | ||
| > | ||
| キャンセル | ||
| </button> | ||
| </div> | ||
| ) : ( | ||
| <div className="flex items-start justify-between gap-4"> | ||
| <button | ||
| type="button" | ||
| onClick={() => setViewingId(collection.id)} | ||
| className="min-w-0 flex-1 text-left" | ||
| > | ||
| <div className="flex items-center gap-2"> | ||
| <span className="font-medium text-black dark:text-white"> | ||
| {collection.name} | ||
| </span> | ||
| {collection.isPublic && ( | ||
| <span className="rounded bg-zinc-200 px-1.5 py-0.5 text-xs text-zinc-600 dark:bg-zinc-700 dark:text-zinc-400"> | ||
| 公開 | ||
| </span> | ||
| )} | ||
| </div> | ||
| {collection.description && ( | ||
| <div className="mt-1 text-sm text-zinc-500"> | ||
| {collection.description} | ||
| </div> | ||
| )} | ||
| <div className="mt-1 text-xs text-zinc-400"> | ||
| {collection.contentCount}件のコンテンツ | ||
| </div> | ||
| </button> | ||
| <div className="flex shrink-0 items-center gap-2"> | ||
| <button | ||
| type="button" | ||
| onClick={() => handleTogglePublic(collection)} | ||
| className="rounded px-2 py-1 text-xs text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-300" | ||
| title={collection.isPublic ? '非公開にする' : '公開する'} | ||
| > | ||
| {collection.isPublic ? '非公開にする' : '公開する'} | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={() => handleStartEdit(collection)} | ||
| className="rounded px-2 py-1 text-xs text-zinc-500 hover:bg-zinc-100 hover:text-zinc-700 dark:hover:bg-zinc-800 dark:hover:text-zinc-300" | ||
| > | ||
| 編集 | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={() => | ||
| handleDelete(collection.id, collection.name) | ||
| } | ||
| className="rounded px-2 py-1 text-xs text-red-500 hover:bg-red-50 hover:text-red-700 dark:hover:bg-red-900/20" | ||
| > | ||
| 削除 | ||
| </button> | ||
| </div> | ||
| </div> | ||
| )} | ||
| </li> | ||
| ))} | ||
| </ul> | ||
| )} | ||
|
|
||
| {isCreating && ( | ||
| <CreateCollectionModal | ||
| onClose={() => setIsCreating(false)} | ||
| onCreated={() => setIsCreating(false)} | ||
| /> | ||
| )} | ||
|
|
||
| {viewingId && ( | ||
| <CollectionDetailModal | ||
| collectionId={viewingId} | ||
| onClose={() => setViewingId(null)} | ||
| /> | ||
| )} | ||
| </PageWithSidebar> | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.