diff --git a/.env.example b/.env.example index fe3fdc2b..2f94ac40 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,8 @@ NEXT_PUBLIC_APP_URL=http://localhost:3000 # Test background jobs and other Inngest features locally by running 'npx inngest-cli@latest dev' INNGEST_EVENT_KEY="local" + +# ZITADEL OIDC +ZITADEL_ISSUER= +ZITADEL_CLIENT_ID= +ZITADEL_CLIENT_SECRET= \ No newline at end of file diff --git a/apps/next/public/assets/zitadel.png b/apps/next/public/assets/zitadel.png new file mode 100644 index 00000000..b8ede743 Binary files /dev/null and b/apps/next/public/assets/zitadel.png differ diff --git a/apps/next/src/components/ImportFromFileModal.tsx b/apps/next/src/components/ImportFromFileModal.tsx new file mode 100644 index 00000000..97c649b6 --- /dev/null +++ b/apps/next/src/components/ImportFromFileModal.tsx @@ -0,0 +1,116 @@ +// src/components/ImportFromFileModal.tsx +import { useRef, useState } from "react"; +import { + Button, + Input, + FormControl, + FormErrorMessage, + Spinner, + useToast, + Stack, +} from "@chakra-ui/react"; +import { api } from "@quenti/trpc"; +import { Modal } from "@quenti/components/modal"; +import styles from "./glow-wrapper.module.css"; + +interface ImportFromFileModalProps { + isOpen: boolean; + onClose: () => void; +} + +export function ImportFromFileModal({ isOpen, onClose }: ImportFromFileModalProps) { + const inputRef = useRef(null); + const toast = useToast(); + const [file, setFile] = useState(null); + const [error, setError] = useState(); + const [isLoading, setIsLoading] = useState(false); + + const fromFile = api.import?.fromFile?.useMutation({ + onSuccess(data: { title: string; count: number; createdSetId: string }) { + toast({ status: "success", description: `Importado “${data.title}” com ${data.count} cards.` }); + onClose(); + window.location.href = `/${data.createdSetId}`; + }, + onError(err: { message: string }) { + setError(err.message); + setIsLoading(false); + }, + }); + + function handleSelectClick() { + setError(undefined); + inputRef.current?.click(); + } + + function handleFileChange(e: React.ChangeEvent) { + const f = e.target.files?.[0]; + if (!f) return; + if (f.size > 5 * 1024 * 1024) { + setError("Arquivo muito grande (máx. 5 MB)."); + return; + } + setFile(f); + setError(undefined); + } + + async function handleImport() { + if (!file) { + setError("Selecione um arquivo primeiro."); + return; + } + setIsLoading(true); + const content = file.name.endsWith(".apkg") + ? await file.arrayBuffer().then(buf => Buffer.from(buf).toString("base64")) + : await file.text(); + fromFile.mutate({ fileName: file.name, fileContent: content }); + } + + return ( + + + + + + Import from file + + + + {error && {error}} + + + + + + + + + + + ); +} + +export default ImportFromFileModal; \ No newline at end of file diff --git a/apps/next/src/components/auth-layout.tsx b/apps/next/src/components/auth-layout.tsx index d19bf8f5..0d95d9b2 100644 --- a/apps/next/src/components/auth-layout.tsx +++ b/apps/next/src/components/auth-layout.tsx @@ -2,12 +2,14 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { signIn, useSession } from "next-auth/react"; import { useRouter } from "next/router"; import React from "react"; +import Image from "next/image"; import { Controller, type SubmitHandler, useForm } from "react-hook-form"; import { z } from "zod"; import { Link } from "@quenti/components"; import { HeadSeo } from "@quenti/components/head-seo"; import { WEBSITE_URL } from "@quenti/lib/constants/url"; +import zitadel from "../../public/assets/zitadel.png"; import { Box, @@ -201,6 +203,34 @@ export const AuthLayout: React.FC = ({ > Sign {verb} with Google + = ({ ); -}; +}; \ No newline at end of file diff --git a/apps/next/src/components/navbar.tsx b/apps/next/src/components/navbar.tsx index 2272001a..9c6cc9f8 100644 --- a/apps/next/src/components/navbar.tsx +++ b/apps/next/src/components/navbar.tsx @@ -1,5 +1,6 @@ import { useSession } from "next-auth/react"; import dynamic from "next/dynamic"; +import { useState } from "react"; import { useRouter } from "next/router"; import React from "react"; @@ -31,6 +32,12 @@ const ImportFromQuizletModal = dynamic( () => import("./import-from-quizlet-modal"), { ssr: false }, ); + +const ImportFromFileModal = dynamic( + () => import("./ImportFromFileModal"), + { ssr: false } + ); + const CreateFolderModal = dynamic(() => import("./create-folder-modal"), { ssr: false, }); @@ -48,6 +55,7 @@ export const Navbar: React.FC = () => { const [folderChildSetId, setFolderChildSetId] = React.useState(); const [importIsEdit, setImportIsEdit] = React.useState(false); const [importModalOpen, setImportModalOpen] = React.useState(false); + const [fileImportModalOpen, setFileImportModalOpen] = useState(false); React.useEffect(() => { const createFolder = (setId?: string) => { @@ -63,7 +71,7 @@ export const Navbar: React.FC = () => { }; menuEventChannel.on("createFolder", createFolder); - menuEventChannel.on("openImportDialog", openImportDialog); + menuEventChannel.on("openImportDialog", openImportDialog); menuEventChannel.on("createClass", createClass); return () => { menuEventChannel.off("createFolder", createFolder); @@ -96,6 +104,10 @@ export const Navbar: React.FC = () => { }} edit={importIsEdit} /> + setFileImportModalOpen(false)} + /> { setImportIsEdit(false); setImportModalOpen(true); }} + onFileImportClick={() => setFileImportModalOpen(true)} onClassClick={onClassClick} /> @@ -151,6 +164,7 @@ export const Navbar: React.FC = () => { setImportIsEdit(false); setImportModalOpen(true); }} + onFileImportClick={() => setFileImportModalOpen(true)} /> diff --git a/apps/next/src/components/navbar/left-nav.tsx b/apps/next/src/components/navbar/left-nav.tsx index 4c49534a..0f653906 100644 --- a/apps/next/src/components/navbar/left-nav.tsx +++ b/apps/next/src/components/navbar/left-nav.tsx @@ -24,6 +24,8 @@ import { IconBooks, IconChevronDown, IconCloudDownload, + IconDownload, + IconUpload, IconFolder, IconSchool, IconSparkles, @@ -39,12 +41,14 @@ import { UnboundOnly } from "../unbound-only"; export interface LeftNavProps { onFolderClick: () => void; onImportClick: () => void; + onFileImportClick: () => void; onClassClick: () => void; } export const LeftNav: React.FC = ({ onFolderClick, onImportClick, + onFileImportClick, onClassClick, }) => { const { data: session, status } = useSession()!; @@ -156,6 +160,11 @@ export const LeftNav: React.FC = ({ label="Import from Quizlet" onClick={onImportClick} /> + } + label="Import from file" + onClick={onFileImportClick} + /> } diff --git a/apps/next/src/components/navbar/mobile-menu.tsx b/apps/next/src/components/navbar/mobile-menu.tsx index e31e9961..8ef5ef22 100644 --- a/apps/next/src/components/navbar/mobile-menu.tsx +++ b/apps/next/src/components/navbar/mobile-menu.tsx @@ -21,6 +21,7 @@ import { IconBooks, IconChevronDown, IconCloudDownload, + IconUpload, IconFolder, IconSchool, } from "@tabler/icons-react"; @@ -35,6 +36,7 @@ export interface MobileMenuProps { onFolderClick: () => void; onClassClick: () => void; onImportClick: () => void; + onFileImportClick: () => void; } export const MobileMenu: React.FC = ({ @@ -43,6 +45,7 @@ export const MobileMenu: React.FC = ({ onFolderClick, onClassClick, onImportClick, + onFileImportClick, }) => { const router = useRouter(); React.useEffect(() => { @@ -156,6 +159,11 @@ export const MobileMenu: React.FC = ({ label="Import from Quizlet" onClick={onImportClick} /> + } + label="Import from file" + onClick={onFileImportClick} + /> } diff --git a/apps/next/src/pages/api/auth/[...nextauth].ts b/apps/next/src/pages/api/auth/[...nextauth].ts index a38ab9ba..0ccc4b59 100644 --- a/apps/next/src/pages/api/auth/[...nextauth].ts +++ b/apps/next/src/pages/api/auth/[...nextauth].ts @@ -3,4 +3,4 @@ import NextAuth from "next-auth"; import { authOptions } from "@quenti/auth/next-auth-options"; -export default NextAuth(authOptions) as NextApiHandler; +export default NextAuth(authOptions as any) as NextApiHandler; \ No newline at end of file diff --git a/apps/next/src/pages/auth/login.tsx b/apps/next/src/pages/auth/login.tsx index 656acd0d..6aa6f8b6 100644 --- a/apps/next/src/pages/auth/login.tsx +++ b/apps/next/src/pages/auth/login.tsx @@ -16,4 +16,4 @@ export default function Login() { ); } -Login.PageWrapper = PageWrapper; +Login.PageWrapper = PageWrapper; \ No newline at end of file diff --git a/apps/next/src/server/integrations/anki.ts b/apps/next/src/server/integrations/anki.ts new file mode 100644 index 00000000..3da0278d --- /dev/null +++ b/apps/next/src/server/integrations/anki.ts @@ -0,0 +1,16 @@ +import AdmZip from "adm-zip"; +import Database from "better-sqlite3"; + +export async function parseAnkiApkg(base64: string) { + const zip = new AdmZip(Buffer.from(base64, "base64")); + const entry = zip.getEntry("collection.anki2"); + if (!entry) throw new Error("APKG inválido"); + const db = new Database(entry.getData(), { readonly: true }); + const cards: { term: string; definition: string }[] = []; + for (const { flds } of db.prepare("SELECT flds FROM notes").all() as any[]) { + const [front, back] = flds.split("\u001F"); + if (front && back) cards.push({ term: front, definition: back }); + } + db.close(); + return cards; +} diff --git a/apps/next/src/server/integrations/csv.ts b/apps/next/src/server/integrations/csv.ts new file mode 100644 index 00000000..0bf63c34 --- /dev/null +++ b/apps/next/src/server/integrations/csv.ts @@ -0,0 +1,9 @@ +export function parseCsv(text: string) { + return text + .split(/\r?\n/) + .map(line => line.split(",")) + .filter(([a, b]) => a && b) + .map(([a, b]) => (a && b ? { term: a.trim(), definition: b.trim() } : null)) + .filter(item => item !== null); + } + \ No newline at end of file diff --git a/apps/next/src/server/integrations/json.ts b/apps/next/src/server/integrations/json.ts new file mode 100644 index 00000000..2a031a2c --- /dev/null +++ b/apps/next/src/server/integrations/json.ts @@ -0,0 +1,8 @@ +export function parseJson(text: string) { + const arr = JSON.parse(text); + if (!Array.isArray(arr)) throw new Error("JSON deve ser um array."); + return arr + .filter((i): i is any => i.term && i.definition) + .map(i => ({ term: i.term, definition: i.definition })); + } + \ No newline at end of file diff --git a/apps/next/src/server/integrations/markdown.ts b/apps/next/src/server/integrations/markdown.ts new file mode 100644 index 00000000..d2ac2121 --- /dev/null +++ b/apps/next/src/server/integrations/markdown.ts @@ -0,0 +1,11 @@ +export function parseMarkdown(text: string) { + return text + .split(/\r?\n\r?\n/) + .map(block => block.split(/\r?\n/)) + .filter(([a, b]) => a && b) + .map(([a, b]) => ({ + term: (a ?? "").replace(/^[-*]\s*/, "").trim(), + definition: (b ?? "").replace(/^[-*]\s*/, "").trim(), + })); + } + \ No newline at end of file diff --git a/apps/next/tsconfig.json b/apps/next/tsconfig.json index f8bd1b11..12917354 100644 --- a/apps/next/tsconfig.json +++ b/apps/next/tsconfig.json @@ -9,5 +9,5 @@ "**/*.cjs", "**/*.mjs", "../../packages/types/next-auth.d.ts" - ] +, "../../packages/trpc/server/routers/import/from-file.handler.ts" ] } diff --git a/bun.lockb b/bun.lockb index 0ba8c703..939e2c27 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index a935bb37..3c3c47c7 100644 --- a/package.json +++ b/package.json @@ -28,16 +28,23 @@ }, "dependencies": { "@prisma/client": "5.5.2", + "adm-zip": "^0.5.16", + "better-sqlite3": "^11.9.1", + "caniuse-lite": "^1.0.30001715", "eslint-config-next": "^14.0.4", "eslint-config-prettier": "^9.1.0", "eslint-config-turbo": "^1.11.1", "eslint-plugin-prettier": "^5.0.1", "eslint-plugin-unused-imports": "^3.0.0", + "next-auth": "^4.24.11", + "openid-client": "^6.4.2", "turbo": "latest" }, "devDependencies": { "@faker-js/faker": "^8.0.2", "@trivago/prettier-plugin-sort-imports": "^4.3.0", + "@types/adm-zip": "^0.5.7", + "@types/better-sqlite3": "^7.6.13", "@types/jest": "^29.5.11", "@typescript-eslint/eslint-plugin": "^6.13.2", "@typescript-eslint/parser": "^6.13.2", diff --git a/packages/auth/next-auth-options.ts b/packages/auth/next-auth-options.ts index 06fc3fc0..ec7e7c79 100644 --- a/packages/auth/next-auth-options.ts +++ b/packages/auth/next-auth-options.ts @@ -1,10 +1,41 @@ import { type NextAuthOptions } from "next-auth"; +import { type DefaultSession, type DefaultUser } from "next-auth"; import GoogleProvider from "next-auth/providers/google"; +import ZitadelProvider from "next-auth/providers/zitadel"; import { env } from "@quenti/env/server"; import { APP_URL } from "@quenti/lib/constants/url"; import { prisma } from "@quenti/prisma"; +// Extend the default session and user types +declare module "next-auth" { + interface Session extends DefaultSession { + user: { + id: string; + username: string; + displayName?: string; + type?: string; + banned: boolean; + flags?: number; + completedOnboarding?: boolean; + organizationId?: string; + isOrgEligible?: boolean; + } & DefaultSession["user"]; + version?: string; + } + + interface User extends DefaultUser { + username?: string; + displayName?: string; + type?: string; + bannedAt?: Date; + flags?: number; + completedOnboarding?: boolean; + organizationId?: string; + isOrgEligible?: boolean; + } +} + import pjson from "../../apps/next/package.json"; import { sendVerificationRequest } from "./magic-link"; import { CustomPrismaAdapter } from "./prisma-adapter"; @@ -13,7 +44,7 @@ const version = pjson.version; export const authOptions: NextAuthOptions = { // Include user.id on session - callbacks: { + callbacks: { session({ session, user }) { if (session.user) { session.user.id = user.id; @@ -73,6 +104,28 @@ export const authOptions: NextAuthOptions = { type: "email", sendVerificationRequest, }, + ZitadelProvider({ + issuer: env.ZITADEL_ISSUER, + clientId: env.ZITADEL_CLIENT_ID, + clientSecret: env.ZITADEL_CLIENT_SECRET, + wellKnown: `${env.ZITADEL_ISSUER}/.well-known/openid-configuration`, + authorization: { + params: { + scope: `openid email profile`, + }, + }, + profile(profile) { + console.log("📥 ZITADEL raw profile:", profile); + return { + id: profile.sub, + name: profile.name, + email: profile.email, + image: profile.picture, + username: profile.preferred_username, + type: "ZITADEL", + }; + }, + }), /** * ...add more providers here * diff --git a/packages/auth/prisma-adapter.ts b/packages/auth/prisma-adapter.ts index c47800b7..2b2441bb 100644 --- a/packages/auth/prisma-adapter.ts +++ b/packages/auth/prisma-adapter.ts @@ -9,7 +9,7 @@ import type { PrismaClient, UserType } from "@quenti/prisma/client"; export function CustomPrismaAdapter(p: PrismaClient): Adapter { return { ...PrismaAdapter(p), - createUser: async (data) => { + createUser: async (data: { email: string; name?: string | null; image?: string | null }) => { const name = data.name; let uniqueUsername = null; diff --git a/packages/env/server/server.mjs b/packages/env/server/server.mjs index a819ae25..0e87303c 100644 --- a/packages/env/server/server.mjs +++ b/packages/env/server/server.mjs @@ -4,6 +4,9 @@ import { z } from "zod"; export const env = createEnv({ server: { DATABASE_URL: z.string().url(), + ZITADEL_ISSUER: z.string().url(), + ZITADEL_CLIENT_ID: z.string(), + ZITADEL_CLIENT_SECRET: z.string(), PLANETSCALE: z.string().optional(), NODE_ENV: z.enum(["development", "test", "production"]).optional(), NEXTAUTH_SECRET: diff --git a/packages/trpc/server/routers/import/_router.ts b/packages/trpc/server/routers/import/_router.ts index 95b7262c..018abbea 100644 --- a/packages/trpc/server/routers/import/_router.ts +++ b/packages/trpc/server/routers/import/_router.ts @@ -1,10 +1,13 @@ import { loadHandler } from "../../lib/load-handler"; import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { ZFromUrlSchema } from "./from-url.schema"; +import { ZFromFileSchema } from "./from-file.schema"; +import { fromFileHandler } from "./from-file.handler"; type ImportRouterHandlerCache = { handlers: { ["from-url"]?: typeof import("./from-url.handler").fromUrlHandler; + ["from-file"]?: typeof fromFileHandler; }; } & { routerPath: string }; @@ -20,4 +23,11 @@ export const importRouter = createTRPCRouter({ await loadHandler(HANDLER_CACHE, "from-url"); return HANDLER_CACHE.handlers["from-url"]!({ ctx, input }); }), + + fromFile: protectedProcedure + .input(ZFromFileSchema) // ← aqui usamos ZFromFileSchema + .mutation(async ({ ctx, input }) => { + await loadHandler(HANDLER_CACHE, "from-file"); + return HANDLER_CACHE.handlers["from-file"]!({ ctx, input }); + }), }); diff --git a/packages/trpc/server/routers/import/from-file.handler.ts b/packages/trpc/server/routers/import/from-file.handler.ts new file mode 100644 index 00000000..9df64a57 --- /dev/null +++ b/packages/trpc/server/routers/import/from-file.handler.ts @@ -0,0 +1,75 @@ +import { prisma } from "@quenti/prisma"; +import { parseCsv } from "../../../../../apps/next/src/server/integrations/csv"; +import { parseJson } from "../../../../../apps/next/src/server/integrations/json"; +import { parseMarkdown } from "../../../../../apps/next/src/server/integrations/markdown"; +import { parseAnkiApkg } from "../../../../../apps/next/src/server/integrations/anki"; + +import { TRPCError } from "@trpc/server"; +import type { FromFileInput } from "./from-file.schema"; + +/** + * Handler para o procedimento `import.fromFile`. + * Recebe sempre { ctx, input }, onde: + * - ctx.session.user.id é o ID do usuário + * - input.fileName / input.fileContent vêm do front-end + */ +export async function fromFileHandler({ + ctx, + input, +}: { + ctx: { session: { user: { id: string } } }; + input: FromFileInput; +}) { + const { fileName, fileContent } = input as unknown as { fileName: string; fileContent: string }; + const userId = ctx.session.user.id; + + // Detectar extensão + const ext = fileName.split(".").pop()?.toLowerCase(); + let cards: { term: string; definition: string }[] = []; + + switch (ext) { + case "csv": + cards = parseCsv(fileContent); + break; + case "json": + cards = parseJson(fileContent); + break; + case "md": + case "markdown": + cards = parseMarkdown(fileContent); + break; + case "apkg": + cards = await parseAnkiApkg(fileContent); + break; + default: + throw new TRPCError({ code: "BAD_REQUEST", message: "Formato não suportado." }); + } + + if (cards.length === 0) { + throw new TRPCError({ code: "BAD_REQUEST", message: "Nenhum flashcard encontrado." }); + } + + // Criar StudySet + const title = fileName.replace(/\.[^/.]+$/, ""); + const set = await prisma.studySet.create({ + data: { title, userId, description: "Default description" }, + }); + + // Inserir termos em lote + await prisma.term.createMany({ + data: cards.map((c) => ({ + studySetId: set.id, + word: c.term, + definition: c.definition, + rank: 0, // Provide a default value for rank or calculate it as needed + })), + }); + + return { + createdSetId: set.id, + title, + count: cards.length, + }; +} + +export default fromFileHandler; \ No newline at end of file diff --git a/packages/trpc/server/routers/import/from-file.schema.ts b/packages/trpc/server/routers/import/from-file.schema.ts new file mode 100644 index 00000000..cdf8117e --- /dev/null +++ b/packages/trpc/server/routers/import/from-file.schema.ts @@ -0,0 +1,15 @@ +// packages/trpc/server/routers/import/from-file.schema.ts +import { z } from "zod"; + +/** + * Schema para a rota `import.fromFile` + */ +export const ZFromFileSchema = z.object({ + fileName: z.string(), + fileContent: z.string(), +}); + +/** + * Tipo de input inferido + */ +export type FromFileInput = z.infer;