+
+
+ {import.meta.env.DEV && isEditable &&
}
)
}
diff --git a/arccode.dev/src/components/guild/Guild.tsx b/arccode.dev/src/components/guild/Guild.tsx
new file mode 100644
index 0000000..6b89c22
--- /dev/null
+++ b/arccode.dev/src/components/guild/Guild.tsx
@@ -0,0 +1,27 @@
+import useGuild from '~hooks/guild/useGuild'
+import useGuilds from '~hooks/guild/useGuilds'
+
+function Guild() {
+ const { guilds } = useGuilds()
+ const { guild } = useGuild()
+
+ if (!guilds.length) {
+ return null
+ }
+
+ if (!guild) {
+ return (
+
+ Start by selecting a guild
+
+ )
+ }
+
+ return (
+
+ {guild.name}
+
+ )
+}
+
+export default Guild
diff --git a/arccode.dev/src/components/guild/GuildProvider.tsx b/arccode.dev/src/components/guild/GuildProvider.tsx
new file mode 100644
index 0000000..c0b16ef
--- /dev/null
+++ b/arccode.dev/src/components/guild/GuildProvider.tsx
@@ -0,0 +1,43 @@
+import { type PropsWithChildren, useCallback, useMemo } from 'react'
+import { useSearchParams } from 'react-router-dom'
+
+import GuildContext, { GuildContextType } from '~contexts/guild/GuildContext'
+
+import useGuilds from '~hooks/guild/useGuilds'
+
+const GUILD_ID_SEARCH_PARAMETERS_KEY = 'guild'
+
+function GuildProvider({ children }: PropsWithChildren) {
+ const { guilds } = useGuilds()
+ const [searchParams, setSearchParams] = useSearchParams()
+ const guildId = searchParams.get(GUILD_ID_SEARCH_PARAMETERS_KEY) ?? ''
+ const guild = useMemo(() => guilds.find(x => x.id === guildId) ?? null, [guildId, guilds])
+
+ const setGuildId = useCallback((guildId: string) => {
+ setSearchParams(x => {
+ x.set(GUILD_ID_SEARCH_PARAMETERS_KEY, guildId)
+
+ return x
+ }, {
+ replace: true,
+ })
+ }, [
+ setSearchParams,
+ ])
+
+ const guildContextValue = useMemo
(() => ({
+ guild,
+ setGuildId,
+ }), [
+ guild,
+ setGuildId,
+ ])
+
+ return (
+
+ {children}
+
+ )
+}
+
+export default GuildProvider
diff --git a/arccode.dev/src/components/guild/Guilds.tsx b/arccode.dev/src/components/guild/Guilds.tsx
new file mode 100644
index 0000000..6ec29ab
--- /dev/null
+++ b/arccode.dev/src/components/guild/Guilds.tsx
@@ -0,0 +1,26 @@
+import GuildsProvider from '~components/guild/GuildsProvider'
+import GuildProvider from '~components/guild/GuildProvider'
+import GuildsList from '~components/guild/GuildsList'
+import Guild from '~components/guild/Guild'
+
+function Guilds() {
+ return (
+
+
+
+ Guilds
+
+
+
+
+ )
+}
+
+export default Guilds
diff --git a/arccode.dev/src/components/guild/GuildsList.tsx b/arccode.dev/src/components/guild/GuildsList.tsx
new file mode 100644
index 0000000..2c1a030
--- /dev/null
+++ b/arccode.dev/src/components/guild/GuildsList.tsx
@@ -0,0 +1,61 @@
+import useUser from '~hooks/user/useUser'
+import useGuild from '~hooks/guild/useGuild'
+import useGuilds from '~hooks/guild/useGuilds'
+
+import Spinner from '~components/common/Spinner'
+import { Button } from '~components/ui/Button'
+
+function GuildsList() {
+ const { user } = useUser()
+ const { guilds, loadingGuilds, hasMoreGuilds, fetchMoreGuilds } = useGuilds()
+ const { setGuildId } = useGuild()
+
+ return (
+
+ {user?.isAdministrator && (
+
+
+
+ )}
+ {guilds.map(guild => (
+
setGuildId(guild.id)}
+ >
+
+ {guild.emoji}
+
+
+ {guild.name}
+
+
+ ))}
+ {!loadingGuilds && hasMoreGuilds && (
+
+
+
+ )}
+ {loadingGuilds && (
+
+
+
+ )}
+
+ )
+}
+
+export default GuildsList
diff --git a/arccode.dev/src/components/guild/GuildsProvider.tsx b/arccode.dev/src/components/guild/GuildsProvider.tsx
new file mode 100644
index 0000000..e67ebfb
--- /dev/null
+++ b/arccode.dev/src/components/guild/GuildsProvider.tsx
@@ -0,0 +1,63 @@
+import { type PropsWithChildren, useCallback, useMemo } from 'react'
+import { collection, or, orderBy, query, where } from 'firebase/firestore'
+
+import type { Guild } from '~types'
+
+import { NULL_DOCUMENT_ID } from '~constants'
+
+import { db } from '~firebase'
+
+import type { GuildsContextType } from '~contexts/guild/GuildsContext'
+import GuildsContext from '~contexts/guild/GuildsContext'
+
+import useUser from '~hooks/user/useUser'
+import usePaginatedDocuments from '~hooks/db/usePaginatedDocuments'
+
+function GuildsProvider({ children }: PropsWithChildren) {
+ const { user } = useUser()
+ const q = useMemo(() => query(
+ collection(db, 'guilds'),
+ or(
+ where('isPrivate', '==', false),
+ where('memberIds', 'array-contains', user?.id ?? NULL_DOCUMENT_ID),
+ ),
+ orderBy('lastMessageAt', 'desc'),
+ ), [
+ user?.id,
+ ])
+ const { data: guilds, loading, error, hasMore, fetchMore } = usePaginatedDocuments(q, 25)
+
+ const createGuild = useCallback(async (name: string) => {
+ console.log('name', name)
+ }, [])
+
+ const guildsContextValue = useMemo(() => ({
+ guilds,
+ loadingGuilds: loading,
+ hasMoreGuilds: hasMore,
+ createGuild,
+ fetchMoreGuilds: fetchMore,
+ }), [
+ guilds,
+ loading,
+ hasMore,
+ createGuild,
+ fetchMore,
+ ])
+
+ if (error) {
+ return (
+
+ An error occurred.
+
+ )
+ }
+
+ return (
+
+ {children}
+
+ )
+}
+
+export default GuildsProvider
diff --git a/arccode.dev/src/contexts/guild/GuildContext.ts b/arccode.dev/src/contexts/guild/GuildContext.ts
new file mode 100644
index 0000000..3d7b8c4
--- /dev/null
+++ b/arccode.dev/src/contexts/guild/GuildContext.ts
@@ -0,0 +1,13 @@
+import { createContext } from 'react'
+
+import type { Guild } from '~types'
+
+export type GuildContextType = {
+ guild: Guild | null
+ setGuildId: (guildId: string) => void
+}
+
+export default createContext({
+ guild: null,
+ setGuildId: () => {},
+})
diff --git a/arccode.dev/src/contexts/guild/GuildsContext.ts b/arccode.dev/src/contexts/guild/GuildsContext.ts
new file mode 100644
index 0000000..14989ab
--- /dev/null
+++ b/arccode.dev/src/contexts/guild/GuildsContext.ts
@@ -0,0 +1,19 @@
+import { createContext } from 'react'
+
+import type { Guild } from '~types'
+
+export type GuildsContextType = {
+ guilds: Guild[]
+ loadingGuilds: boolean
+ hasMoreGuilds: boolean
+ createGuild: (name: string) => Promise
+ fetchMoreGuilds: () => void
+}
+
+export default createContext({
+ guilds: [],
+ loadingGuilds: false,
+ hasMoreGuilds: false,
+ createGuild: async () => {},
+ fetchMoreGuilds: () => {},
+})
diff --git a/arccode.dev/src/hooks/db/useDocuments.ts b/arccode.dev/src/hooks/db/useDocuments.ts
index add784c..7c5964e 100644
--- a/arccode.dev/src/hooks/db/useDocuments.ts
+++ b/arccode.dev/src/hooks/db/useDocuments.ts
@@ -23,7 +23,9 @@ function useDocuments(query: Query, enabled = true)
console.error(error)
setError(error as Error)
}
- }, [query])
+ }, [
+ query,
+ ])
const fetch = useCallback(async () => {
if (!enabled) {
diff --git a/arccode.dev/src/hooks/db/usePaginatedDocuments.ts b/arccode.dev/src/hooks/db/usePaginatedDocuments.ts
new file mode 100644
index 0000000..2e249fe
--- /dev/null
+++ b/arccode.dev/src/hooks/db/usePaginatedDocuments.ts
@@ -0,0 +1,83 @@
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { type DocumentSnapshot, type Query, getDocs, limit, query, startAfter } from 'firebase/firestore'
+
+import type { DatabaseResource } from '~types'
+
+function usePaginatedDocuments(q: Query, limitTo = 100, enabled = true) {
+ const [data, setData] = useState>({})
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [cursor, setCursor] = useState(null)
+ const [nextCursor, setNextCursor] = useState(null)
+ const q2 = useMemo(() => cursor ? query(q, startAfter(cursor), limit(limitTo)) : query(q, limit(limitTo)), [q, limitTo, cursor])
+
+ const refetch = useCallback(async () => {
+ setLoading(true)
+
+ try {
+ const querySnapshot = await getDocs(q2)
+ const data: T[] = []
+ let lastDoc: DocumentSnapshot | null = null
+
+ querySnapshot.forEach(doc => {
+ data.push(doc.data() as T)
+ lastDoc = doc
+ })
+
+ setData(x => ({
+ ...x,
+ ...data.reduce((acc, x) => ({ ...acc, [x.id]: x }), {}),
+ }))
+ setNextCursor(data.length === limitTo ? lastDoc : null)
+ }
+ catch (error) {
+ console.error(error)
+ setError(error as Error)
+ }
+
+ setLoading(false)
+ }, [
+ limitTo,
+ q2,
+ ])
+
+ const fetch = useCallback(async () => {
+ if (!enabled) {
+ setData({})
+ setLoading(false)
+ setError(null)
+
+ return
+ }
+
+ await refetch()
+ }, [
+ enabled,
+ refetch,
+ ])
+
+ const fetchMore = useCallback(() => {
+ if (!nextCursor) return
+
+ setCursor(nextCursor)
+ }, [
+ nextCursor,
+ ])
+
+ useEffect(() => {
+ fetch()
+ }, [
+ fetch,
+ ])
+
+ return {
+ data: Object.values(data),
+ loading,
+ error,
+ hasMore: !!nextCursor,
+ refetch,
+ fetchMore,
+ }
+}
+
+export default usePaginatedDocuments
diff --git a/arccode.dev/src/hooks/guild/useGuild.ts b/arccode.dev/src/hooks/guild/useGuild.ts
new file mode 100644
index 0000000..f9d8a5d
--- /dev/null
+++ b/arccode.dev/src/hooks/guild/useGuild.ts
@@ -0,0 +1,9 @@
+import { useContext } from 'react'
+
+import GuildContext from '~contexts/guild/GuildContext'
+
+function useGuild() {
+ return useContext(GuildContext)
+}
+
+export default useGuild
diff --git a/arccode.dev/src/hooks/guild/useGuilds.ts b/arccode.dev/src/hooks/guild/useGuilds.ts
new file mode 100644
index 0000000..e387bc7
--- /dev/null
+++ b/arccode.dev/src/hooks/guild/useGuilds.ts
@@ -0,0 +1,9 @@
+import { useContext } from 'react'
+
+import GuildsContext from '~contexts/guild/GuildsContext'
+
+function useGuilds() {
+ return useContext(GuildsContext)
+}
+
+export default useGuilds
diff --git a/arccode.dev/src/router/subRouters/AdministratorSubRouter.tsx b/arccode.dev/src/router/subRouters/AdministratorSubRouter.tsx
index 931952d..e842b71 100644
--- a/arccode.dev/src/router/subRouters/AdministratorSubRouter.tsx
+++ b/arccode.dev/src/router/subRouters/AdministratorSubRouter.tsx
@@ -3,9 +3,12 @@ import { Outlet, Route, Routes } from 'react-router-dom'
import AdministratorLayout from '~app/administrator.../layout'
import Administrator from '~app/administrator.../page'
+import EmailsLayout from '~app/administrator.../emails/layout'
import Emails from '~app/administrator.../emails/page'
import UsersLayout from '~app/administrator.../users/layout'
import Users from '~app/administrator.../users/page'
+import GuildsLayout from '~app/administrator.../guilds/layout'
+import Guilds from '~app/administrator.../guilds/page'
import NotFound from '~components/common/NotFound'
@@ -22,7 +25,7 @@ function AdministratorSubRouter() {
/>
}
+ element={}
>
}
/>
+ }
+ >
+ }
+ />
+ }
+ />
+
}
diff --git a/arccode.dev/src/types.ts b/arccode.dev/src/types.ts
index 2175c7b..bae1d60 100644
--- a/arccode.dev/src/types.ts
+++ b/arccode.dev/src/types.ts
@@ -32,15 +32,18 @@ export type User = DatabaseResource<{
nUpdates: number
sentDailyRecapEmailAt: string
timezoneOffset: number
+ guildIds?: string[]
}>
-export type Email = DatabaseResource<{
- to: string
- message: {
- subject: string
- text?: string
- html?: string
- }
+export type Guild = DatabaseResource<{
+ name: string
+ description: string
+ emoji: string
+ isPrivate: boolean
+ administratorIds: string[]
+ moderatorIds: string[]
+ memberIds: string[]
+ lastMessageAt: string
}>
/* ---
diff --git a/arccode.dev/src/utils/db/createUser.ts b/arccode.dev/src/utils/db/createUser.ts
index 37872f8..21655fb 100644
--- a/arccode.dev/src/utils/db/createUser.ts
+++ b/arccode.dev/src/utils/db/createUser.ts
@@ -11,6 +11,7 @@ type CreateUserArg = Omit<
| 'nUpdates'
| 'sentDailyRecapEmailAt'
| 'timezoneOffset'
+ | 'guildIds'
| 'createdAt'
| 'updatedAt'
| 'deletedAt'
@@ -28,6 +29,7 @@ function createUser(user: CreateUserArg): User {
nUpdates: 0,
sentDailyRecapEmailAt: new Date(0).toISOString(),
timezoneOffset: new Date().getTimezoneOffset(),
+ guildIds: [],
createdAt: now,
updatedAt: now,
deletedAt: '',