feat: adição de novos ícones SVG e configuração do ambiente

- Adicionados ícones SVG para ChatGPT, Claude, Gemini e OpenRouter
- Implementados ícones para modos claro e escuro do ChatGPT
- Criado script de inicialização para PostgreSQL com extensão pgcrypto
- Adicionado script de configuração de ambiente que faz backup do .env
- Configurado tsconfig.json para TypeScript com opções de compilação
This commit is contained in:
Felipe Coutinho
2025-11-15 15:49:36 -03:00
commit ea0b8618e0
441 changed files with 53569 additions and 0 deletions

View File

@@ -0,0 +1,144 @@
"use server";
import { anotacoes } from "@/db/schema";
import { type ActionResult, handleActionError } from "@/lib/actions/helpers";
import { revalidateForEntity } from "@/lib/actions/helpers";
import { uuidSchema } from "@/lib/schemas/common";
import { db } from "@/lib/db";
import { getUser } from "@/lib/auth/server";
import { and, eq } from "drizzle-orm";
import { z } from "zod";
const taskSchema = z.object({
id: z.string(),
text: z.string().min(1, "O texto da tarefa não pode estar vazio."),
completed: z.boolean(),
});
const noteBaseSchema = z.object({
title: z
.string({ message: "Informe o título da anotação." })
.trim()
.min(1, "Informe o título da anotação.")
.max(30, "O título deve ter no máximo 30 caracteres."),
description: z
.string({ message: "Informe o conteúdo da anotação." })
.trim()
.max(350, "O conteúdo deve ter no máximo 350 caracteres.")
.optional()
.default(""),
type: z.enum(["nota", "tarefa"], {
message: "O tipo deve ser 'nota' ou 'tarefa'.",
}),
tasks: z.array(taskSchema).optional().default([]),
}).refine(
(data) => {
// Se for nota, a descrição é obrigatória
if (data.type === "nota") {
return data.description.trim().length > 0;
}
// Se for tarefa, deve ter pelo menos uma tarefa
if (data.type === "tarefa") {
return data.tasks && data.tasks.length > 0;
}
return true;
},
{
message: "Notas precisam de descrição e Tarefas precisam de ao menos uma tarefa.",
}
);
const createNoteSchema = noteBaseSchema;
const updateNoteSchema = noteBaseSchema.and(z.object({
id: uuidSchema("Anotação"),
}));
const deleteNoteSchema = z.object({
id: uuidSchema("Anotação"),
});
type NoteCreateInput = z.infer<typeof createNoteSchema>;
type NoteUpdateInput = z.infer<typeof updateNoteSchema>;
type NoteDeleteInput = z.infer<typeof deleteNoteSchema>;
export async function createNoteAction(
input: NoteCreateInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = createNoteSchema.parse(input);
await db.insert(anotacoes).values({
title: data.title,
description: data.description,
type: data.type,
tasks: data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null,
userId: user.id,
});
revalidateForEntity("anotacoes");
return { success: true, message: "Anotação criada com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function updateNoteAction(
input: NoteUpdateInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = updateNoteSchema.parse(input);
const [updated] = await db
.update(anotacoes)
.set({
title: data.title,
description: data.description,
type: data.type,
tasks: data.tasks && data.tasks.length > 0 ? JSON.stringify(data.tasks) : null,
})
.where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id)))
.returning({ id: anotacoes.id });
if (!updated) {
return {
success: false,
error: "Anotação não encontrada.",
};
}
revalidateForEntity("anotacoes");
return { success: true, message: "Anotação atualizada com sucesso." };
} catch (error) {
return handleActionError(error);
}
}
export async function deleteNoteAction(
input: NoteDeleteInput
): Promise<ActionResult> {
try {
const user = await getUser();
const data = deleteNoteSchema.parse(input);
const [deleted] = await db
.delete(anotacoes)
.where(and(eq(anotacoes.id, data.id), eq(anotacoes.userId, user.id)))
.returning({ id: anotacoes.id });
if (!deleted) {
return {
success: false,
error: "Anotação não encontrada.",
};
}
revalidateForEntity("anotacoes");
return { success: true, message: "Anotação removida com sucesso." };
} catch (error) {
return handleActionError(error);
}
}

View File

@@ -0,0 +1,48 @@
import { anotacoes, type Anotacao } from "@/db/schema";
import { db } from "@/lib/db";
import { eq } from "drizzle-orm";
export type Task = {
id: string;
text: string;
completed: boolean;
};
export type NoteData = {
id: string;
title: string;
description: string;
type: "nota" | "tarefa";
tasks?: Task[];
createdAt: string;
};
export async function fetchNotesForUser(userId: string): Promise<NoteData[]> {
const noteRows = await db.query.anotacoes.findMany({
where: eq(anotacoes.userId, userId),
orderBy: (note: typeof anotacoes.$inferSelect, { desc }: { desc: (field: unknown) => unknown }) => [desc(note.createdAt)],
});
return noteRows.map((note: Anotacao) => {
let tasks: Task[] | undefined;
// Parse tasks if they exist
if (note.tasks) {
try {
tasks = JSON.parse(note.tasks);
} catch (error) {
console.error("Failed to parse tasks for note", note.id, error);
tasks = undefined;
}
}
return {
id: note.id,
title: (note.title ?? "").trim(),
description: (note.description ?? "").trim(),
type: (note.type ?? "nota") as "nota" | "tarefa",
tasks,
createdAt: note.createdAt.toISOString(),
};
});
}

View File

@@ -0,0 +1,23 @@
import PageDescription from "@/components/page-description";
import { RiFileListLine } from "@remixicon/react";
export const metadata = {
title: "Anotações | OpenSheets",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<section className="space-y-6 px-6">
<PageDescription
icon={<RiFileListLine />}
title="Notas"
subtitle="Gerencie suas anotações e mantenha o controle sobre suas ideias e tarefas."
/>
{children}
</section>
);
}

View File

@@ -0,0 +1,51 @@
import { Skeleton } from "@/components/ui/skeleton";
/**
* Loading state para a página de anotações
* Layout: Header com botão + Grid de cards de notas
*/
export default function AnotacoesLoading() {
return (
<main className="flex flex-col items-start gap-6">
<div className="w-full space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
<Skeleton className="h-10 w-40 rounded-2xl bg-foreground/10" />
</div>
{/* Grid de cards de notas */}
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border p-4 space-y-3"
>
{/* Título */}
<Skeleton className="h-6 w-3/4 rounded-2xl bg-foreground/10" />
{/* Conteúdo (3-4 linhas) */}
<div className="space-y-2">
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-2/3 rounded-2xl bg-foreground/10" />
{i % 2 === 0 && (
<Skeleton className="h-4 w-full rounded-2xl bg-foreground/10" />
)}
</div>
{/* Footer com data e ações */}
<div className="flex items-center justify-between pt-2 border-t">
<Skeleton className="h-3 w-24 rounded-2xl bg-foreground/10" />
<div className="flex gap-1">
<Skeleton className="size-8 rounded-2xl bg-foreground/10" />
<Skeleton className="size-8 rounded-2xl bg-foreground/10" />
</div>
</div>
</div>
))}
</div>
</div>
</main>
);
}

View File

@@ -0,0 +1,14 @@
import { NotesPage } from "@/components/anotacoes/notes-page";
import { getUserId } from "@/lib/auth/server";
import { fetchNotesForUser } from "./data";
export default async function Page() {
const userId = await getUserId();
const notes = await fetchNotesForUser(userId);
return (
<main className="flex flex-col items-start gap-6">
<NotesPage notes={notes} />
</main>
);
}