diff --git a/src/app/(private)/dashboard/about-us/(about-us)/edit/page.tsx b/src/app/(private)/dashboard/about-us/(about-us)/edit/page.tsx
index 91e514a4..8a277699 100644
--- a/src/app/(private)/dashboard/about-us/(about-us)/edit/page.tsx
+++ b/src/app/(private)/dashboard/about-us/(about-us)/edit/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function AboutUsEditPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/about-us/contributors/edit/[id]/page.tsx b/src/app/(private)/dashboard/about-us/contributors/edit/[id]/page.tsx
index 6bcc63d1..1f8d7c5a 100644
--- a/src/app/(private)/dashboard/about-us/contributors/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/about-us/contributors/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditContributorPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/about-us/links/edit/[id]/page.tsx b/src/app/(private)/dashboard/about-us/links/edit/[id]/page.tsx
index 96914adf..a8e3dc83 100644
--- a/src/app/(private)/dashboard/about-us/links/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/about-us/links/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditAboutUsLinkPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/about-us/roles/edit/[id]/page.tsx b/src/app/(private)/dashboard/about-us/roles/edit/[id]/page.tsx
index 1df00a89..37383571 100644
--- a/src/app/(private)/dashboard/about-us/roles/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/about-us/roles/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditRolePage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/about-us/versions/edit/[id]/page.tsx b/src/app/(private)/dashboard/about-us/versions/edit/[id]/page.tsx
index 4ae0c5e5..dcb1ad76 100644
--- a/src/app/(private)/dashboard/about-us/versions/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/about-us/versions/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditMilestonePage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/academic-semesters/edit/[id]/page.tsx b/src/app/(private)/dashboard/academic-semesters/edit/[id]/page.tsx
index 85847ad4..a87d0d62 100644
--- a/src/app/(private)/dashboard/academic-semesters/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/academic-semesters/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditAcademicSemesterPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/banners/edit/[id]/page.tsx b/src/app/(private)/dashboard/banners/edit/[id]/page.tsx
index 2d91d6b9..a8cd151c 100644
--- a/src/app/(private)/dashboard/banners/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/banners/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditBannerPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/calendar-events/edit/[id]/page.tsx b/src/app/(private)/dashboard/calendar-events/edit/[id]/page.tsx
index 97958c08..43a64d08 100644
--- a/src/app/(private)/dashboard/calendar-events/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/calendar-events/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditCalendarEventPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/day-swaps/edit/[id]/page.tsx b/src/app/(private)/dashboard/day-swaps/edit/[id]/page.tsx
index e0b574af..d10598c9 100644
--- a/src/app/(private)/dashboard/day-swaps/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/day-swaps/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditDaySwapPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/departments/edit/[id]/page.tsx b/src/app/(private)/dashboard/departments/edit/[id]/page.tsx
index 0b4f581a..28a4c38e 100644
--- a/src/app/(private)/dashboard/departments/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/departments/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditDepartmentPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/guide-articles/edit/[id]/page.tsx b/src/app/(private)/dashboard/guide-articles/edit/[id]/page.tsx
index 1086716b..4b824bac 100644
--- a/src/app/(private)/dashboard/guide-articles/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/guide-articles/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditGuideArticlePage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/holidays/edit/[id]/page.tsx b/src/app/(private)/dashboard/holidays/edit/[id]/page.tsx
index 9efc47f0..dd86dd12 100644
--- a/src/app/(private)/dashboard/holidays/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/holidays/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditHolidayPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/map/aeds/edit/[id]/page.tsx b/src/app/(private)/dashboard/map/aeds/edit/[id]/page.tsx
index 8bd21454..616c63b9 100644
--- a/src/app/(private)/dashboard/map/aeds/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/map/aeds/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditAedPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/map/bicycle-showers/edit/[id]/page.tsx b/src/app/(private)/dashboard/map/bicycle-showers/edit/[id]/page.tsx
index c36f240f..36fa604f 100644
--- a/src/app/(private)/dashboard/map/bicycle-showers/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/map/bicycle-showers/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditBicycleShowerPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/map/buildings/edit/[id]/page.tsx b/src/app/(private)/dashboard/map/buildings/edit/[id]/page.tsx
index cada8e86..702208ad 100644
--- a/src/app/(private)/dashboard/map/buildings/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/map/buildings/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditBuildingPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/map/campuses/edit/[id]/page.tsx b/src/app/(private)/dashboard/map/campuses/edit/[id]/page.tsx
index 576b7ce5..7a256845 100644
--- a/src/app/(private)/dashboard/map/campuses/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/map/campuses/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditCampusPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/map/food-spots/edit/[id]/page.tsx b/src/app/(private)/dashboard/map/food-spots/edit/[id]/page.tsx
index 2e5e21f2..d4941c2c 100644
--- a/src/app/(private)/dashboard/map/food-spots/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/map/food-spots/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditFoodSpotPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/map/libraries/edit/[id]/page.tsx b/src/app/(private)/dashboard/map/libraries/edit/[id]/page.tsx
index 8868aeab..9446bf47 100644
--- a/src/app/(private)/dashboard/map/libraries/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/map/libraries/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditLibraryPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/map/pink-boxes/edit/[id]/page.tsx b/src/app/(private)/dashboard/map/pink-boxes/edit/[id]/page.tsx
index 244b4c01..87c507b1 100644
--- a/src/app/(private)/dashboard/map/pink-boxes/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/map/pink-boxes/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditPinkBoxPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/map/polinka-stations/edit/[id]/page.tsx b/src/app/(private)/dashboard/map/polinka-stations/edit/[id]/page.tsx
index 2aa4108d..513b83b3 100644
--- a/src/app/(private)/dashboard/map/polinka-stations/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/map/polinka-stations/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditPolinkaStationPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/misc/(misc)/edit/page.tsx b/src/app/(private)/dashboard/misc/(misc)/edit/page.tsx
index 9e4bf320..56aa38ce 100644
--- a/src/app/(private)/dashboard/misc/(misc)/edit/page.tsx
+++ b/src/app/(private)/dashboard/misc/(misc)/edit/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function MobileConfigEditPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/misc/sks-opening-hours/edit/[id]/page.tsx b/src/app/(private)/dashboard/misc/sks-opening-hours/edit/[id]/page.tsx
index 99627459..7edb606e 100644
--- a/src/app/(private)/dashboard/misc/sks-opening-hours/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/misc/sks-opening-hours/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditSksOpeningHoursPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/dashboard/notifications/topics/edit/[id]/page.tsx b/src/app/(private)/dashboard/notifications/topics/edit/[id]/page.tsx
index 813d4123..90976825 100644
--- a/src/app/(private)/dashboard/notifications/topics/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/notifications/topics/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditNotificationTopicPage(
diff --git a/src/app/(private)/dashboard/page.tsx b/src/app/(private)/dashboard/page.tsx
index 1aa82bf3..731ffd49 100644
--- a/src/app/(private)/dashboard/page.tsx
+++ b/src/app/(private)/dashboard/page.tsx
@@ -1,7 +1,11 @@
import { RefreshCcw } from "lucide-react";
import { DashboardButton } from "@/components/presentation/dashboard-button";
-import { getUserDisplayName } from "@/features/authentication";
+import {
+ getUserDisplayName,
+ isAdmin,
+ isSolvroAdmin,
+} from "@/features/authentication";
import { getAuthStateServer } from "@/features/authentication/server";
import { Resource } from "@/features/resources";
@@ -20,12 +24,21 @@ export default async function AdminPage() {
-
+ {isSolvroAdmin(user) || isAdmin(user) ? (
+
+ ) : (
+
+ )}
diff --git a/src/app/(private)/dashboard/student-organizations/edit/[id]/page.tsx b/src/app/(private)/dashboard/student-organizations/edit/[id]/page.tsx
index 36210da7..538883c4 100644
--- a/src/app/(private)/dashboard/student-organizations/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/student-organizations/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditStudentOrganizationPage(
diff --git a/src/app/(private)/dashboard/versions/edit/[id]/page.tsx b/src/app/(private)/dashboard/versions/edit/[id]/page.tsx
index 94de20cf..29c8df71 100644
--- a/src/app/(private)/dashboard/versions/edit/[id]/page.tsx
+++ b/src/app/(private)/dashboard/versions/edit/[id]/page.tsx
@@ -1,4 +1,5 @@
-import { AbstractResourceEditPage, Resource } from "@/features/resources";
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
import type { ResourceEditPageProps } from "@/types/components";
export default function EditVersionPage(props: ResourceEditPageProps) {
diff --git a/src/app/(private)/drafts/(resources)/guide-article-drafts/edit/[id]/page.tsx b/src/app/(private)/drafts/(resources)/guide-article-drafts/edit/[id]/page.tsx
new file mode 100644
index 00000000..1c6c6cdc
--- /dev/null
+++ b/src/app/(private)/drafts/(resources)/guide-article-drafts/edit/[id]/page.tsx
@@ -0,0 +1,13 @@
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
+import type { ResourceEditPageProps } from "@/types/components";
+
+export default function EditGuideArticlePage(props: ResourceEditPageProps) {
+ return (
+
+ );
+}
diff --git a/src/app/(private)/drafts/(resources)/student-organization-drafts/edit/[id]/page.tsx b/src/app/(private)/drafts/(resources)/student-organization-drafts/edit/[id]/page.tsx
new file mode 100644
index 00000000..44aef666
--- /dev/null
+++ b/src/app/(private)/drafts/(resources)/student-organization-drafts/edit/[id]/page.tsx
@@ -0,0 +1,15 @@
+import { Resource } from "@/features/resources";
+import { AbstractResourceEditPage } from "@/features/resources/server";
+import type { ResourceEditPageProps } from "@/types/components";
+
+export default function EditStudentOrganizationPage(
+ props: ResourceEditPageProps,
+) {
+ return (
+
+ );
+}
diff --git a/src/app/(private)/drafts/layout.tsx b/src/app/(private)/drafts/layout.tsx
new file mode 100644
index 00000000..3fda4972
--- /dev/null
+++ b/src/app/(private)/drafts/layout.tsx
@@ -0,0 +1,10 @@
+import { AbstractResourceLayout } from "@/features/resources/server";
+import type { WrapperProps } from "@/types/components";
+
+export default function ReviewLayout({ children }: WrapperProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/app/(private)/drafts/page.tsx b/src/app/(private)/drafts/page.tsx
new file mode 100644
index 00000000..653b48e1
--- /dev/null
+++ b/src/app/(private)/drafts/page.tsx
@@ -0,0 +1,5 @@
+import { DraftList } from "@/features/review/server";
+
+export default function ReviewPage() {
+ return
;
+}
diff --git a/src/app/(private)/review/page.tsx b/src/app/(private)/review/page.tsx
index 9e99cf89..653b48e1 100644
--- a/src/app/(private)/review/page.tsx
+++ b/src/app/(private)/review/page.tsx
@@ -1,6 +1,5 @@
-import { ErrorMessage } from "@/components/presentation/error-message";
-import { ApplicationError } from "@/config/enums";
+import { DraftList } from "@/features/review/server";
export default function ReviewPage() {
- return
;
+ return
;
}
diff --git a/src/features/abstract-resource-form/components/abstract-resource-form.tsx b/src/features/abstract-resource-form/components/abstract-resource-form.tsx
index 89dfc2bf..2fe6353b 100644
--- a/src/features/abstract-resource-form/components/abstract-resource-form.tsx
+++ b/src/features/abstract-resource-form/components/abstract-resource-form.tsx
@@ -1,9 +1,17 @@
import { get } from "react-hook-form";
import { ApiImage } from "@/features/backend/server";
-import { getResourceMetadata } from "@/features/resources";
+import {
+ RelationType,
+ getResourceMetadata,
+ getResourceQueryName,
+ getResourceRelationDefinitions,
+} from "@/features/resources";
import type { Resource } from "@/features/resources";
-import type { ResourceDefaultValues } from "@/features/resources/types";
+import type {
+ ResourceDefaultValues,
+ XToManyResource,
+} from "@/features/resources/types";
import type {
ExistingImages,
ResourceCreatePageProps,
@@ -60,6 +68,17 @@ export async function AbstractResourceForm
({
(defaultValues as Record)[key] = parsed;
}
+ const relationDefinitions = getResourceRelationDefinitions(resource);
+ for (const [relation, definition] of typedEntries(relationDefinitions)) {
+ if (definition.type === RelationType.ManyToOne) {
+ continue;
+ }
+ const queryName = getResourceQueryName(relation as XToManyResource);
+ if (!(queryName in (defaultValues as Record))) {
+ (defaultValues as Record)[queryName] = [];
+ }
+ }
+
const relatedResources = await fetchRelatedResources(resource);
const pivotResources = await fetchPivotResources(
metadata.form.inputs.relationInputs,
diff --git a/src/features/abstract-resource-form/components/arf-controller.tsx b/src/features/abstract-resource-form/components/arf-controller.tsx
index b80a4b06..82c2ec1c 100644
--- a/src/features/abstract-resource-form/components/arf-controller.tsx
+++ b/src/features/abstract-resource-form/components/arf-controller.tsx
@@ -8,9 +8,10 @@ import { toast } from "sonner";
import { ReturnButton } from "@/components/presentation/return-button";
import { Form } from "@/components/ui/form";
+import { isSolvroAdmin, useAuthentication } from "@/features/authentication";
import { fetchMutation, useMutationWrapper } from "@/features/backend";
import type { ModifyResourceResponse } from "@/features/backend/types";
-import { declineNoun } from "@/features/polish";
+import { GrammaticalCase, declineNoun } from "@/features/polish";
import type { Resource } from "@/features/resources";
import {
DeleteButtonWithDialog,
@@ -28,6 +29,7 @@ import type {
ResourcePk,
RoutableResource,
} from "@/features/resources/types";
+import { ApproveButton } from "@/features/review";
import { useRouter } from "@/hooks/use-router";
import { getToastMessages } from "@/lib/get-toast-messages";
import { cn } from "@/lib/utils";
@@ -58,15 +60,18 @@ export function ArfController({
relatedResources,
pivotResources,
className,
+ draft = false,
}: ResourceFormProps & {
defaultValues: ResourceDefaultValues;
existingImages: ExistingImages;
relatedResources: ResourceRelations;
pivotResources: ResourcePivotRelationData;
+ draft?: boolean;
}) {
const schema = RESOURCE_SCHEMAS[resource];
const router = useRouter();
const relationContext = useArfRelation();
+ const { user } = useAuthentication();
const form = useForm>({
// Maybe try extracting the id from the defaultValues and passing it as an editedResourceId prop
resolver: zodResolver(schema) as Resolver>,
@@ -112,6 +117,7 @@ export function ArfController({
const response = await fetchMutation>(endpoint, {
body,
resource,
+ draft: draft || !isSolvroAdmin(user),
...mutationOptions,
});
const wasCreated = mutationOptions.method === "POST";
@@ -195,8 +201,18 @@ export function ArfController({
onSubmit={onSubmit}
confirmationMessage={confirmationMessage}
>
- {submitLabel} {declensions.accusative}
+ {draft
+ ? `Zapisz ${declineNoun("draft", { case: GrammaticalCase.Accusative })}`
+ : `${submitLabel} ${declensions.accusative}`}
+
+ {draft && isSolvroAdmin(user) ? (
+
+ ) : null}
{isEditing && metadata.deletable !== false ? (
{
resource: T;
relatedResources: ResourceRelations;
dragHandleProps?: Omit;
+ actions?: ReactNode;
}
/** TODO: pass custom delete functionality as a prop, which would eliminate this helper */
@@ -38,7 +39,8 @@ const isStudentOrganizationProps = (
} => props.resource === Resource.StudentOrganizations;
export function ArlItem(props: ItemProps) {
- const { ref, item, resource, relatedResources, dragHandleProps } = props;
+ const { ref, item, resource, relatedResources, dragHandleProps, actions } =
+ props;
const metadata = getResourceMetadata(resource);
const id = getResourcePkValue(resource, item);
@@ -100,14 +102,18 @@ export function ArlItem(props: ItemProps) {
)}
diff --git a/src/features/abstract-resource-list/components/toggle-status-button.tsx b/src/features/abstract-resource-list/components/toggle-status-button.tsx
index c5e41209..1d8be155 100644
--- a/src/features/abstract-resource-list/components/toggle-status-button.tsx
+++ b/src/features/abstract-resource-list/components/toggle-status-button.tsx
@@ -1,3 +1,5 @@
+"use client";
+
import { useQueryClient } from "@tanstack/react-query";
import { Archive, ArchiveRestore } from "lucide-react";
import { toast } from "sonner";
diff --git a/src/features/abstract-resource-list/index.ts b/src/features/abstract-resource-list/index.ts
index 928cd835..eccf305f 100644
--- a/src/features/abstract-resource-list/index.ts
+++ b/src/features/abstract-resource-list/index.ts
@@ -1,3 +1,6 @@
export * from "./components/abstract-resource-list";
+export * from "./components/arl-item";
+
export * from "./hooks/use-order-mutation";
+
export * from "./utils/calculate-new-sort-value";
diff --git a/src/features/authentication/data/permissions.ts b/src/features/authentication/data/permissions.ts
new file mode 100644
index 00000000..aaa8af9f
--- /dev/null
+++ b/src/features/authentication/data/permissions.ts
@@ -0,0 +1,3 @@
+export const SOLVRO_ADMINS_ONLY = ["solvro_admin"] as const;
+export const ADMINS_ONLY = ["admin", ...SOLVRO_ADMINS_ONLY] as const;
+export const ANY_AUTHENTICATED_ROLE = ["user", ...ADMINS_ONLY] as const;
diff --git a/src/features/authentication/data/route-permissions.ts b/src/features/authentication/data/route-permissions.ts
index 129e0701..fba7fbdb 100644
--- a/src/features/authentication/data/route-permissions.ts
+++ b/src/features/authentication/data/route-permissions.ts
@@ -4,40 +4,39 @@ import { Resource } from "@/features/resources";
import type { RoutableResource } from "@/features/resources/types";
import type { RecordIntersection } from "@/types/helpers";
-const SOLVRO_ADMINS_ONLY = ["solvro_admin"] as const;
-const ADMINS_ONLY = ["admin", ...SOLVRO_ADMINS_ONLY] as const;
-const ANY_AUTHENTICATED_ROLE = ["user", ...ADMINS_ONLY] as const;
+import { ANY_AUTHENTICATED_ROLE, SOLVRO_ADMINS_ONLY } from "./permissions";
export const ROUTE_PERMISSIONS = {
[`/${Resource.Dashboard}`]: ANY_AUTHENTICATED_ROLE,
+ "/drafts": ANY_AUTHENTICATED_ROLE,
"/review": SOLVRO_ADMINS_ONLY,
- [`/${Resource.AboutUs}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.AboutUsLinks}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.AcademicSemesters}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.Aeds}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.Banners}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.BicycleShowers}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.Buildings}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.CalendarEvents}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.Campuses}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.Contributors}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.DaySwaps}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.Departments}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.FoodSpots}`]: ANY_AUTHENTICATED_ROLE,
+ [`/${Resource.AboutUs}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.AboutUsLinks}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.AcademicSemesters}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.Aeds}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.Banners}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.BicycleShowers}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.Buildings}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.CalendarEvents}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.Campuses}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.Contributors}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.DaySwaps}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.Departments}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.FoodSpots}`]: SOLVRO_ADMINS_ONLY,
[`/${Resource.GuideArticles}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.Holidays}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.Libraries}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.Map}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.Milestones}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.MobileConfig}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.Notifications}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.NotificationTopics}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.PinkBoxes}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.PolinkaStations}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.Roles}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.SksOpeningHours}`]: ANY_AUTHENTICATED_ROLE,
+ [`/${Resource.Holidays}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.Libraries}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.Map}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.Milestones}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.MobileConfig}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.Notifications}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.NotificationTopics}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.PinkBoxes}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.PolinkaStations}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.Roles}`]: SOLVRO_ADMINS_ONLY,
+ [`/${Resource.SksOpeningHours}`]: SOLVRO_ADMINS_ONLY,
[`/${Resource.StudentOrganizations}`]: ANY_AUTHENTICATED_ROLE,
- [`/${Resource.Versions}`]: ANY_AUTHENTICATED_ROLE,
+ [`/${Resource.Versions}`]: SOLVRO_ADMINS_ONLY,
} satisfies RecordIntersection<
`/${RoutableResource}`,
Route,
diff --git a/src/features/authentication/index.ts b/src/features/authentication/index.ts
index 50ba3d97..6f3c1ca7 100644
--- a/src/features/authentication/index.ts
+++ b/src/features/authentication/index.ts
@@ -6,4 +6,6 @@ export * from "./constants";
export * from "./hooks/use-authentication";
export * from "./utils/get-user-display-name";
+export * from "./utils/is-admin";
+export * from "./utils/is-solvro-admin";
export * from "./utils/parse-auth-cookie";
diff --git a/src/features/authentication/utils/is-admin.ts b/src/features/authentication/utils/is-admin.ts
new file mode 100644
index 00000000..f11ba372
--- /dev/null
+++ b/src/features/authentication/utils/is-admin.ts
@@ -0,0 +1,8 @@
+import { ADMINS_ONLY } from "../data/permissions";
+import type { User } from "../types/internal";
+import { getUserPermissions } from "./get-user-permissions";
+
+export const isAdmin = (user: User | null): boolean => {
+ const permissions = getUserPermissions(user);
+ return ADMINS_ONLY.some((role) => permissions.includes(role));
+};
diff --git a/src/features/authentication/utils/is-solvro-admin.ts b/src/features/authentication/utils/is-solvro-admin.ts
new file mode 100644
index 00000000..1062f3a5
--- /dev/null
+++ b/src/features/authentication/utils/is-solvro-admin.ts
@@ -0,0 +1,8 @@
+import { SOLVRO_ADMINS_ONLY } from "../data/permissions";
+import type { User } from "../types";
+import { getUserPermissions } from "./get-user-permissions";
+
+export const isSolvroAdmin = (user: User | null): boolean => {
+ const permissions = getUserPermissions(user);
+ return SOLVRO_ADMINS_ONLY.every((role) => permissions.includes(role));
+};
diff --git a/src/features/backend/data/api-error-messages.ts b/src/features/backend/data/api-error-messages.ts
index d926eb87..b253df65 100644
--- a/src/features/backend/data/api-error-messages.ts
+++ b/src/features/backend/data/api-error-messages.ts
@@ -4,4 +4,5 @@ export const API_ERROR_MESSAGES: Record
= {
E_UNEXPECTED_ERROR: "Nastąpił nieoczekiwany błąd",
E_VALIDATION_ERROR: "Wpisane dane są niekompletne lub nieprawidłowe",
E_NOT_FOUND: "Nie znaleziono podanego zasobu",
+ E_UNAUTHORIZED_ACCESS: "Brak uprawnień do wyświetlenia tego zasobu",
};
diff --git a/src/features/backend/lib/create-request.ts b/src/features/backend/lib/create-request.ts
index 6eddd1b2..69023aa6 100644
--- a/src/features/backend/lib/create-request.ts
+++ b/src/features/backend/lib/create-request.ts
@@ -13,6 +13,7 @@ export function createRequest(
{
accessTokenOverride,
resource,
+ draft = false,
body,
includeRelations = false,
...options
@@ -29,7 +30,7 @@ export function createRequest(
};
}
- const endpointPrefix = getResourceEndpointPrefix(resource);
+ const endpointPrefix = getResourceEndpointPrefix(resource, draft);
let queryParameters = getRelationQueryParameters(resource, includeRelations);
if (queryParameters !== "") {
queryParameters = `${endpoint.includes("?") ? "&" : "?"}${queryParameters}`;
diff --git a/src/features/backend/types/internal.ts b/src/features/backend/types/internal.ts
index a7ac9c03..3845c046 100644
--- a/src/features/backend/types/internal.ts
+++ b/src/features/backend/types/internal.ts
@@ -12,6 +12,7 @@ interface BaseRequestOptions extends Omit<
headers?: Record;
accessTokenOverride?: string;
resource?: T;
+ draft?: boolean;
}
export interface QueryRequestOptions<
T extends Resource,
diff --git a/src/features/backend/utils/get-resource-endpoint-prefix.ts b/src/features/backend/utils/get-resource-endpoint-prefix.ts
index 03d49eb0..2ab5bc21 100644
--- a/src/features/backend/utils/get-resource-endpoint-prefix.ts
+++ b/src/features/backend/utils/get-resource-endpoint-prefix.ts
@@ -1,5 +1,14 @@
import type { Resource } from "@/features/resources";
import { getResourceMetadata } from "@/features/resources/node";
-export const getResourceEndpointPrefix = (resource: Resource | undefined) =>
- resource == null ? "" : `${getResourceMetadata(resource).apiPath}/`;
+export const getResourceEndpointPrefix = (
+ resource: Resource | undefined,
+ draft: boolean,
+) => {
+ if (resource == null) {
+ return "";
+ }
+ const metadata = getResourceMetadata(resource);
+ const draftPath = draft ? metadata.apiDraftPath : null;
+ return `${draftPath ?? metadata.apiPath}/`;
+};
diff --git a/src/features/polish/data/simple-noun-declensions.ts b/src/features/polish/data/simple-noun-declensions.ts
index d5c05be1..321b6cb9 100644
--- a/src/features/polish/data/simple-noun-declensions.ts
+++ b/src/features/polish/data/simple-noun-declensions.ts
@@ -701,6 +701,27 @@ export const SIMPLE_NOUN_DECLENSIONS = {
},
},
[Resource.VersionScreenshots]: REUSABLE_DECLENSIONS.screenshot,
+ draft: {
+ gender: GrammaticalGender.Masculine,
+ singular: {
+ nominative: "draft",
+ genitive: "draftu",
+ dative: "draftowi",
+ accusative: "draft",
+ instrumental: "draftem",
+ locative: "drafcie",
+ vocative: "drafcie",
+ },
+ plural: {
+ nominative: "drafty",
+ genitive: "draftów",
+ dative: "draftom",
+ accusative: "drafty",
+ instrumental: "draftami",
+ locative: "draftach",
+ vocative: "drafty",
+ },
+ },
image: {
gender: GrammaticalGender.Neuter,
singular: {
diff --git a/src/features/resources/components/abstract-resource-edit-page-internal.tsx b/src/features/resources/components/abstract-resource-edit-page-internal.tsx
index c62be9e6..3be85398 100644
--- a/src/features/resources/components/abstract-resource-edit-page-internal.tsx
+++ b/src/features/resources/components/abstract-resource-edit-page-internal.tsx
@@ -3,6 +3,7 @@ import type { ReactNode } from "react";
import { ErrorMessage } from "@/components/presentation/error-message";
import { ApplicationError } from "@/config/enums";
import { AbstractResourceForm } from "@/features/abstract-resource-form";
+import { getAuthStateServer } from "@/features/authentication/server";
import { fetchQuery } from "@/features/backend";
import type { GetResourceWithRelationsResponse } from "@/features/backend/types";
@@ -11,17 +12,25 @@ import type { RoutableResource } from "../types";
export async function AbstractResourceEditPageInternal({
resource,
path,
+ draft = false,
errorMessage,
}: {
resource: RoutableResource;
path: string;
+ draft?: boolean;
errorMessage: ReactNode;
}) {
+ const authState = await getAuthStateServer();
let resourceData;
try {
const response = await fetchQuery<
GetResourceWithRelationsResponse
- >(path, { resource, includeRelations: true });
+ >(path, {
+ resource,
+ includeRelations: true,
+ draft,
+ accessTokenOverride: authState?.accessToken,
+ });
resourceData = response.data;
} catch {
return (
@@ -34,6 +43,10 @@ export async function AbstractResourceEditPageInternal({
}
return (
-
+
);
}
diff --git a/src/features/resources/components/abstract-resource-edit-page.tsx b/src/features/resources/components/abstract-resource-edit-page.tsx
index ff1ca755..9f1a97a1 100644
--- a/src/features/resources/components/abstract-resource-edit-page.tsx
+++ b/src/features/resources/components/abstract-resource-edit-page.tsx
@@ -13,8 +13,10 @@ import { AbstractResourceEditPageInternal } from "./abstract-resource-edit-page-
export async function AbstractResourceEditPage({
resource,
params,
+ draft = false,
}: ResourceEditPageProps & {
resource: RoutableResource;
+ draft?: boolean;
}) {
const metadata = getResourceMetadata(resource);
const declensions = declineNoun(resource);
@@ -24,6 +26,7 @@ export async function AbstractResourceEditPage({
Nie udało się wczytać {declensions.genitive}.>}
/>
);
@@ -48,6 +51,7 @@ export async function AbstractResourceEditPage({
Nie istnieje{" "}
diff --git a/src/features/resources/data/resource-metadata.ts b/src/features/resources/data/resource-metadata.ts
index 8917adc4..26325d4b 100644
--- a/src/features/resources/data/resource-metadata.ts
+++ b/src/features/resources/data/resource-metadata.ts
@@ -569,6 +569,7 @@ export const RESOURCE_METADATA = {
},
[Resource.GuideArticles]: {
apiPath: "guide_articles",
+ apiDraftPath: "guide_article_drafts",
orderable: true,
itemMapper: (item) => ({
name: item.title,
@@ -885,6 +886,7 @@ export const RESOURCE_METADATA = {
},
[Resource.StudentOrganizations]: {
apiPath: "student_organizations",
+ apiDraftPath: "student_organization_drafts",
itemMapper: (item) => ({
name: item.name,
description: item.shortDescription,
diff --git a/src/features/resources/index.ts b/src/features/resources/index.ts
index 901c2d25..cb5c966a 100644
--- a/src/features/resources/index.ts
+++ b/src/features/resources/index.ts
@@ -1,4 +1,3 @@
-export * from "./components/abstract-resource-edit-page";
export * from "./components/abstract-resource-group";
export * from "./components/delete-button-with-dialog";
export * from "./components/create-button";
diff --git a/src/features/resources/server.ts b/src/features/resources/server.ts
index 98147b56..4504df95 100644
--- a/src/features/resources/server.ts
+++ b/src/features/resources/server.ts
@@ -1 +1,2 @@
+export * from "./components/abstract-resource-edit-page";
export * from "./components/abstract-resource-layout";
diff --git a/src/features/resources/types/index.ts b/src/features/resources/types/index.ts
index 4ae57a39..e332aa7e 100644
--- a/src/features/resources/types/index.ts
+++ b/src/features/resources/types/index.ts
@@ -12,6 +12,7 @@ export type {
ResourceSchemaKey,
RoutableResource,
SubmitFormConfirmationMessage,
+ SpecificResourceMetadata,
} from "./internal";
export type {
diff --git a/src/features/resources/types/internal.ts b/src/features/resources/types/internal.ts
index 51156159..24566f4a 100644
--- a/src/features/resources/types/internal.ts
+++ b/src/features/resources/types/internal.ts
@@ -96,6 +96,8 @@ export type ResourceMetadata = Readonly<{
pk?: ResourceSchemaKey;
/** A mapping of the client-side resources to their paths in the backend API. */
apiPath: string;
+ /** If the API supports draft models for this resource, the path to be used when creating drafts. */
+ apiDraftPath?: string;
/** The API version to be used when fetching this resource. Defaults to 1. */
apiVersion?: number;
/**
diff --git a/src/features/review/api/fetch-drafts.ts b/src/features/review/api/fetch-drafts.ts
new file mode 100644
index 00000000..3e635856
--- /dev/null
+++ b/src/features/review/api/fetch-drafts.ts
@@ -0,0 +1,91 @@
+import "server-only";
+
+import { isAdmin, isSolvroAdmin } from "@/features/authentication";
+import { getAuthStateServer } from "@/features/authentication/server";
+import type { User } from "@/features/authentication/types";
+import { fetchQuery } from "@/features/backend";
+import { getResourceMetadata } from "@/features/resources";
+import type { ResourceDataType } from "@/features/resources/types";
+import { typedEntries } from "@/utils";
+
+import { DRAFT_TYPE_RESOURCES } from "../data/draft-type-resources";
+import type { DraftableResource, ResourceDraft } from "../types/internal";
+
+async function fetchDraftsForAdmins(accessToken: string) {
+ try {
+ const { data } = await fetchQuery<{ data: ResourceDraft[] }>("drafts", {
+ accessTokenOverride: accessToken,
+ });
+ return data;
+ } catch {
+ return [];
+ }
+}
+
+/** Fetches drafts of the given user based on instance-level read permissions. */
+async function fetchUserOwnedDrafts(user: User, accessToken: string) {
+ const draftResourceEntries = typedEntries(DRAFT_TYPE_RESOURCES);
+
+ const draftPathToInfo = draftResourceEntries
+ .map(([draftType, resource]) => ({
+ draftType,
+ resource,
+ path: getResourceMetadata(resource).apiDraftPath,
+ }))
+ .filter((info) => info.path != null);
+
+ const relevantPermissions = user.permissions.filter(
+ (permission) =>
+ permission.action === "read" &&
+ permission.instanceId != null &&
+ draftPathToInfo.some((info) => info.path === permission.modelName),
+ );
+ if (relevantPermissions.length === 0) {
+ return [];
+ }
+
+ const draftPromises = relevantPermissions.map(async (permission) => {
+ const info = draftPathToInfo.find(
+ (index) => index.path === permission.modelName,
+ );
+ if (info == null) {
+ return null;
+ }
+ const { resource, draftType } = info;
+
+ try {
+ const response = await fetchQuery<{
+ data: ResourceDataType;
+ }>(String(permission.instanceId), {
+ accessTokenOverride: accessToken,
+ resource,
+ draft: true,
+ });
+
+ return {
+ resourceType: draftType,
+ data: response.data,
+ userId: user.id,
+ } as ResourceDraft;
+ } catch {
+ return null;
+ }
+ });
+
+ const results = await Promise.all(draftPromises);
+ return results.filter((draft) => draft !== null);
+}
+
+export const fetchDrafts = async () => {
+ const authState = await getAuthStateServer();
+ if (authState == null) {
+ return [];
+ }
+
+ const { user, accessToken } = authState;
+
+ if (isSolvroAdmin(user) || isAdmin(user)) {
+ return await fetchDraftsForAdmins(accessToken);
+ }
+ return await fetchUserOwnedDrafts(user, accessToken);
+};
diff --git a/src/features/review/components/approve-button.tsx b/src/features/review/components/approve-button.tsx
new file mode 100644
index 00000000..5a733497
--- /dev/null
+++ b/src/features/review/components/approve-button.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { Check } from "lucide-react";
+import type { Route } from "next";
+import { toast } from "sonner";
+
+import { Button } from "@/components/ui/button";
+import { fetchMutation, useMutationWrapper } from "@/features/backend";
+import { declineNoun } from "@/features/polish";
+import type { Resource } from "@/features/resources";
+import type { ResourcePk } from "@/features/resources/types";
+import { useRouter } from "@/hooks/use-router";
+import { sanitizeId } from "@/utils";
+
+export function ApproveButton({
+ id,
+ resource,
+}: {
+ id: ResourcePk;
+ resource: Resource;
+ showLabel?: boolean;
+}) {
+ const router = useRouter();
+
+ const { mutateAsync, isPending } = useMutationWrapper(
+ `approve-draft__${resource}__${String(id)}`,
+ async () => {
+ const result = await fetchMutation(`${sanitizeId(id)}/approve`, {
+ method: "POST",
+ resource,
+ draft: true,
+ });
+ router.push(`/${resource}` as Route);
+ router.refresh();
+ return result;
+ },
+ );
+
+ const declensions = declineNoun(resource);
+
+ //TODO: dialog?
+ return (
+
+ );
+}
diff --git a/src/features/review/components/draft-edit-button.tsx b/src/features/review/components/draft-edit-button.tsx
new file mode 100644
index 00000000..d362a76d
--- /dev/null
+++ b/src/features/review/components/draft-edit-button.tsx
@@ -0,0 +1,31 @@
+import { SquarePen } from "lucide-react";
+import type { Route } from "next";
+
+import { Link } from "@/components/core/link";
+import { Button } from "@/components/ui/button";
+import { getResourceMetadata } from "@/features/resources";
+import type { EditableResource, ResourcePk } from "@/features/resources/types";
+import { sanitizeId } from "@/utils";
+
+export function DraftEditButton({
+ resource,
+ id,
+}: {
+ resource: EditableResource;
+ id: ResourcePk;
+}) {
+ const metadata = getResourceMetadata(resource);
+ const draftPath = metadata.apiDraftPath?.replaceAll("_", "-"); //TODO
+
+ if (draftPath == null) {
+ return null;
+ }
+
+ return (
+
+ );
+}
diff --git a/src/features/review/components/draft-item.tsx b/src/features/review/components/draft-item.tsx
new file mode 100644
index 00000000..338f8ed2
--- /dev/null
+++ b/src/features/review/components/draft-item.tsx
@@ -0,0 +1,45 @@
+import { ArlItem } from "@/features/abstract-resource-list";
+import { isAdmin, isSolvroAdmin } from "@/features/authentication";
+import type { AuthState } from "@/features/authentication/types";
+
+import type {
+ DraftableResource,
+ DraftableResourceRelationMap,
+ ResourceDraft,
+} from "../types/internal";
+import { getDraftResource } from "../utils/get-draft-resource";
+import { DraftEditButton } from "./draft-edit-button";
+
+export function DraftItem({
+ draft,
+ relatedResourcesMap,
+ authState,
+}: {
+ draft: ResourceDraft;
+ relatedResourcesMap: DraftableResourceRelationMap;
+ authState: AuthState;
+}) {
+ const resource = getDraftResource(draft);
+ const relatedResources = relatedResourcesMap[resource];
+ if (relatedResources == null) {
+ throw new Error(`Relations not found for draft resource ${resource}`);
+ }
+
+ const canEdit =
+ isSolvroAdmin(authState.user) ||
+ isAdmin(authState.user) ||
+ draft.userId === authState.user.id;
+
+ return (
+
+ ) : null
+ }
+ />
+ );
+}
diff --git a/src/features/review/components/draft-list.tsx b/src/features/review/components/draft-list.tsx
new file mode 100644
index 00000000..5aed44c2
--- /dev/null
+++ b/src/features/review/components/draft-list.tsx
@@ -0,0 +1,48 @@
+import { fetchRelatedResources } from "@/features/abstract-resource-form";
+import { getAuthStateServer } from "@/features/authentication/server";
+import { typedFromEntries } from "@/utils";
+
+import { fetchDrafts } from "../api/fetch-drafts";
+import type { DraftableResourceRelationMap } from "../types/internal";
+import { getDraftResource } from "../utils/get-draft-resource";
+import { DraftItem } from "./draft-item";
+
+export async function DraftList() {
+ const [drafts, authState] = await Promise.all([
+ fetchDrafts(),
+ getAuthStateServer(),
+ ]);
+
+ if (authState == null) {
+ return null;
+ }
+
+ const resources = new Set(drafts.map((draft) => getDraftResource(draft)));
+ const relatedResourcesMap: DraftableResourceRelationMap = typedFromEntries(
+ await Promise.all(
+ [...resources].map(async (resource) => [
+ resource,
+ await fetchRelatedResources(resource),
+ ]),
+ ),
+ );
+
+ if (drafts.length === 0) {
+ return (
+ Brak draftów
+ );
+ }
+
+ return (
+
+ {drafts.map((draft) => (
+
+ ))}
+
+ );
+}
diff --git a/src/features/review/data/draft-type-resources.ts b/src/features/review/data/draft-type-resources.ts
new file mode 100644
index 00000000..7e14f67a
--- /dev/null
+++ b/src/features/review/data/draft-type-resources.ts
@@ -0,0 +1,8 @@
+import { Resource } from "@/features/resources";
+
+import type { DraftableResource } from "../types/internal";
+
+export const DRAFT_TYPE_RESOURCES = {
+ article_draft: Resource.GuideArticles,
+ organization_draft: Resource.StudentOrganizations,
+} as const satisfies Record;
diff --git a/src/features/review/index.ts b/src/features/review/index.ts
new file mode 100644
index 00000000..c729bec3
--- /dev/null
+++ b/src/features/review/index.ts
@@ -0,0 +1 @@
+export * from "./components/approve-button";
diff --git a/src/features/review/server.ts b/src/features/review/server.ts
new file mode 100644
index 00000000..3a41863c
--- /dev/null
+++ b/src/features/review/server.ts
@@ -0,0 +1 @@
+export * from "./components/draft-list";
diff --git a/src/features/review/types/internal.ts b/src/features/review/types/internal.ts
new file mode 100644
index 00000000..124cfc44
--- /dev/null
+++ b/src/features/review/types/internal.ts
@@ -0,0 +1,28 @@
+import type { Resource } from "@/features/resources";
+import type {
+ ResourceDataType,
+ SpecificResourceMetadata,
+} from "@/features/resources/types";
+import type { ResourceRelations } from "@/types/components";
+import type { Invert } from "@/types/helpers";
+
+import type { DRAFT_TYPE_RESOURCES } from "../data/draft-type-resources";
+
+export type DraftableResource = {
+ [R in Resource]: SpecificResourceMetadata extends { apiDraftPath: string }
+ ? R
+ : never;
+}[Resource];
+
+type ResourceDraftTypes = Invert;
+export interface ResourceDraft<
+ T extends DraftableResource = DraftableResource,
+> {
+ resourceType: ResourceDraftTypes[T];
+ data: ResourceDataType;
+ userId: number;
+}
+
+export type DraftableResourceRelationMap = Partial<{
+ [R in DraftableResource]: ResourceRelations;
+}>;
diff --git a/src/features/review/utils/get-draft-resource.ts b/src/features/review/utils/get-draft-resource.ts
new file mode 100644
index 00000000..e15f9b89
--- /dev/null
+++ b/src/features/review/utils/get-draft-resource.ts
@@ -0,0 +1,10 @@
+import { DRAFT_TYPE_RESOURCES } from "../data/draft-type-resources";
+import type { DraftableResource, ResourceDraft } from "../types/internal";
+
+export const getDraftResource = (
+ draft: ResourceDraft,
+): R => {
+ const resource = DRAFT_TYPE_RESOURCES[draft.resourceType];
+ // TODO: double cast should not be necessary
+ return resource as DraftableResource as R;
+};
diff --git a/src/schemas/positive-integer-schema.ts b/src/schemas/positive-integer-schema.ts
index 40910b7a..9fb591ee 100644
--- a/src/schemas/positive-integer-schema.ts
+++ b/src/schemas/positive-integer-schema.ts
@@ -2,7 +2,7 @@ import { z } from "zod";
import { FORM_ERROR_MESSAGES } from "@/data/form-error-messages";
-export const PositiveIntegerSchema = z
+export const PositiveIntegerSchema = z.coerce
.number()
.int()
.positive({ message: FORM_ERROR_MESSAGES.REQUIRED });
diff --git a/src/types/components.ts b/src/types/components.ts
index 854b501f..b5c10303 100644
--- a/src/types/components.ts
+++ b/src/types/components.ts
@@ -19,6 +19,7 @@ export type WrapperProps = Readonly<{
export interface ResourceFormProps {
resource: T;
className?: string;
+ draft?: boolean;
}
export type SearchParameters = Record;
diff --git a/src/types/helpers.ts b/src/types/helpers.ts
index 74a48a40..4b3c5641 100644
--- a/src/types/helpers.ts
+++ b/src/types/helpers.ts
@@ -17,3 +17,7 @@ export type OptionalPromise = T | Promise;
export type NonNullableValues = {
[K in keyof T]: NonNullable;
};
+
+export type Invert> = {
+ [K in keyof T as T[K]]: K;
+};