Skip to content

Commit

Permalink
Merge pull request #126 from arnaugomez:feature/CM-151
Browse files Browse the repository at this point in the history
Feature/CM-151
  • Loading branch information
arnaugomez authored Sep 28, 2024
2 parents a484150 + b9ac45e commit 1da2e4d
Show file tree
Hide file tree
Showing 28 changed files with 414 additions and 18 deletions.
File renamed without changes.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { Navbar } from "@/src/common/ui/navbar/components/navbar";
* Layout for pages that have a fixed size that is equal to the width and height
* of the browser screen. The main body does not have scroll in these pages.
*/
export default function AdminLayout({
export default function FullscreenLayout({
children,
}: Readonly<{
children: React.ReactNode;
Expand Down
23 changes: 23 additions & 0 deletions app/(navbar-layout)/(login-guard)/admin/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { fetchSession } from "@/src/auth/ui/fetch/fetch-session";
import { redirect } from "next/navigation";
import type { PropsWithChildren } from "react";

/**
* Checks that the user is an admin. Otherwise, it redirects to the home page.
*/
async function adminGuard() {
const result = await fetchSession();
if (!result.user?.isAdmin) {
redirect("/home");
}
}

/**
* Applies the `loginGuard` guard to the pages inside this layout
*/
export default async function AdminGuardLayout({
children,
}: PropsWithChildren) {
await adminGuard();
return <>{children}</>;
}
3 changes: 3 additions & 0 deletions app/(navbar-layout)/(login-guard)/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { AdminPage } from "@/src/admin/ui/pages/admin-page";

export default AdminPage;
5 changes: 2 additions & 3 deletions app/(navbar-layout)/(login-guard)/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { fetchSession } from "@/src/auth/ui/fetch/fetch-session";
import { redirect } from "next/navigation";
import type { PropsWithChildren } from "react";

/**
* Checks that the user is logged in and has verified the email. Otherwise, it
Expand All @@ -20,9 +21,7 @@ async function loginGuard() {
*/
export default async function LoginGuardLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
}: PropsWithChildren) {
await loginGuard();
return <>{children}</>;
}
2 changes: 1 addition & 1 deletion app/(navbar-layout)/error.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";
import type { ErrorPageProps } from "@/src/common/ui/models/props-with-error";
import ErrorPage from "../(admin-layout)/error";
import ErrorPage from "../(fullscreen-layout)/error";

/**
* Displays a page with an error message. Is shown when an error occurs in the pages
Expand Down
2 changes: 1 addition & 1 deletion app/auth/error.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"use client";
import type { ErrorPageProps } from "@/src/common/ui/models/props-with-error";
import ErrorPage from "../(admin-layout)/error";
import ErrorPage from "../(fullscreen-layout)/error";

/**
* Displays a page with an error message. It is shown when an error occurs in
Expand Down
2 changes: 1 addition & 1 deletion app/global-error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import type { ErrorPageProps } from "@/src/common/ui/models/props-with-error";
import { inter } from "@/src/common/ui/styles/fonts";
import { cn } from "@/src/common/ui/utils/shadcn";
import ErrorPage from "./(admin-layout)/error";
import ErrorPage from "./(fullscreen-layout)/error";

/**
* Error page that is shown when an error occurs in the `RootLayout` component.
Expand Down
6 changes: 3 additions & 3 deletions app/not-found.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ import { textStyles } from "@/src/common/ui/styles/text-styles";
import { cn } from "@/src/common/ui/utils/shadcn";
import { SearchX } from "lucide-react";
import Link from "next/link";
import AdminLayout from "./(admin-layout)/layout";
import FullscreenLayout from "./(fullscreen-layout)/layout";

/**
* This page is shown when a user tries to access a route that does not exist.
*/
export default function NotFoundPage() {
return (
<AdminLayout>
<FullscreenLayout>
<main className="absolute inset-0 flex flex-col items-center justify-center px-4">
<div className="h-2"></div>
<SearchX className="size-8 flex-none text-slate-500" />
Expand All @@ -24,6 +24,6 @@ export default function NotFoundPage() {
</Button>
<div className="h-2"></div>
</main>
</AdminLayout>
</FullscreenLayout>
);
}
Empty file.
178 changes: 178 additions & 0 deletions src/admin/domain/config/admin-resources-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
import type { OptionModel } from "@/src/common/domain/models/option-model";
import { PracticeCardRatingModel } from "@/src/practice/domain/models/practice-card-rating-model";
import { PracticeCardStateModel } from "@/src/practice/domain/models/practice-card-state-model";
import {
AdminFieldTypeModel,
AdminResourceTypeModel,
type AdminResourceModel,
} from "../models/admin-resource-model";

const practiceCardStateOptions: OptionModel[] = [
{ value: PracticeCardStateModel.learning, label: "Aprendiendo" },
{ value: PracticeCardStateModel.new, label: "Nueva" },
{ value: PracticeCardStateModel.relearning, label: "Reaprendiendo" },
{ value: PracticeCardStateModel.review, label: "Repasar" },
];

const practiceCardRatingOptions: OptionModel[] = [
{ value: PracticeCardRatingModel.again, label: "Repetir" },
{ value: PracticeCardRatingModel.easy, label: "Fácil" },
{ value: PracticeCardRatingModel.good, label: "Bien" },
{ value: PracticeCardRatingModel.hard, label: "Difícil" },
{ value: PracticeCardRatingModel.manual, label: "Manual" },
];

export const adminResourcesConfig: AdminResourceModel[] = [
{
type: AdminResourceTypeModel.tags,
fields: [{ name: "name", type: AdminFieldTypeModel.string }],
},
{
type: AdminResourceTypeModel.reviewLogs,
fields: [
{ name: "cardId", type: AdminFieldTypeModel.objectId },
{ name: "courseEnrollmentId", type: AdminFieldTypeModel.objectId },
{
name: "rating",
type: AdminFieldTypeModel.select,
options: practiceCardRatingOptions,
},
{
name: "state",
type: AdminFieldTypeModel.select,
options: practiceCardStateOptions,
},
{ name: "due", type: AdminFieldTypeModel.date },
{ name: "stability", type: AdminFieldTypeModel.number },
{ name: "difficulty", type: AdminFieldTypeModel.number },
{ name: "elapsedDays", type: AdminFieldTypeModel.number },
{ name: "lastElapsedDays", type: AdminFieldTypeModel.number },
{ name: "scheduledDays", type: AdminFieldTypeModel.number },
{ name: "review", type: AdminFieldTypeModel.date },
],
},
{
type: AdminResourceTypeModel.rateLimits,
fields: [
{ name: "name", type: AdminFieldTypeModel.string },
{ name: "count", type: AdminFieldTypeModel.number },
{ name: "updatedAt", type: AdminFieldTypeModel.date },
],
},
{
type: AdminResourceTypeModel.profiles,
fields: [
{ name: "userId", type: AdminFieldTypeModel.objectId },
{ name: "displayName", type: AdminFieldTypeModel.string },
{ name: "handle", type: AdminFieldTypeModel.string },
{ name: "bio", type: AdminFieldTypeModel.string },
{ name: "picture", type: AdminFieldTypeModel.string },
{ name: "backgroundPicture", type: AdminFieldTypeModel.string },
{ name: "website", type: AdminFieldTypeModel.string },
{ name: "isPublic", type: AdminFieldTypeModel.boolean },
{ name: "tags", type: AdminFieldTypeModel.tags },
],
},
{
type: AdminResourceTypeModel.practiceCards,
fields: [
{ name: "courseEnrollmentId", type: AdminFieldTypeModel.objectId },
{ name: "noteId", type: AdminFieldTypeModel.objectId },
{ name: "due", type: AdminFieldTypeModel.date },
{ name: "stability", type: AdminFieldTypeModel.number },
{ name: "difficulty", type: AdminFieldTypeModel.number },
{ name: "elapsedDays", type: AdminFieldTypeModel.number },
{ name: "scheduledDays", type: AdminFieldTypeModel.number },
{ name: "reps", type: AdminFieldTypeModel.number },
{ name: "lapses", type: AdminFieldTypeModel.number },
{
name: "state",
type: AdminFieldTypeModel.select,
options: practiceCardStateOptions,
},
{ name: "lastReview", type: AdminFieldTypeModel.date },
],
},

{
type: AdminResourceTypeModel.notes,
fields: [
{ name: "courseId", type: AdminFieldTypeModel.objectId },
{ name: "front", type: AdminFieldTypeModel.string },
{ name: "back", type: AdminFieldTypeModel.string },
{ name: "createdAt", type: AdminFieldTypeModel.date },
],
},
{
type: AdminResourceTypeModel.forgotPasswordTokens,
fields: [
{ name: "userId", type: AdminFieldTypeModel.objectId },
{ name: "tokenHash", isReadonly: true, type: AdminFieldTypeModel.string },
{ name: "expiresAt", type: AdminFieldTypeModel.date },
],
},
{
type: AdminResourceTypeModel.emailVerificationCodes,
fields: [
{ name: "userId", type: AdminFieldTypeModel.objectId },
{ name: "code", type: AdminFieldTypeModel.string },
{ name: "expiresAt", type: AdminFieldTypeModel.date },
],
},
{
type: AdminResourceTypeModel.courseEnrollments,
fields: [
{ name: "courseId", type: AdminFieldTypeModel.objectId },
{ name: "profileId", type: AdminFieldTypeModel.objectId },
{ name: "isFavorite", type: AdminFieldTypeModel.boolean },
{
name: "config",
type: AdminFieldTypeModel.form,
fields: [
{ name: "enableFuzz", type: AdminFieldTypeModel.boolean },
{ name: "maximumInterval", type: AdminFieldTypeModel.number },
{ name: "requestRetention", type: AdminFieldTypeModel.number },
{ name: "dailyNewCardsCount", type: AdminFieldTypeModel.number },
{
name: "showAdvancedRatingOptions",
type: AdminFieldTypeModel.boolean,
},
],
},
],
},
{
type: AdminResourceTypeModel.coursePermissions,
fields: [
{ name: "courseId", type: AdminFieldTypeModel.objectId },
{ name: "profileId", type: AdminFieldTypeModel.objectId },
{ name: "permissionType", type: AdminFieldTypeModel.string },
],
},
{
type: AdminResourceTypeModel.courses,
fields: [
{ name: "name", type: AdminFieldTypeModel.string },
{ name: "description", type: AdminFieldTypeModel.string },
{ name: "picture", type: AdminFieldTypeModel.string },
{ name: "isPublic", type: AdminFieldTypeModel.boolean },
{ name: "tags", type: AdminFieldTypeModel.tags },
],
},
{
type: AdminResourceTypeModel.sessions,
fields: [
{ name: "expires_at", type: AdminFieldTypeModel.date },
{ name: "user_id", type: AdminFieldTypeModel.objectId },
],
},
{
type: AdminResourceTypeModel.users,
fields: [
{ name: "email", type: AdminFieldTypeModel.string },
{ name: "acceptTerms", type: AdminFieldTypeModel.boolean },
{ name: "isEmailVerified", type: AdminFieldTypeModel.boolean },
{ name: "isAdmin", type: AdminFieldTypeModel.boolean },
],
},
];
50 changes: 50 additions & 0 deletions src/admin/domain/models/admin-resource-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import type { OptionModel } from "@/src/common/domain/models/option-model";

export enum AdminResourceTypeModel {
courseEnrollments = "courseEnrollments",
coursePermissions = "coursePermissions",
courses = "courses",
emailVerificationCodes = "emailVerificationCodes",
fileUploads = "fileUploads",
forgotPasswordTokens = "forgotPasswordTokens",
notes = "notes",
practiceCards = "practiceCards",
profiles = "profiles",
rateLimits = "rateLimits",
reviewLogs = "reviewLogs",
sessions = "sessions",
tags = "tags",
users = "users",
}

export interface AdminResourceModel {
type: AdminResourceTypeModel;
fields: AdminFieldModel[];
}

interface AdminFieldModel {
name: string;
isReadonly?: boolean;
type: AdminFieldTypeModel;
/**
* Sub-fields of the field. Used when the field type is `AdminFieldTypeModel.form`.
*/
fields?: AdminFieldModel[];

/**
* Options for the field. Used when the field type is `AdminFieldTypeModel.select` or similar.
*/
options?: OptionModel[];
}

export enum AdminFieldTypeModel {
boolean = "boolean",
date = "date",
form = "form",
number = "number",
objectId = "objectId",
string = "string",
tags = "tags",
richText = "richText",
select = "select",
}
20 changes: 20 additions & 0 deletions src/admin/ui/components/admin-greeting.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { textStyles } from "@/src/common/ui/styles/text-styles";
import { SlidersHorizontalIcon } from "lucide-react";

export function AdminGreeting() {
return (
<div className="px-4">
<div className="mx-auto max-w-screen-lg">
<h1 className={textStyles.h2}>
<SlidersHorizontalIcon className="mr-3 inline size-8 -translate-y-1" />
Panel de administración
</h1>
<div className="h-2" />
<p className={textStyles.muted}>
Bienvenido al panel de administración. Desde aquí puedes gestionar los
cursos, usuarios y configuración de la plataforma.
</p>
</div>
</div>
);
}
30 changes: 30 additions & 0 deletions src/admin/ui/components/admin-resource-card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { textStyles } from "@/src/common/ui/styles/text-styles";
import Link from "next/link";
import type { AdminResourceModel } from "../../domain/models/admin-resource-model";
import { translateAdminKey } from "../i18n/admin-translations";
import { AdminResourceIcon } from "./admin-resource-icon";

interface AdminResourceCardProps {
resource: AdminResourceModel;
}
export function AdminResourceCard({ resource }: AdminResourceCardProps) {
return (
<Link
href={`/admin/resources/${resource.type}`}
className="block rounded-lg border border-gray-200 p-4 shadow-sm hover:bg-gray-50"
>
<AdminResourceIcon
adminResourceType={resource.type}
className="text-gray-500"
/>
<div className="h-2" />
<h4 className={textStyles.h4}>
{translateAdminKey(resource.type, "label")}
</h4>
<div className="h-1" />
<p className={textStyles.muted}>
{translateAdminKey(resource.type, "description")}
</p>
</Link>
);
}
Loading

0 comments on commit 1da2e4d

Please sign in to comment.