Skip to content
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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
ENVIRONMENT=
RESEND_API_KEY=
DOMAIN=
ADMIN_EMAIL=
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ _fresh/
node_modules/

sqlite*
.serena
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
# Expenso

This is my first app built in deno/fresh. It's my attempt of creating the expense tracker app I always wanted and never found.
This is my first app built in deno/fresh. It's my attempt of creating the
expense tracker app I always wanted and never found.

## Usage

Make sure to [install Deno](https://deno.land/manual/getting_started/installation).
Make sure to
[install Deno](https://deno.land/manual/getting_started/installation).

Then copy the `.env.example` to `.env` and fill the necessary values. You will need a Resend account available for starting the app with authentication.
Then copy the `.env.example` to `.env` and fill the necessary values. You will
need a Resend account available for starting the app with authentication.

You're good to start the project:

Expand All @@ -18,6 +21,10 @@ This will watch the project directory and restart as necessary.

## Inspecting the DB

Since I used [deno kv](https://deno.com/kv), I didn't find a proper way of inspecting its data, even if I export it to a sqlite file with the `DENO_KV_PATH` environment variable.
Since I used [deno kv](https://deno.com/kv), I didn't find a proper way of
inspecting its data, even if I export it to a sqlite file with the
`DENO_KV_PATH` environment variable.

I found [kview](https://github.com/kitsonk/kview) which works better than inspecting the sqlite file. So, if you intend to run the project locally and want to inspect any db data, I highly encourage to use kview.
I found [kview](https://github.com/kitsonk/kview) which works better than
inspecting the sqlite file. So, if you intend to run the project locally and
want to inspect any db data, I highly encourage to use kview.
269 changes: 269 additions & 0 deletions islands/BackupImporter.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import { useState } from "preact/hooks";

const CHUNK_SIZE = 50 * 1024; // 50KB chunks
const API_BASE = "/api/admin/backup";

interface ImportResult {
success: boolean;
message: string;
entriesImported?: number;
entriesSkipped?: number;
errors?: string[];
}

export default function BackupImporter() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isDragOver, setIsDragOver] = useState(false);
const [isUploading, setIsUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [statusMessage, setStatusMessage] = useState("Preparing upload...");
const [result, setResult] = useState<
{
data: ImportResult;
type: "success" | "warning" | "error";
} | null
>(null);
const [dryRun, setDryRun] = useState(false);
const [skipExisting, setSkipExisting] = useState(true);

const handleFileSelect = (file: File) => {
setSelectedFile(file);
setResult(null);
};

const handleUpload = async () => {
if (!selectedFile) return;

setIsUploading(true);
setResult(null);
setProgress(0);

const sessionId = generateSessionId();
const totalChunks = Math.ceil(selectedFile.size / CHUNK_SIZE);

try {
for (let i = 0; i < totalChunks; i++) {
const start = i * CHUNK_SIZE;
const end = Math.min(start + CHUNK_SIZE, selectedFile.size);
const chunk = selectedFile.slice(start, end);
const chunkText = await chunk.text();

setProgress(Math.round((i / totalChunks) * 100));
setStatusMessage("Uploading chunks...");

const response = await fetch(API_BASE, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
sessionId,
chunkIndex: i,
totalChunks,
data: chunkText,
}),
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.message || "Chunk upload failed");
}
}

setProgress(100);
setStatusMessage("Processing import...");

const completeUrl =
`${API_BASE}?action=complete&sessionId=${sessionId}&dryRun=${dryRun}&skipExisting=${skipExisting}`;
const completeResponse = await fetch(completeUrl, { method: "POST" });
const resultData = await completeResponse.json();

if (completeResponse.ok || completeResponse.status === 207) {
setResult({
data: resultData,
type: completeResponse.status === 207 ? "warning" : "success",
});
} else {
throw new Error(resultData.message || "Import failed");
}
} catch (error) {
setResult({
data: {
success: false,
message: error instanceof Error ? error.message : "Unknown error",
errors: [String(error)],
},
type: "error",
});

try {
await fetch(`${API_BASE}?sessionId=${sessionId}`, { method: "DELETE" });
} catch {
console.error("Cleanup failed");
}
} finally {
setIsUploading(false);
}
};

const generateSessionId = () => {
return Date.now().toString(36) + Math.random().toString(36).substring(2);
};

const fileSizeMB = selectedFile
? (selectedFile.size / (1024 * 1024)).toFixed(2)
: "0";
const chunkCount = selectedFile
? Math.ceil(selectedFile.size / CHUNK_SIZE)
: 0;

const alertClass = result?.type ? `alert-${result?.type}` : "alert-error";

return (
<div class="card bg-base-100 shadow-xl w-full max-w-xl">
<div class="card-body">
<h2 class="card-title text-2xl mb-2">Backup Importer</h2>
<p class="text-base-content/70 mb-6">
Upload large backup files by splitting them into manageable chunks
</p>

{/* File Input - using label for native click behavior */}
<label
for="backup-file-input"
class={`border-2 border-dashed rounded-box p-8 text-center cursor-pointer transition-all hover:border-primary hover:bg-base-200 block ${
isDragOver ? "border-primary bg-base-200" : "border-base-300"
}`}
onDragOver={(e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(true);
}}
onDragLeave={(e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
}}
onDrop={(e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragOver(false);
const files = e.dataTransfer?.files;
if (files && files.length > 0) {
handleFileSelect(files[0]);
}
}}
>
<div class="text-5xl mb-3">📁</div>
<p class="font-semibold">
Click to select{" "}
<span class="font-normal">or drag and drop your backup file</span>
</p>
<p class="text-sm text-base-content/50 mt-2">Supports .jsonl files</p>
</label>
<input
id="backup-file-input"
type="file"
accept=".jsonl,.json"
class="hidden"
onChange={(e: Event) => {
const input = e.target as HTMLInputElement;
const files = input.files;
if (files && files.length > 0) {
handleFileSelect(files[0]);
}
}}
/>

{selectedFile && (
<div class="bg-base-200 rounded-box p-4 mt-4">
<p>
<span class="font-semibold">File:</span> {selectedFile.name}
</p>
<p>
<span class="font-semibold">Size:</span> {fileSizeMB} MB
</p>
<p>
<span class="font-semibold">Chunks:</span> {chunkCount}
</p>
</div>
)}

<div class="form-control mt-4">
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
class="checkbox checkbox-primary"
checked={dryRun}
onChange={(e: Event) =>
setDryRun((e.target as HTMLInputElement).checked)}
/>
<span class="label-text">Dry run (test without importing)</span>
</label>
<label class="label cursor-pointer justify-start gap-3">
<input
type="checkbox"
class="checkbox checkbox-primary"
checked={skipExisting}
onChange={(e: Event) =>
setSkipExisting((e.target as HTMLInputElement).checked)}
/>
<span class="label-text">Skip existing entries</span>
</label>
</div>

<button
type="button"
class={`btn btn-primary w-full mt-4 ${isUploading ? "loading" : ""}`}
onClick={handleUpload}
disabled={!selectedFile || isUploading}
>
{!selectedFile
? "Select a file to upload"
: isUploading
? "Uploading..."
: "Start Upload"}
</button>

{isUploading && (
<div class="mt-4">
<progress
class="progress progress-primary w-full"
value={progress}
max="100"
>
</progress>
<p class="text-center text-sm text-base-content/70 mt-2">
{statusMessage} ({progress}%)
</p>
</div>
)}

{result && (
<div class={`alert ${alertClass} mt-4`}>
<div class="w-full">
<p class="font-semibold">{result.data.message}</p>
{result.data.entriesImported !== undefined && (
<p>Entries imported: {result.data.entriesImported}</p>
)}
{result.data.entriesSkipped !== undefined &&
result.data.entriesSkipped > 0 && (
<p>Entries skipped: {result.data.entriesSkipped}</p>
)}
{result.data.errors && result.data.errors.length > 0 && (
<div class="mt-2 max-h-40 overflow-y-auto text-sm">
<p class="font-semibold">
Errors ({result.data.errors.length}):
</p>
{result.data.errors.slice(0, 10).map((error, i) => (
<p key={i}>• {error}</p>
))}
{result.data.errors.length > 10 && (
<p>... and {result.data.errors.length - 10} more</p>
)}
</div>
)}
</div>
</div>
)}
</div>
</div>
);
}
17 changes: 4 additions & 13 deletions routes/_error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,22 +57,13 @@ export default function NotFoundError(props: PageProps) {
}
}

if (error instanceof ZodError) {
const status = getStatusCode(error);
const validationError = fromError(error);

return (
<ErrorPage
title={`${status} - ${STATUS_TEXT[status]}`}
description={validationError.message}
/>
);
}
const status = getStatusCode(error as Error);
const validationError = fromError(error);

return (
<ErrorPage
title="500 - Oops, something went wrong"
description="An unexpected error occurred. Please try again later."
title={`${status} - ${STATUS_TEXT[status]}`}
description={validationError.message}
/>
);
}
1 change: 0 additions & 1 deletion routes/_middleware.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import { getSessionIdCookie } from "@/utils/auth.ts";
export async function handler(ctx: Context<State>) {
ctx.state.sessionUser = undefined;

// fetch session id from cookies. try one provider at a time
const sessionId = getSessionIdCookie(ctx.req);
if (sessionId === undefined) {
return await ctx.next();
Expand Down
17 changes: 17 additions & 0 deletions routes/admin/_middleware.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Context } from "fresh";
import { State } from "@/utils/state.ts";
import { env } from "@/utils/env.ts";

export async function handler(ctx: Context<State>) {
// Check if user is signed in
if (!ctx.state.sessionUser) {
throw new Deno.errors.NotFound("Page not found");
}

// Check if user's email matches the admin email
if (!env.ADMIN_EMAIL || ctx.state.sessionUser.email !== env.ADMIN_EMAIL) {
throw new Deno.errors.NotFound("Page not found");
}

return await ctx.next();
}
10 changes: 10 additions & 0 deletions routes/admin/backup-importer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import BackupImporter from "@/islands/BackupImporter.tsx";
import { define } from "@/utils/state.ts";

export default define.page(() => {
return (
<div class="min-h-screen bg-base-200 flex items-center justify-center p-4">
<BackupImporter />
</div>
);
});
Loading