Compare commits
4 Commits
42f3521cd7
...
3d815afc82
| Author | SHA1 | Date | |
|---|---|---|---|
| 3d815afc82 | |||
| 10b7397dea | |||
| e4aa1cecf0 | |||
| c0740877b5 |
6
bun.lock
6
bun.lock
@@ -4,6 +4,7 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "ical-pwa",
|
"name": "ical-pwa",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/drizzle-adapter": "^1.10.0",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -22,6 +23,7 @@
|
|||||||
"next-auth": "^5.0.0-beta.29",
|
"next-auth": "^5.0.0-beta.29",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"postgres": "^3.4.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"tailwind-merge": "^3.3.1",
|
"tailwind-merge": "^3.3.1",
|
||||||
@@ -51,6 +53,8 @@
|
|||||||
|
|
||||||
"@auth/core": ["@auth/core@0.40.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw=="],
|
"@auth/core": ["@auth/core@0.40.0", "", { "dependencies": { "@panva/hkdf": "^1.2.1", "jose": "^6.0.6", "oauth4webapi": "^3.3.0", "preact": "10.24.3", "preact-render-to-string": "6.5.11" }, "peerDependencies": { "@simplewebauthn/browser": "^9.0.1", "@simplewebauthn/server": "^9.0.2", "nodemailer": "^6.8.0" }, "optionalPeers": ["@simplewebauthn/browser", "@simplewebauthn/server", "nodemailer"] }, "sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw=="],
|
||||||
|
|
||||||
|
"@auth/drizzle-adapter": ["@auth/drizzle-adapter@1.10.0", "", { "dependencies": { "@auth/core": "0.40.0" } }, "sha512-3MKsdAINTfvV4QKev8PFMNG93HJEUHh9sggDXnmUmriFogRf8qLvgqnPsTlfUyWcLwTzzrrYjeu8CGM+4IxHwQ=="],
|
||||||
|
|
||||||
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
|
||||||
|
|
||||||
"@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
|
"@emnapi/core": ["@emnapi/core@1.4.5", "", { "dependencies": { "@emnapi/wasi-threads": "1.0.4", "tslib": "^2.4.0" } }, "sha512-XsLw1dEOpkSX/WucdqUhPWP7hDxSvZiY+fsUC14h+FtQ2Ifni4znbBt8punRX+Uj2JG/uDb8nEHVKvrVlvdZ5Q=="],
|
||||||
@@ -869,6 +873,8 @@
|
|||||||
|
|
||||||
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
|
||||||
|
|
||||||
|
"postgres": ["postgres@3.4.7", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
|
||||||
|
|
||||||
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
"postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="],
|
||||||
|
|
||||||
"postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
|
"postgres-bytea": ["postgres-bytea@1.0.0", "", {}, "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w=="],
|
||||||
|
|||||||
@@ -17,36 +17,6 @@ services:
|
|||||||
'traefik.http.routers.ical-local.tls.certresolver': 'letsencrypt'
|
'traefik.http.routers.ical-local.tls.certresolver': 'letsencrypt'
|
||||||
'traefik.http.routers.ical-local.service': 'ical-local-service'
|
'traefik.http.routers.ical-local.service': 'ical-local-service'
|
||||||
'traefik.http.services.ical-local-service.loadbalancer.server.port': '3000'
|
'traefik.http.services.ical-local-service.loadbalancer.server.port': '3000'
|
||||||
postgresql:
|
|
||||||
image: docker.io/library/postgres:16-alpine
|
|
||||||
restart: unless-stopped
|
|
||||||
container_name: local-ical-db
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
|
|
||||||
start_period: 20s
|
|
||||||
interval: 30s
|
|
||||||
retries: 5
|
|
||||||
timeout: 5s
|
|
||||||
# ports:
|
|
||||||
# - '5432:5432'
|
|
||||||
volumes:
|
|
||||||
- ical-local-postges:/var/lib/postgresql/data
|
|
||||||
env_file:
|
|
||||||
- .env.db
|
|
||||||
networks:
|
|
||||||
- traefik
|
|
||||||
- internal
|
|
||||||
labels:
|
|
||||||
'traefik.enable': 'true'
|
|
||||||
'traefik.docker.network': 'traefik'
|
|
||||||
'traefik.http.routers.ical-local-db.rule': 'Host(`db.cal.cloud.dmytros.dev`)'
|
|
||||||
'traefik.http.routers.ical-local-db.entrypoints': 'websecure'
|
|
||||||
'traefik.http.routers.ical-local-db.tls.certresolver': 'letsencrypt'
|
|
||||||
'traefik.http.routers.ical-local-db.service': 'ical-local-db-service'
|
|
||||||
'traefik.http.services.ical-local-db-service.loadbalancer.server.port': '5432'
|
|
||||||
volumes:
|
|
||||||
ical-local-postges:
|
|
||||||
driver: local
|
|
||||||
networks:
|
networks:
|
||||||
traefik:
|
traefik:
|
||||||
external: true
|
external: true
|
||||||
|
|||||||
15
drizzle.config.ts
Normal file
15
drizzle.config.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { defineConfig } from 'drizzle-kit';
|
||||||
|
import * as dotenv from 'dotenv';
|
||||||
|
|
||||||
|
dotenv.config({ path: '.env.local' });
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
dialect: 'postgresql',
|
||||||
|
schema: './src/db/schema.ts',
|
||||||
|
out: './drizzle',
|
||||||
|
dbCredentials: {
|
||||||
|
url: process.env.DATABASE_URL!,
|
||||||
|
},
|
||||||
|
verbose: true,
|
||||||
|
strict: true,
|
||||||
|
});
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@auth/drizzle-adapter": "^1.10.0",
|
||||||
"@radix-ui/react-checkbox": "^1.3.3",
|
"@radix-ui/react-checkbox": "^1.3.3",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@@ -27,6 +28,7 @@
|
|||||||
"next-auth": "^5.0.0-beta.29",
|
"next-auth": "^5.0.0-beta.29",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.16.3",
|
"pg": "^8.16.3",
|
||||||
|
"postgres": "^3.4.7",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-dom": "19.1.0",
|
"react-dom": "19.1.0",
|
||||||
"tailwind-merge": "^3.3.1"
|
"tailwind-merge": "^3.3.1"
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Card } from '@/components/ui/card'
|
|||||||
import { RecurrencePicker } from '@/components/recurrence-picker'
|
import { RecurrencePicker } from '@/components/recurrence-picker'
|
||||||
import { IcsFilePicker } from '@/components/ics-file-picker'
|
import { IcsFilePicker } from '@/components/ics-file-picker'
|
||||||
|
|
||||||
import { addEvent, deleteEvent, getAllEvents, clearEvents, getDB } from '@/lib/db'
|
import { saveEvent as addEvent, deleteEvent, getEvents as getAllEvents, clearEvents, updateEvent } from '@/lib/events-db'
|
||||||
import { parseICS, generateICS } from '@/lib/ical'
|
import { parseICS, generateICS } from '@/lib/ical'
|
||||||
import type { CalendarEvent } from '@/lib/types'
|
import type { CalendarEvent } from '@/lib/types'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
@@ -74,11 +74,8 @@ export default function HomePage() {
|
|||||||
lastModified: new Date().toISOString(),
|
lastModified: new Date().toISOString(),
|
||||||
}
|
}
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
const db = await getDB()
|
await updateEvent(eventData)
|
||||||
if (db) {
|
|
||||||
await db.put('events', eventData)
|
|
||||||
setEvents(prev => prev.map(e => (e.id === editingId ? eventData : e)))
|
setEvents(prev => prev.map(e => (e.id === editingId ? eventData : e)))
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
await addEvent(eventData)
|
await addEvent(eventData)
|
||||||
setEvents(prev => [...prev, eventData])
|
setEvents(prev => [...prev, eventData])
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import NextAuth, { NextAuthConfig, NextAuthResult } from "next-auth";
|
import NextAuth, { NextAuthConfig, NextAuthResult } from "next-auth";
|
||||||
import Authentik from "next-auth/providers/authentik";
|
import Authentik from "next-auth/providers/authentik";
|
||||||
import type { Provider } from "next-auth/providers";
|
import type { Provider } from "next-auth/providers";
|
||||||
|
import { DrizzleAdapter } from "@auth/drizzle-adapter";
|
||||||
|
import { db } from "@/db/index";
|
||||||
|
|
||||||
const providers: Provider[] = [
|
const providers: Provider[] = [
|
||||||
Authentik({
|
Authentik({
|
||||||
@@ -20,6 +22,7 @@ export const providerMap = providers.map((provider) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const config = {
|
const config = {
|
||||||
|
adapter: DrizzleAdapter(db),
|
||||||
providers,
|
providers,
|
||||||
pages: {
|
pages: {
|
||||||
signIn: "/signin",
|
signIn: "/signin",
|
||||||
|
|||||||
12
src/db/index.ts
Normal file
12
src/db/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { drizzle } from 'drizzle-orm/postgres-js';
|
||||||
|
import postgres from 'postgres';
|
||||||
|
import * as schema from './schema';
|
||||||
|
|
||||||
|
const connectionString = process.env.DATABASE_URL!;
|
||||||
|
|
||||||
|
const client = postgres(connectionString, {
|
||||||
|
prepare: false,
|
||||||
|
connect_timeout: 30,
|
||||||
|
idle_timeout: 30,
|
||||||
|
});
|
||||||
|
export const db = drizzle(client, { schema });
|
||||||
36
src/db/schema.ts
Normal file
36
src/db/schema.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { pgTable, text, timestamp, uuid } from 'drizzle-orm/pg-core';
|
||||||
|
|
||||||
|
export const users = pgTable('users', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
email: text('email').notNull().unique(),
|
||||||
|
name: text('name'),
|
||||||
|
image: text('image'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const accounts = pgTable('accounts', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
type: text('type').notNull(),
|
||||||
|
provider: text('provider').notNull(),
|
||||||
|
providerAccountId: text('provider_account_id').notNull(),
|
||||||
|
refreshToken: text('refresh_token'),
|
||||||
|
accessToken: text('access_token'),
|
||||||
|
expiresAt: timestamp('expires_at'),
|
||||||
|
tokenType: text('token_type'),
|
||||||
|
scope: text('scope'),
|
||||||
|
idToken: text('id_token'),
|
||||||
|
sessionState: text('session_state'),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const sessions = pgTable('sessions', {
|
||||||
|
id: uuid('id').primaryKey().defaultRandom(),
|
||||||
|
sessionToken: text('session_token').notNull().unique(),
|
||||||
|
userId: uuid('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||||
|
expires: timestamp('expires').notNull(),
|
||||||
|
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||||
|
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||||
|
});
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { openDB, DBSchema, IDBPDatabase } from "idb";
|
|
||||||
import { type CalendarEvent } from "./types";
|
|
||||||
|
|
||||||
interface ICalDB extends DBSchema {
|
|
||||||
events: {
|
|
||||||
key: string;
|
|
||||||
value: CalendarEvent;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let dbPromise: Promise<IDBPDatabase<ICalDB>> | null = null;
|
|
||||||
|
|
||||||
async function initDB() {
|
|
||||||
return openDB<ICalDB>("icalPWA", 1, {
|
|
||||||
upgrade(db) {
|
|
||||||
if (!db.objectStoreNames.contains("events")) {
|
|
||||||
db.createObjectStore("events", { keyPath: "id" });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the database in a browser-safe way
|
|
||||||
export async function getDB() {
|
|
||||||
if (typeof window === "undefined") return null;
|
|
||||||
if (!dbPromise) {
|
|
||||||
dbPromise = initDB();
|
|
||||||
}
|
|
||||||
return dbPromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
// CRUD operations — all SSR-safe
|
|
||||||
export async function getAllEvents() {
|
|
||||||
const db = await getDB();
|
|
||||||
if (!db) return [];
|
|
||||||
return db.getAll("events");
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function addEvent(event: ICalDB["events"]["value"]) {
|
|
||||||
const db = await getDB();
|
|
||||||
if (!db) return;
|
|
||||||
return db.put("events", event);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteEvent(id: string) {
|
|
||||||
const db = await getDB();
|
|
||||||
if (!db) return;
|
|
||||||
return db.delete("events", id);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function clearEvents() {
|
|
||||||
const db = await getDB();
|
|
||||||
if (!db) return;
|
|
||||||
return db.clear("events");
|
|
||||||
}
|
|
||||||
62
src/lib/events-db.ts
Normal file
62
src/lib/events-db.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { openDB, type IDBPDatabase } from 'idb';
|
||||||
|
import type { CalendarEvent } from '@/lib/types';
|
||||||
|
|
||||||
|
const DB_NAME = 'LocalCalEvents';
|
||||||
|
const DB_VERSION = 1;
|
||||||
|
const EVENTS_STORE = 'events';
|
||||||
|
|
||||||
|
let dbPromise: Promise<IDBPDatabase> | null = null;
|
||||||
|
|
||||||
|
function getDB() {
|
||||||
|
if (!dbPromise) {
|
||||||
|
dbPromise = openDB(DB_NAME, DB_VERSION, {
|
||||||
|
upgrade(db) {
|
||||||
|
if (!db.objectStoreNames.contains(EVENTS_STORE)) {
|
||||||
|
const store = db.createObjectStore(EVENTS_STORE, { keyPath: 'id' });
|
||||||
|
store.createIndex('start', 'start');
|
||||||
|
store.createIndex('title', 'title');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return dbPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveEvent(event: CalendarEvent): Promise<void> {
|
||||||
|
const db = await getDB();
|
||||||
|
await db.put(EVENTS_STORE, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEvents(): Promise<CalendarEvent[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.getAll(EVENTS_STORE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEvent(id: string): Promise<CalendarEvent | undefined> {
|
||||||
|
const db = await getDB();
|
||||||
|
return db.get(EVENTS_STORE, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteEvent(id: string): Promise<void> {
|
||||||
|
const db = await getDB();
|
||||||
|
await db.delete(EVENTS_STORE, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateEvent(event: CalendarEvent): Promise<void> {
|
||||||
|
const db = await getDB();
|
||||||
|
await db.put(EVENTS_STORE, event);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getEventsByDateRange(startDate: string, endDate: string): Promise<CalendarEvent[]> {
|
||||||
|
const db = await getDB();
|
||||||
|
const tx = db.transaction(EVENTS_STORE, 'readonly');
|
||||||
|
const index = tx.store.index('start');
|
||||||
|
const events = await index.getAll(IDBKeyRange.bound(startDate, endDate));
|
||||||
|
await tx.done;
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function clearEvents(): Promise<void> {
|
||||||
|
const db = await getDB();
|
||||||
|
await db.clear(EVENTS_STORE);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user