diff --git a/hosting/.eslintrc.js b/hosting/.eslintrc.js index b19f8749..7c473da8 100644 --- a/hosting/.eslintrc.js +++ b/hosting/.eslintrc.js @@ -17,7 +17,14 @@ module.exports = { es2021: true, }, - extends: ["eslint:recommended", "plugin:react/recommended", "plugin:@typescript-eslint/recommended", "google"], + extends: [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:@typescript-eslint/recommended", + "plugin:prettier/recommended", + "plugin:@next/next/recommended", + "google", + ], plugins: ["react", "@typescript-eslint", "prettier"], @@ -38,7 +45,7 @@ module.exports = { "max-len": "off", "require-jsdoc": "off", "prettier/prettier": "error", - indent: ["error", 2], + indent: ["error", 2, {SwitchCase: 1}], "operator-linebreak": ["error", "before"], quotes: "off", // Use config quotes from prettier, so we turn off this rules to avoiding conflict between eslint and prettier }, diff --git a/hosting/package-lock.json b/hosting/package-lock.json index 64f1e380..0665cf50 100644 --- a/hosting/package-lock.json +++ b/hosting/package-lock.json @@ -19,13 +19,14 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@next/eslint-plugin-next": "^14.2.3", "@types/node": "^20", "@types/react": "^18.3.2", "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^7.10.0", "@typescript-eslint/parser": "^7.10.0", "autoprefixer": "^10.0.1", - "eslint": "^8", + "eslint": "^8.57.0", "eslint-config-google": "^0.14.0", "eslint-config-next": "14.1.0", "eslint-config-prettier": "^9.1.0", @@ -910,11 +911,10 @@ "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.1.0.tgz", - "integrity": "sha512-x4FavbNEeXx/baD/zC/SdrvkjSby8nBn8KcCREqk6UuwvwoAPZmaV8TFCAuo/cpovBRTIY67mHhe86MQQm/68Q==", + "version": "14.2.3", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.2.3.tgz", + "integrity": "sha512-L3oDricIIjgj1AVnRdRor21gI7mShlSwU/1ZGHmqM3LzHhXXhdkrfeNY5zif25Bi5Dd7fiJHsbhoZCHfXYvlAw==", "dev": true, - "license": "MIT", "dependencies": { "glob": "10.3.10" } @@ -2555,7 +2555,6 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", "dev": true, - "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -2646,6 +2645,15 @@ } } }, + "node_modules/eslint-config-next/node_modules/@next/eslint-plugin-next": { + "version": "14.1.0", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-14.1.0.tgz", + "integrity": "sha512-x4FavbNEeXx/baD/zC/SdrvkjSby8nBn8KcCREqk6UuwvwoAPZmaV8TFCAuo/cpovBRTIY67mHhe86MQQm/68Q==", + "dev": true, + "dependencies": { + "glob": "10.3.10" + } + }, "node_modules/eslint-config-next/node_modules/@typescript-eslint/parser": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.21.0.tgz", @@ -2788,7 +2796,6 @@ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-9.1.0.tgz", "integrity": "sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==", "dev": true, - "license": "MIT", "bin": { "eslint-config-prettier": "bin/cli.js" }, @@ -2978,7 +2985,6 @@ "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.1.3.tgz", "integrity": "sha512-C9GCVAs4Eq7ZC/XFQHITLiHJxQngdtraXaM+LoUFoFp/lHNl2Zn8f3WQbe9HvTBBQ9YnKFB0/2Ajdqwo5D1EAw==", "dev": true, - "license": "MIT", "dependencies": { "prettier-linter-helpers": "^1.0.0", "synckit": "^0.8.6" @@ -5186,7 +5192,6 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz", "integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==", "dev": true, - "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, diff --git a/hosting/package.json b/hosting/package.json index bfbaa674..c3bd6ec2 100644 --- a/hosting/package.json +++ b/hosting/package.json @@ -9,7 +9,7 @@ "lint": "next lint", "lint:fix": "next lint --fix", "prettier:fix": "prettier --write .", - "codecheck": "npm run lint:fix && npm run prettier:fix" + "codecheck": "npm run prettier:fix && npm run lint:fix" }, "dependencies": { "apexcharts": "^3.45.2", @@ -23,13 +23,14 @@ "react-dom": "^18.3.1" }, "devDependencies": { + "@next/eslint-plugin-next": "^14.2.3", "@types/node": "^20", "@types/react": "^18.3.2", "@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^7.10.0", "@typescript-eslint/parser": "^7.10.0", "autoprefixer": "^10.0.1", - "eslint": "^8", + "eslint": "^8.57.0", "eslint-config-google": "^0.14.0", "eslint-config-next": "14.1.0", "eslint-config-prettier": "^9.1.0", diff --git a/hosting/src/app/[site]/content/[type]/page.tsx b/hosting/src/app/[site]/content/[type]/page.tsx new file mode 100644 index 00000000..e1689a20 --- /dev/null +++ b/hosting/src/app/[site]/content/[type]/page.tsx @@ -0,0 +1,47 @@ +"use client"; +import React from "react"; +import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb"; +import {Table, TableRowActions, TableRowLabel} from "@/components/Table"; +import DefaultLayout from "@/components/Layouts/DefaultLayout"; +import {useTanamDocuments} from "@/hooks/useTanamDocuments"; +import Alerts from "@/components/common/Alerts"; + +import {useTanamDocumentType} from "@/hooks/useTanamDocumentTypes"; +import Loader from "../../../../components/common/Loader"; + +export default function ContentOverviewPage() { + const {data: documents, error: docsError} = useTanamDocuments(); + const {data: documentType} = useTanamDocumentType(); + return ( + + {documentType ? : } + + {docsError ? ( + + ) : ( + [ +
+
{document.id}
+
, +

+ {document.createdAt.toDate().toUTCString()} +

, + , + console.log("View", document)} + onDelete={() => console.log("Delete", document)} + onDownload={() => console.log("Download", document)} + />, + ])} + /> + )} + + ); +} diff --git a/hosting/src/app/[site]/layout.tsx b/hosting/src/app/[site]/layout.tsx index 9aea231f..f04b552a 100644 --- a/hosting/src/app/[site]/layout.tsx +++ b/hosting/src/app/[site]/layout.tsx @@ -15,7 +15,7 @@ interface RootLayoutProps { const RootLayout: React.FC = ({children}) => { const [loading, setLoading] = useState(true); const [errorMessage, setError] = useState(""); - const {siteData, error} = useTanamSite(); + const {data: siteData, error} = useTanamSite(); useEffect(() => { if (siteData || error) { diff --git a/hosting/src/app/[site]/tables/page.tsx b/hosting/src/app/[site]/tables/page.tsx deleted file mode 100644 index 7c764b9a..00000000 --- a/hosting/src/app/[site]/tables/page.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import Breadcrumb from "@/components/Breadcrumbs/Breadcrumb"; -import TableOne from "@/components/Tables/TableOne"; -import TableThree from "@/components/Tables/TableThree"; -import TableTwo from "@/components/Tables/TableTwo"; - -import {Metadata} from "next"; -import DefaultLayout from "@/components/Layouts/DefaultLayout"; - -export const metadata: Metadata = { - title: "Next.js Tables | TailAdmin - Next.js Dashboard Template", - description: "This is Next.js Tables page for TailAdmin - Next.js Tailwind CSS Admin Dashboard Template", -}; - -const TablesPage = () => { - return ( - - - -
- - - -
-
- ); -}; - -export default TablesPage; diff --git a/hosting/src/components/Dashboard/E-commerce.tsx b/hosting/src/components/Dashboard/E-commerce.tsx index acebacfe..00629ecc 100644 --- a/hosting/src/components/Dashboard/E-commerce.tsx +++ b/hosting/src/components/Dashboard/E-commerce.tsx @@ -4,7 +4,6 @@ import ChartOne from "@/components/Charts/ChartOne"; import ChartThree from "@/components/Charts/ChartThree"; import ChartTwo from "@/components/Charts/ChartTwo"; import ChatCard from "@/components/Chat/ChatCard"; -import TableOne from "@/components/Tables/TableOne"; import CardDataStats from "@/components/CardDataStats"; import MapOne from "@/components/Maps/MapOne"; @@ -103,9 +102,6 @@ const ECommerce: React.FC = () => { -
- -
diff --git a/hosting/src/components/Sidebar/index.tsx b/hosting/src/components/Sidebar/index.tsx index ca40604e..5478df9c 100644 --- a/hosting/src/components/Sidebar/index.tsx +++ b/hosting/src/components/Sidebar/index.tsx @@ -12,6 +12,7 @@ import {DashboardIcon} from "./icons/DashboardIcon"; import {FormsIcon} from "./icons/FormsIcon"; import {ProfileIcon} from "./icons/ProfileIcon"; import {SettingsIcon} from "./icons/SettingsIcon"; +import {useTanamSite} from "@/hooks/useTanamSite"; interface SidebarProps { sidebarOpen: boolean; @@ -20,10 +21,10 @@ interface SidebarProps { const Sidebar = ({sidebarOpen, setSidebarOpen}: SidebarProps) => { const pathname = usePathname() ?? "/"; - const site = pathname.split("/")[1]; const trigger = useRef(null); const sidebar = useRef(null); - const {data: documentTypes} = useTanamDocumentTypes(site); + const {data: documentTypes} = useTanamDocumentTypes(); + const {data: site} = useTanamSite(); const storedSidebarExpanded = "true"; @@ -87,9 +88,9 @@ const Sidebar = ({sidebarOpen, setSidebarOpen}: SidebarProps) => { } title="Content" - isExpanded={pathname.includes("/document-types/")} + isExpanded={pathname.includes("/content/")} menuItems={documentTypes.map((doc) => ({ - href: `/document-types/${doc.id}`, + href: `/${site?.id}/content/${doc.id}`, title: doc.title, }))} /> diff --git a/hosting/src/components/Table/Table.tsx b/hosting/src/components/Table/Table.tsx new file mode 100644 index 00000000..5528fd9e --- /dev/null +++ b/hosting/src/components/Table/Table.tsx @@ -0,0 +1,60 @@ +import React from "react"; + +interface TableProps { + headers: string[]; + rows: React.ReactNode[][]; +} + +/** + * A Table component + * + * Example: + * ```tsx + *
[ + *
{document.id}
, + *

{document.createdAt.toDate().toUTCString()}

, + * , + * console.log("View", document)} + * onDelete={() => console.log("Delete", document)} + * onDownload={() => console.log("Download", document)} + * />, + * ])} + * /> + * ``` + * + * @param {TableProps} param0 Table parameters + * @return {JSX.Element} Table component + */ +export function Table({headers, rows}: TableProps): JSX.Element { + return ( +
+
+
+ + + {headers.map((header, index) => ( + + ))} + + + + {rows.map((row, rowIndex) => ( + + {row.map((col, colIndex) => ( + + ))} + + ))} + +
+ {header} +
+ {col} +
+ + + ); +} diff --git a/hosting/src/components/Table/TableRowActions.tsx b/hosting/src/components/Table/TableRowActions.tsx new file mode 100644 index 00000000..4491427a --- /dev/null +++ b/hosting/src/components/Table/TableRowActions.tsx @@ -0,0 +1,103 @@ +import React from "react"; + +interface TableRowActionsProps { + onView?: () => void; + onDelete?: () => void; + onDownload?: () => void; +} + +export function TableRowActions({onView, onDelete, onDownload}: TableRowActionsProps) { + return ( +
+ {onView && ( + + )} + {onDelete && ( + + )} + {onDownload && ( + + )} +
+ ); +} + +function ViewIcon() { + return ( + + + + + ); +} + +function DeleteIcon() { + return ( + + + + + + + ); +} + +function DownloadIcon() { + return ( + + + + + ); +} diff --git a/hosting/src/components/Table/TableRowLabel.tsx b/hosting/src/components/Table/TableRowLabel.tsx new file mode 100644 index 00000000..10c5570b --- /dev/null +++ b/hosting/src/components/Table/TableRowLabel.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +type StatusType = "success" | "danger" | "warning" | "info"; + +interface TableRowLabelProps { + title: string; + status: StatusType; +} + +export function TableRowLabel({title, status}: TableRowLabelProps) { + let colorClasses = ""; + + switch (status) { + case "success": + colorClasses = "bg-success text-success"; + break; + case "danger": + colorClasses = "bg-danger text-danger"; + break; + case "warning": + colorClasses = "bg-warning text-warning"; + break; + case "info": + colorClasses = "bg-gray-300 text-gray-700"; + break; + } + + return ( +

{title}

+ ); +} diff --git a/hosting/src/components/Table/index.tsx b/hosting/src/components/Table/index.tsx new file mode 100644 index 00000000..1eb2cc2b --- /dev/null +++ b/hosting/src/components/Table/index.tsx @@ -0,0 +1,3 @@ +export {Table} from "./Table"; +export {TableRowActions} from "./TableRowActions"; +export {TableRowLabel} from "./TableRowLabel"; diff --git a/hosting/src/components/Tables/TableFour.tsx b/hosting/src/components/Tables/TableFour.tsx deleted file mode 100644 index 8cd593e7..00000000 --- a/hosting/src/components/Tables/TableFour.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import {BRAND} from "@/types/brand"; -import Image from "next/image"; -import DropdownDefault from "@/components/Dropdowns/DropdownDefault"; - -const brandData: BRAND[] = [ - { - logo: "/images/brand/brand-01.svg", - name: "Google", - visitors: 3.5, - revenues: "5,768", - sales: 590, - conversion: 4.8, - }, - { - logo: "/images/brand/brand-02.svg", - name: "Twitter", - visitors: 2.2, - revenues: "4,635", - sales: 467, - conversion: 4.3, - }, - { - logo: "/images/brand/brand-06.svg", - name: "Youtube", - visitors: 2.1, - revenues: "4,290", - sales: 420, - conversion: 3.7, - }, - { - logo: "/images/brand/brand-04.svg", - name: "Vimeo", - visitors: 1.5, - revenues: "3,580", - sales: 389, - conversion: 2.5, - }, - { - logo: "/images/brand/brand-05.svg", - name: "Facebook", - visitors: 3.5, - revenues: "6,768", - sales: 390, - conversion: 4.2, - }, -]; - -const TableFour: React.FC = () => { - return ( -
-
-
-
-

Top Channels

-
- -
- -
-
-
-
Source
-
-
-
Visitors
-
-
-
Revenues
-
-
-
Conversion
-
-
- - {brandData.map((brand, key) => ( -
-
-
- Brand -
-

{brand.name}

-
- -
-

{brand.visitors}K

-
- -
-

${brand.revenues}

-
- -
-

{brand.conversion}%

-
-
- ))} -
-
-
- ); -}; - -export default TableFour; diff --git a/hosting/src/components/Tables/TableOne.tsx b/hosting/src/components/Tables/TableOne.tsx deleted file mode 100644 index c9978335..00000000 --- a/hosting/src/components/Tables/TableOne.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import {BRAND} from "@/types/brand"; -import Image from "next/image"; - -const brandData: BRAND[] = [ - { - logo: "/images/brand/brand-01.svg", - name: "Google", - visitors: 3.5, - revenues: "5,768", - sales: 590, - conversion: 4.8, - }, - { - logo: "/images/brand/brand-02.svg", - name: "Twitter", - visitors: 2.2, - revenues: "4,635", - sales: 467, - conversion: 4.3, - }, - { - logo: "/images/brand/brand-03.svg", - name: "Github", - visitors: 2.1, - revenues: "4,290", - sales: 420, - conversion: 3.7, - }, - { - logo: "/images/brand/brand-04.svg", - name: "Vimeo", - visitors: 1.5, - revenues: "3,580", - sales: 389, - conversion: 2.5, - }, - { - logo: "/images/brand/brand-05.svg", - name: "Facebook", - visitors: 3.5, - revenues: "6,768", - sales: 390, - conversion: 4.2, - }, -]; - -const TableOne = () => { - return ( -
-

Top Channels

- -
-
-
-
Source
-
-
-
Visitors
-
-
-
Revenues
-
-
-
Sales
-
-
-
Conversion
-
-
- - {brandData.map((brand, key) => ( -
-
-
- Brand -
-

{brand.name}

-
- -
-

{brand.visitors}K

-
- -
-

${brand.revenues}

-
- -
-

{brand.sales}

-
- -
-

{brand.conversion}%

-
-
- ))} -
-
- ); -}; - -export default TableOne; diff --git a/hosting/src/components/Tables/TableThree.tsx b/hosting/src/components/Tables/TableThree.tsx deleted file mode 100644 index 85158f78..00000000 --- a/hosting/src/components/Tables/TableThree.tsx +++ /dev/null @@ -1,144 +0,0 @@ -import {Package} from "@/types/package"; - -const packageData: Package[] = [ - { - name: "Free package", - price: 0.0, - invoiceDate: "Jan 13,2023", - status: "Paid", - }, - { - name: "Standard Package", - price: 59.0, - invoiceDate: "Jan 13,2023", - status: "Paid", - }, - { - name: "Business Package", - price: 99.0, - invoiceDate: "Jan 13,2023", - status: "Unpaid", - }, - { - name: "Standard Package", - price: 59.0, - invoiceDate: "Jan 13,2023", - status: "Pending", - }, -]; - -const TableThree = () => { - return ( -
-
- - - - - - - - - - - {packageData.map((packageItem, key) => ( - - - - - - - ))} - -
PackageInvoice dateStatusActions
-
{packageItem.name}
-

${packageItem.price}

-
-

{packageItem.invoiceDate}

-
-

- {packageItem.status} -

-
-
- - - -
-
-
-
- ); -}; - -export default TableThree; diff --git a/hosting/src/components/Tables/TableTwo.tsx b/hosting/src/components/Tables/TableTwo.tsx deleted file mode 100644 index 93324b6b..00000000 --- a/hosting/src/components/Tables/TableTwo.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import Image from "next/image"; -import {Product} from "@/types/product"; - -const productData: Product[] = [ - { - image: "/images/product/product-01.png", - name: "Apple Watch Series 7", - category: "Electronics", - price: 296, - sold: 22, - profit: 45, - }, - { - image: "/images/product/product-02.png", - name: "Macbook Pro M1", - category: "Electronics", - price: 546, - sold: 12, - profit: 125, - }, - { - image: "/images/product/product-03.png", - name: "Dell Inspiron 15", - category: "Electronics", - price: 443, - sold: 64, - profit: 247, - }, - { - image: "/images/product/product-04.png", - name: "HP Probook 450", - category: "Electronics", - price: 499, - sold: 72, - profit: 103, - }, -]; - -const TableTwo = () => { - return ( -
-
-

Top Products

-
- -
-
-

Product Name

-
-
-

Category

-
-
-

Price

-
-
-

Sold

-
-
-

Profit

-
-
- - {productData.map((product, key) => ( -
-
-
-
- Product -
-

{product.name}

-
-
-
-

{product.category}

-
-
-

${product.price}

-
-
-

{product.sold}

-
-
-

${product.profit}

-
-
- ))} -
- ); -}; - -export default TableTwo; diff --git a/hosting/src/components/common/Alerts.tsx b/hosting/src/components/common/Alerts.tsx new file mode 100644 index 00000000..4a6b5ea1 --- /dev/null +++ b/hosting/src/components/common/Alerts.tsx @@ -0,0 +1,82 @@ +import React from "react"; + +interface NotificationProps { + type: "warning" | "success" | "error"; + title: string; + message: string; +} + +function Notification({type, title, message}: NotificationProps) { + let colorClasses = ""; + let IconComponent = null; + + switch (type) { + case "warning": + colorClasses = "border-warning bg-warning bg-opacity-[15%] text-[#9D5425]"; + IconComponent = WarningIcon; + break; + case "success": + colorClasses = "border-[#34D399] bg-[#34D399] bg-opacity-[15%] text-black dark:text-[#34D399]"; + IconComponent = SuccessIcon; + break; + case "error": + colorClasses = "border-[#F87171] bg-[#F87171] bg-opacity-[15%] text-[#B45454]"; + IconComponent = ErrorIcon; + break; + } + + return ( +
+ {IconComponent && } +
+
{title}
+

{message}

+
+
+ ); +} + +function WarningIcon() { + return ( +
+ + + +
+ ); +} + +function SuccessIcon() { + return ( +
+ + + +
+ ); +} + +function ErrorIcon() { + return ( +
+ + + +
+ ); +} + +export default Notification; diff --git a/hosting/src/hooks/useTanamDocumentTypes.tsx b/hosting/src/hooks/useTanamDocumentTypes.tsx index 6cff31a5..cadc85e3 100644 --- a/hosting/src/hooks/useTanamDocumentTypes.tsx +++ b/hosting/src/hooks/useTanamDocumentTypes.tsx @@ -1,24 +1,35 @@ -import {useState, useEffect} from "react"; import {firestore} from "@/firebase"; import {TanamDocumentType} from "@/models/TanamDocumentType"; -import {collection, onSnapshot} from "firebase/firestore"; +import {collection, doc, onSnapshot} from "firebase/firestore"; +import {useParams} from "next/navigation"; +import {useEffect, useState} from "react"; interface TanamDocumentTypeHook { data: TanamDocumentType[]; error: Error | null; } +interface SingleTanamDocumentTypeHook { + data: TanamDocumentType | null; + error: Error | null; +} + /** * Hook to get a stream of Tanam document types * - * @param {string} site ID of the site * @return {TanamDocumentTypeHook} Hook for document types subscription */ -export function useTanamDocumentTypes(site: string): TanamDocumentTypeHook { +export function useTanamDocumentTypes(): TanamDocumentTypeHook { + const {site} = useParams<{site: string}>() ?? {site: null}; const [data, setData] = useState([]); const [error, setError] = useState(null); useEffect(() => { + if (!site) { + setError(new Error("No site parameter provided")); + return; + } + const collectionRef = collection(firestore, `tanam/${site}/document-types`); const unsubscribe = onSnapshot( @@ -43,3 +54,48 @@ export function useTanamDocumentTypes(site: string): TanamDocumentTypeHook { return {data, error}; } + +/** + * Hook to get a single Tanam document type + * + * @param {string?} documentTypeId Optional document type ID (default to content parameter from URL). + * @return {SingleTanamDocumentTypeHook} Hook for single document type subscription + */ +export function useTanamDocumentType(documentTypeId?: string): SingleTanamDocumentTypeHook { + const {site, type: paramType} = useParams<{site: string; type: string}>() ?? {site: null, type: null}; + const typeId = documentTypeId ?? paramType; + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!site) { + setError(new Error("No site parameter provided")); + return; + } + if (!typeId) { + setError(new Error("Document type ID parameter is missing")); + return; + } + + const docRef = doc(firestore, `tanam/${site}/document-types`, typeId); + + const unsubscribe = onSnapshot( + docRef, + (doc) => { + if (doc.exists()) { + setData(TanamDocumentType.fromJson({id: doc.id, ...doc.data()})); + } else { + setError(new Error("Document type not found")); + } + }, + (err) => { + setError(err); + }, + ); + + // Cleanup subscription on unmount + return () => unsubscribe(); + }, [site, typeId]); + + return {data, error}; +} diff --git a/hosting/src/hooks/useTanamDocuments.tsx b/hosting/src/hooks/useTanamDocuments.tsx new file mode 100644 index 00000000..6f8e7333 --- /dev/null +++ b/hosting/src/hooks/useTanamDocuments.tsx @@ -0,0 +1,58 @@ +import {firestore} from "@/firebase"; +import {TanamDocument} from "@/models/TanamDocument"; +import {collection, onSnapshot, query, where} from "firebase/firestore"; +import {useParams} from "next/navigation"; +import {useEffect, useState} from "react"; + +interface UseTanamDocumentsResult { + data: TanamDocument[]; + error: Error | null; +} + +/** + * Hook to get a stream of documents of a specific content type + * + * @param {string?} documentType Optional document type (default to content parameter from URL). + * @return {UseTanamDocumentsResult} Hook for documents subscription + */ +export function useTanamDocuments(documentType?: string): UseTanamDocumentsResult { + const {site, type: paramType} = useParams<{site: string; type: string}>() ?? {site: null, content: null}; + const type = documentType ?? paramType; + const [data, setData] = useState([]); + const [error, setError] = useState(null); + + useEffect(() => { + if (!site) { + setError(new Error("Site parameter is missing")); + return; + } + if (!type) { + setError(new Error("Content type parameter is missing")); + return; + } + + const collectionRef = collection(firestore, `tanam/${site}/documents`); + const q = query(collectionRef, where("documentType", "==", type)); + + const unsubscribe = onSnapshot( + q, + (snapshot) => { + const documents = snapshot.docs.map((doc) => + TanamDocument.fromJson({ + id: doc.id, + ...doc.data(), + }), + ); + setData(documents); + }, + (err) => { + setError(err); + }, + ); + + // Cleanup subscription on unmount + return () => unsubscribe(); + }, [site, type]); + + return {data, error}; +} diff --git a/hosting/src/hooks/useTanamSite.tsx b/hosting/src/hooks/useTanamSite.tsx index e4fcc3a7..02079431 100644 --- a/hosting/src/hooks/useTanamSite.tsx +++ b/hosting/src/hooks/useTanamSite.tsx @@ -1,12 +1,12 @@ -import {useState, useEffect} from "react"; import {firestore} from "@/firebase"; +import {TanamSite} from "@/models/TanamSite"; import {doc, getDoc} from "firebase/firestore"; -import {TanamSite} from "@/models/tanamSite"; import {useParams} from "next/navigation"; +import {useEffect, useState} from "react"; export function useTanamSite() { const {site} = useParams<{site: string}>() ?? {site: null}; - const [siteData, setSiteData] = useState(null); + const [data, setSiteData] = useState(null); const [error, setError] = useState(null); useEffect(() => { @@ -39,5 +39,5 @@ export function useTanamSite() { } } - return {siteData, error}; + return {data, error}; } diff --git a/hosting/src/models/TanamDocument.ts b/hosting/src/models/TanamDocument.ts new file mode 100644 index 00000000..4195ff80 --- /dev/null +++ b/hosting/src/models/TanamDocument.ts @@ -0,0 +1,81 @@ +import {FieldValue, Timestamp, serverTimestamp} from "firebase/firestore"; + +interface DocumentData { + [key: string]: any; +} + +/** + * Tanam document model. + */ +export class TanamDocument { + /** + * Constructor. + * + * @param {string} id Document ID + * @param {string | null} canonicalUrl Canonical URL of the document + * @param {DocumentData} data Data of the document + * @param {string} documentType Type of the document + * @param {Timestamp} publishedAt Date when the document was published + * @param {number} revision Revision number of the document + * @param {boolean} standalone Whether the document is standalone + * @param {string} status Status of the document + * @param {Tags} tags Tags associated with the document + * @param {Timestamp} createdAt Date when the document was created + * @param {Timestamp} updatedAt Date when the document was last updated + */ + constructor( + public readonly id: string, + public canonicalUrl: string | null, + public data: DocumentData, + public documentType: string, + public publishedAt: Timestamp, + public revision: number, + public standalone: boolean, + public status: string, + public tags: string[], + public readonly createdAt: Timestamp, + public readonly updatedAt: Timestamp, + ) {} + + /** + * Static factory constructor. + * + * @param {any} json JSON representation of the document + * @return {TanamDocument} Document instance + */ + static fromJson(json: any): TanamDocument { + return new TanamDocument( + json.id, + json.canonicalUrl, + json.data, + json.documentType, + json.published.toDate(), + json.revision, + json.standalone, + json.status, + json.tags, + json.created || json.createdAt, + json.updated || json.updatedAt, + ); + } + + /** + * Serialize to JSON for Firestore. + * + * @return {any} JSON representation of the document + */ + toJson(): any { + return { + canonicalUrl: this.canonicalUrl, + data: this.data, + documentType: this.documentType, + published: this.publishedAt, + revision: this.revision, + standalone: this.standalone, + status: this.status, + tags: this.tags, + created: this.createdAt, + updatedAt: serverTimestamp() as FieldValue, + }; + } +} diff --git a/hosting/src/models/TanamDocumentType.ts b/hosting/src/models/TanamDocumentType.ts index 62ede89c..d503092d 100644 --- a/hosting/src/models/TanamDocumentType.ts +++ b/hosting/src/models/TanamDocumentType.ts @@ -1,13 +1,33 @@ +/** + * Tanam document type model. + */ export class TanamDocumentType { + /** + * Constructor. + * + * @param {string} id document type ID + * @param {string} title Document type title + */ constructor( - public id: string, + public readonly id: string, public title: string, ) {} + /** + * Static factory constructor. + * + * @param {any} json JSON representation of the document type + * @return {TanamDocumentType} Document type instance + */ static fromJson(json: any): TanamDocumentType { return new TanamDocumentType(json.id, json.title); } + /** + * Serialize to JSON for Firestore + * + * @return {any} JSON representation of the document type + */ toJson(): any { return { id: this.id, diff --git a/hosting/src/models/tanamSite.ts b/hosting/src/models/TanamSite.ts similarity index 100% rename from hosting/src/models/tanamSite.ts rename to hosting/src/models/TanamSite.ts