This commit is contained in:
Leon
2025-07-15 22:54:35 +02:00
commit f7eda17284
89 changed files with 18535 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,122 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "LetterFeed",
description: "Read your newsletters as RSS feeds!",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
</body>
</html>
);
}

104
frontend/src/app/page.tsx Normal file
View File

@@ -0,0 +1,104 @@
"use client"
import { useState, useEffect, useCallback } from "react"
import {
getNewsletters,
getSettings,
getImapFolders,
Newsletter,
Settings as AppSettings,
} from "@/lib/api"
import { LoadingSpinner } from "@/components/letterfeed/LoadingSpinner"
import { Header } from "@/components/letterfeed/Header"
import { NewsletterList } from "@/components/letterfeed/NewsletterList"
import { EmptyState } from "@/components/letterfeed/EmptyState"
import { AddNewsletterDialog } from "@/components/letterfeed/AddNewsletterDialog"
import { EditNewsletterDialog } from "@/components/letterfeed/EditNewsletterDialog"
import { SettingsDialog } from "@/components/letterfeed/SettingsDialog"
export default function LetterFeedApp() {
const [newsletters, setNewsletters] = useState<Newsletter[]>([])
const [isLoading, setIsLoading] = useState(true)
const [settings, setSettings] = useState<AppSettings | null>(null)
const [folderOptions, setFolderOptions] = useState<string[]>([])
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isSettingsOpen, setIsSettingsOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [editingNewsletter, setEditingNewsletter] = useState<Newsletter | null>(null)
const fetchData = useCallback(async () => {
try {
const [newslettersData, settingsData, foldersData] = await Promise.all([
getNewsletters(),
getSettings(),
getImapFolders(),
])
setNewsletters(newslettersData)
setSettings(settingsData)
setFolderOptions(foldersData)
} catch (error) {
console.error("Failed to fetch data:", error)
} finally {
setIsLoading(false)
}
}, [])
useEffect(() => {
fetchData()
}, [fetchData])
const openEditDialog = (newsletter: Newsletter) => {
setEditingNewsletter(newsletter)
setIsEditDialogOpen(true)
}
if (isLoading) {
return <LoadingSpinner />
}
return (
<div className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<Header
onOpenAddNewsletter={() => setIsAddDialogOpen(true)}
onOpenSettings={() => setIsSettingsOpen(true)}
/>
{newsletters.length > 0 ? (
<NewsletterList newsletters={newsletters} onEditNewsletter={openEditDialog} />
) : (
<EmptyState onAddNewsletter={() => setIsAddDialogOpen(true)} />
)}
<AddNewsletterDialog
isOpen={isAddDialogOpen}
onOpenChange={setIsAddDialogOpen}
onSuccess={fetchData}
/>
{editingNewsletter && (
<EditNewsletterDialog
newsletter={editingNewsletter}
isOpen={isEditDialogOpen}
onOpenChange={setIsEditDialogOpen}
onSuccess={() => {
setEditingNewsletter(null)
fetchData()
}}
/>
)}
{settings && (
<SettingsDialog
settings={settings}
folderOptions={folderOptions}
isOpen={isSettingsOpen}
onOpenChange={setIsSettingsOpen}
onSuccess={fetchData}
/>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,256 @@
import {
getNewsletters,
createNewsletter,
updateNewsletter,
deleteNewsletter,
getSettings,
updateSettings,
getImapFolders,
testImapConnection,
getFeedUrl,
NewsletterCreate,
NewsletterUpdate,
SettingsCreate,
} from "../api"
// Mock the global fetch function
global.fetch = jest.fn()
const mockFetch = (data: any, ok = true) => { // eslint-disable-line @typescript-eslint/no-explicit-any
;(fetch as jest.Mock).mockResolvedValueOnce({
ok,
json: () => Promise.resolve(data),
})
}
const mockFetchError = (data: any = {}) => { // eslint-disable-line @typescript-eslint/no-explicit-any
;(fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
json: () => Promise.resolve(data),
})
}
describe("API Functions", () => {
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL
beforeEach(() => {
// Reset the mock before each test
;(fetch as jest.Mock).mockClear()
})
describe("getNewsletters", () => {
it("should fetch newsletters successfully", async () => {
const mockNewsletters = [
{ id: 1, name: "Newsletter 1", is_active: true, senders: [], entries_count: 5 },
{ id: 2, name: "Newsletter 2", is_active: false, senders: [], entries_count: 10 },
]
mockFetch(mockNewsletters)
const newsletters = await getNewsletters()
expect(newsletters).toEqual(mockNewsletters)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters`)
})
it("should throw an error if fetching newsletters fails", async () => {
mockFetchError()
await expect(getNewsletters()).rejects.toThrow("Failed to fetch newsletters")
})
})
describe("createNewsletter", () => {
it("should create a newsletter successfully", async () => {
const newNewsletter: NewsletterCreate = { name: "New Newsletter", sender_emails: ["test@example.com"] }
const createdNewsletter = {
id: 3,
...newNewsletter,
is_active: true,
senders: [{ id: 1, email: "test@example.com", newsletter_id: 3 }],
entries_count: 0,
}
mockFetch(createdNewsletter)
const result = await createNewsletter(newNewsletter)
expect(result).toEqual(createdNewsletter)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newNewsletter),
})
})
it("should throw an error if creating newsletter fails", async () => {
const newNewsletter: NewsletterCreate = { name: "New Newsletter", sender_emails: [] }
mockFetchError()
await expect(createNewsletter(newNewsletter)).rejects.toThrow("Failed to create newsletter")
})
})
describe("updateNewsletter", () => {
it("should update a newsletter successfully", async () => {
const updatedNewsletter: NewsletterUpdate = { name: "Updated Newsletter", sender_emails: ["updated@example.com"] }
const newsletterId = 1
const returnedNewsletter = {
id: newsletterId,
...updatedNewsletter,
is_active: true,
senders: [{ id: 1, email: "updated@example.com", newsletter_id: newsletterId }],
entries_count: 12,
}
mockFetch(returnedNewsletter)
const result = await updateNewsletter(newsletterId, updatedNewsletter)
expect(result).toEqual(returnedNewsletter)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters/${newsletterId}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(updatedNewsletter),
})
})
it("should throw an error if updating newsletter fails", async () => {
const updatedNewsletter: NewsletterUpdate = { name: "Updated Newsletter", sender_emails: [] }
const newsletterId = 1
mockFetchError()
await expect(updateNewsletter(newsletterId, updatedNewsletter)).rejects.toThrow("Failed to update newsletter")
})
})
describe("deleteNewsletter", () => {
it("should delete a newsletter successfully", async () => {
const newsletterId = 1
mockFetch({}, true) // Successful deletion might not have a body
await deleteNewsletter(newsletterId)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/newsletters/${newsletterId}`, {
method: "DELETE",
})
})
it("should throw an error if deleting newsletter fails", async () => {
const newsletterId = 1
mockFetchError()
await expect(deleteNewsletter(newsletterId)).rejects.toThrow("Failed to delete newsletter")
})
})
describe("getSettings", () => {
it("should fetch settings successfully", async () => {
const mockSettings = {
id: 1,
imap_server: "imap.example.com",
imap_username: "user@example.com",
search_folder: "INBOX",
move_to_folder: null,
mark_as_read: true,
email_check_interval: 60,
auto_add_new_senders: false,
locked_fields: [],
}
mockFetch(mockSettings)
const settings = await getSettings()
expect(settings).toEqual(mockSettings)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/settings`)
})
it("should throw an error if fetching settings fails", async () => {
mockFetchError()
await expect(getSettings()).rejects.toThrow("Failed to fetch settings")
})
})
describe("updateSettings", () => {
it("should update settings successfully", async () => {
const newSettings: SettingsCreate = {
imap_server: "new.imap.com",
imap_username: "newuser@example.com",
imap_password: "password",
search_folder: "Archive",
move_to_folder: "Processed",
mark_as_read: false,
email_check_interval: 120,
auto_add_new_senders: true,
}
const updatedSettings = { id: 1, ...newSettings, locked_fields: [] }
mockFetch(updatedSettings)
const result = await updateSettings(newSettings)
expect(result).toEqual(updatedSettings)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/settings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newSettings),
})
})
it("should throw an error if updating settings fails", async () => {
const newSettings: SettingsCreate = {
imap_server: "new.imap.com",
imap_username: "newuser@example.com",
search_folder: "Archive",
mark_as_read: false,
email_check_interval: 120,
auto_add_new_senders: true,
}
mockFetchError()
await expect(updateSettings(newSettings)).rejects.toThrow("Failed to update settings")
})
})
describe("getImapFolders", () => {
it("should fetch IMAP folders successfully", async () => {
const mockFolders = ["INBOX", "Sent", "Archive"]
mockFetch(mockFolders)
const folders = await getImapFolders()
expect(folders).toEqual(mockFolders)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/folders`)
})
it("should return an empty array if fetching IMAP folders fails", async () => {
mockFetchError()
const folders = await getImapFolders()
expect(folders).toEqual([])
})
})
describe("testImapConnection", () => {
it("should test IMAP connection successfully", async () => {
const mockResponse = { message: "Connection successful" }
mockFetch(mockResponse)
const result = await testImapConnection()
expect(result).toEqual(mockResponse)
expect(fetch).toHaveBeenCalledWith(`${API_BASE_URL}/imap/test`, {
method: "POST",
})
})
it("should throw an error with detail if testing IMAP connection fails", async () => {
const errorMessage = "Invalid credentials"
mockFetchError({ detail: errorMessage })
await expect(testImapConnection()).rejects.toThrow(errorMessage)
})
it("should throw a generic error if testing IMAP connection fails without detail", async () => {
mockFetchError()
await expect(testImapConnection()).rejects.toThrow("Failed to test IMAP connection")
})
})
describe("getFeedUrl", () => {
it("should return the correct feed URL", () => {
const newsletterId = 123
const expectedUrl = `${API_BASE_URL}/feeds/${newsletterId}`
const url = getFeedUrl(newsletterId)
expect(url).toBe(expectedUrl)
})
it("should handle newsletterId being 0", () => {
const newsletterId = 0
const expectedUrl = `${API_BASE_URL}/feeds/0`
const url = getFeedUrl(newsletterId)
expect(url).toBe(expectedUrl)
})
})
})

142
frontend/src/lib/api.ts Normal file
View File

@@ -0,0 +1,142 @@
// frontend/src/lib/api.ts
const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL;
export interface Sender {
id: number;
email: string;
newsletter_id: number;
}
export interface Newsletter {
id: number
name: string
is_active: boolean
senders: { id: number; email: string }[]
entries_count: number
}
export interface NewsletterCreate {
name: string;
sender_emails: string[];
}
export interface NewsletterUpdate {
name: string;
sender_emails: string[];
}
export interface Settings {
id: number;
imap_server: string;
imap_username: string;
search_folder: string;
move_to_folder?: string | null;
mark_as_read: boolean;
email_check_interval: number;
auto_add_new_senders: boolean;
locked_fields: string[];
}
export interface SettingsCreate {
imap_server: string;
imap_username: string;
imap_password?: string;
search_folder: string;
move_to_folder?: string | null;
mark_as_read: boolean;
email_check_interval: number;
auto_add_new_senders: boolean;
}
export async function getNewsletters(): Promise<Newsletter[]> {
const response = await fetch(`${API_BASE_URL}/newsletters`);
if (!response.ok) {
throw new Error("Failed to fetch newsletters");
}
return response.json();
}
export async function createNewsletter(newsletter: NewsletterCreate): Promise<Newsletter> {
const response = await fetch(`${API_BASE_URL}/newsletters`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newsletter),
});
if (!response.ok) {
throw new Error("Failed to create newsletter");
}
return response.json();
}
export async function updateNewsletter(id: number, newsletter: NewsletterUpdate): Promise<Newsletter> {
const response = await fetch(`${API_BASE_URL}/newsletters/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(newsletter),
});
if (!response.ok) {
throw new Error("Failed to update newsletter");
}
return response.json();
}
export async function deleteNewsletter(id: number): Promise<void> {
const response = await fetch(`${API_BASE_URL}/newsletters/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error("Failed to delete newsletter");
}
}
export async function getSettings(): Promise<Settings> {
const response = await fetch(`${API_BASE_URL}/imap/settings`);
if (!response.ok) {
throw new Error("Failed to fetch settings");
}
return response.json();
}
export async function updateSettings(settings: SettingsCreate): Promise<Settings> {
const response = await fetch(`${API_BASE_URL}/imap/settings`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(settings),
});
if (!response.ok) {
throw new Error("Failed to update settings");
}
return response.json();
}
export async function getImapFolders(): Promise<string[]> {
const response = await fetch(`${API_BASE_URL}/imap/folders`);
// If it fails, it's probably because settings are not configured. Return empty array.
if (!response.ok) {
return [];
}
return response.json();
}
export async function testImapConnection(): Promise<{ message: string }> {
const response = await fetch(`${API_BASE_URL}/imap/test`, {
method: 'POST',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || "Failed to test IMAP connection");
}
return response.json();
}
export function getFeedUrl(newsletterId: number): string {
return `${API_BASE_URL}/feeds/${newsletterId}`;
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}