Skip to content

Feature/import from file #359

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Binary file added apps/next/public/assets/zitadel.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
116 changes: 116 additions & 0 deletions apps/next/src/components/ImportFromFileModal.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement>(null);
const toast = useToast();
const [file, setFile] = useState<File | null>(null);
const [error, setError] = useState<string>();
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<HTMLInputElement>) {
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 (
<Modal isOpen={isOpen} onClose={onClose} size="xl" isCentered={false}>
<Modal.Overlay />
<Modal.Content
className={styles.card}
_before={{
opacity: isLoading ? 1 : 0,
}}
_after={{
opacity: isLoading ? 1 : 0,
}}
>
<Stack
bg="white"
_dark={{
bg: "gray.800",
}}
rounded="xl"
>
<Modal.Body>
<Modal.Heading>Import from file</Modal.Heading>
<FormControl isInvalid={!!error}>
<Input
type="file"
accept=".md,.markdown,.json,.csv,.apkg"
ref={inputRef}
style={{ display: "none" }}
onChange={handleFileChange}
/>
<Button onClick={handleSelectClick} mb={2}>
{file ? file.name : "Selecione um arquivo"}
</Button>
{error && <FormErrorMessage>{error}</FormErrorMessage>}
</FormControl>
</Modal.Body>
<Modal.Divider />
<Modal.Footer>
<Button variant="ghost" onClick={onClose} isDisabled={isLoading}>Cancelar</Button>
<Button colorScheme="blue" onClick={handleImport} isDisabled={!file || isLoading}>
{isLoading ? <Spinner size="sm" /> : "Importar"}
</Button>
</Modal.Footer>
</Stack>
</Modal.Content>
</Modal>
);
}

export default ImportFromFileModal;
32 changes: 31 additions & 1 deletion apps/next/src/components/auth-layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -201,6 +203,34 @@ export const AuthLayout: React.FC<AuthLayoutProps> = ({
>
Sign {verb} with Google
</Button>
<Button
size="lg"
fontSize="sm"
variant="outline"
shadow="0 4px 6px -1px rgba(0, 0, 0, 0.04),0 2px 4px -1px rgba(0, 0, 0, 0.01)"
colorScheme="gray"
leftIcon={
<Image
src={zitadel}
alt="Zitadel icon"
width={20}
height={20}
/>
}
onClick={async () => {
if (mode == "signup") {
await event("signup", {});
} else {
await event("login", {});
}

await signIn("zitadel", {
callbackUrl: safeCallbackUrl,
});
}}
>
Sign {verb} with Zitadel
</Button>
<Box>
<Stack
my={expanded ? 4 : 0}
Expand Down Expand Up @@ -343,4 +373,4 @@ export const AuthLayout: React.FC<AuthLayoutProps> = ({
</LazyWrapper>
</>
);
};
};
16 changes: 15 additions & 1 deletion apps/next/src/components/navbar.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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,
});
Expand All @@ -48,6 +55,7 @@ export const Navbar: React.FC = () => {
const [folderChildSetId, setFolderChildSetId] = React.useState<string>();
const [importIsEdit, setImportIsEdit] = React.useState(false);
const [importModalOpen, setImportModalOpen] = React.useState(false);
const [fileImportModalOpen, setFileImportModalOpen] = useState(false);

React.useEffect(() => {
const createFolder = (setId?: string) => {
Expand All @@ -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);
Expand Down Expand Up @@ -96,6 +104,10 @@ export const Navbar: React.FC = () => {
}}
edit={importIsEdit}
/>
<ImportFromFileModal
isOpen={fileImportModalOpen}
onClose={() => setFileImportModalOpen(false)}
/>
<Flex pos="relative" zIndex={1000} w="full" h="20">
<HStack
as="header"
Expand All @@ -112,6 +124,7 @@ export const Navbar: React.FC = () => {
setImportIsEdit(false);
setImportModalOpen(true);
}}
onFileImportClick={() => setFileImportModalOpen(true)}
onClassClick={onClassClick}
/>
<Box display={["block", "block", "none"]}>
Expand Down Expand Up @@ -151,6 +164,7 @@ export const Navbar: React.FC = () => {
setImportIsEdit(false);
setImportModalOpen(true);
}}
onFileImportClick={() => setFileImportModalOpen(true)}
/>
</Box>
<HStack as="nav" display={["none", "none", "flex"]} height="12">
Expand Down
9 changes: 9 additions & 0 deletions apps/next/src/components/navbar/left-nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
IconBooks,
IconChevronDown,
IconCloudDownload,
IconDownload,
IconUpload,
IconFolder,
IconSchool,
IconSparkles,
Expand All @@ -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<LeftNavProps> = ({
onFolderClick,
onImportClick,
onFileImportClick,
onClassClick,
}) => {
const { data: session, status } = useSession()!;
Expand Down Expand Up @@ -156,6 +160,11 @@ export const LeftNav: React.FC<LeftNavProps> = ({
label="Import from Quizlet"
onClick={onImportClick}
/>
<MenuOption
icon={<IconUpload size={20} />}
label="Import from file"
onClick={onFileImportClick}
/>
<MenuDivider />
<MenuOption
icon={<IconFolder size={20} />}
Expand Down
8 changes: 8 additions & 0 deletions apps/next/src/components/navbar/mobile-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
IconBooks,
IconChevronDown,
IconCloudDownload,
IconUpload,
IconFolder,
IconSchool,
} from "@tabler/icons-react";
Expand All @@ -35,6 +36,7 @@ export interface MobileMenuProps {
onFolderClick: () => void;
onClassClick: () => void;
onImportClick: () => void;
onFileImportClick: () => void;
}

export const MobileMenu: React.FC<MobileMenuProps> = ({
Expand All @@ -43,6 +45,7 @@ export const MobileMenu: React.FC<MobileMenuProps> = ({
onFolderClick,
onClassClick,
onImportClick,
onFileImportClick,
}) => {
const router = useRouter();
React.useEffect(() => {
Expand Down Expand Up @@ -156,6 +159,11 @@ export const MobileMenu: React.FC<MobileMenuProps> = ({
label="Import from Quizlet"
onClick={onImportClick}
/>
<MenuOption
icon={<IconUpload size={20} />}
label="Import from file"
onClick={onFileImportClick}
/>
<MenuDivider />
<MenuOption
icon={<IconFolder size={20} />}
Expand Down
2 changes: 1 addition & 1 deletion apps/next/src/pages/api/auth/[...nextauth].ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
2 changes: 1 addition & 1 deletion apps/next/src/pages/auth/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,4 @@ export default function Login() {
);
}

Login.PageWrapper = PageWrapper;
Login.PageWrapper = PageWrapper;
16 changes: 16 additions & 0 deletions apps/next/src/server/integrations/anki.ts
Original file line number Diff line number Diff line change
@@ -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;
}
9 changes: 9 additions & 0 deletions apps/next/src/server/integrations/csv.ts
Original file line number Diff line number Diff line change
@@ -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);
}

8 changes: 8 additions & 0 deletions apps/next/src/server/integrations/json.ts
Original file line number Diff line number Diff line change
@@ -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 }));
}

11 changes: 11 additions & 0 deletions apps/next/src/server/integrations/markdown.ts
Original file line number Diff line number Diff line change
@@ -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(),
}));
}

2 changes: 1 addition & 1 deletion apps/next/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
"**/*.cjs",
"**/*.mjs",
"../../packages/types/next-auth.d.ts"
]
, "../../packages/trpc/server/routers/import/from-file.handler.ts" ]
}
Binary file modified bun.lockb
Binary file not shown.
Loading