Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
ec3fa9e
refactor(monorepo): extract config ui and i18n into packages
EvanTechDev Mar 27, 2026
de98bd2
fix(ui): remove app aliases from shared ui package
EvanTechDev Mar 27, 2026
dc55f60
fix(ui): declare radix and ui runtime dependencies
EvanTechDev Mar 27, 2026
1d4fb8d
fix(ui): add react-day-picker to workspace deps
EvanTechDev Mar 27, 2026
c7f8312
fix(web): use lucide github export name
EvanTechDev Mar 27, 2026
6e8d06d
chore(deps): bump requested packages and pin lucide 0.x
EvanTechDev Mar 27, 2026
6c971e4
fix(turbo): declare vercel build env variables
EvanTechDev Mar 27, 2026
fb829b5
fix(styles): include ui package as tailwind source
EvanTechDev Mar 27, 2026
7f01492
feat(ds): add decentralized storage server workspace
EvanTechDev Mar 28, 2026
d437811
feat(ds): add web ds config and did-share integration
EvanTechDev Mar 28, 2026
1560f4a
feat(ds): land session-signed request flow for ds api
EvanTechDev Mar 28, 2026
43ecb8e
fix(ds): add pg type declarations
EvanTechDev Mar 28, 2026
3bbbba4
fix(web): restore at-oauth page and build metadata generation
EvanTechDev Mar 28, 2026
d04b368
fix(ds): add local pg module declaration
EvanTechDev Mar 28, 2026
49b9f2e
fix(atproto): remove commented feature block and enable by default
EvanTechDev Mar 28, 2026
17c6726
fix(atproto): restore oauth api handlers from stub redirects
EvanTechDev Mar 28, 2026
81c3b70
fix(atproto): remove stub redirects from session and logout
EvanTechDev Mar 28, 2026
51753e5
fix(atproto): relax login origin check for deployed hosts
EvanTechDev Mar 28, 2026
df82dff
fix(atproto): detect ds on frontend and route blob via ds
EvanTechDev Mar 28, 2026
982ce94
web: prompt for DS config when ATProto has no DS
EvanTechDev Mar 28, 2026
34f1612
fix(web): detect atproto session before clerk finishes loading
EvanTechDev Mar 28, 2026
3af3332
fix(web): split atproto channel from clerk for ds and session checks
EvanTechDev Mar 28, 2026
35e4d3b
fix(web): proxy atproto backup and share through custom ds
EvanTechDev Mar 28, 2026
eefeff2
fix(web): keep atproto session detection independent from clerk
EvanTechDev Mar 28, 2026
cbb7769
fix(ds): return 500 for non-auth blob/share failures
EvanTechDev Mar 28, 2026
19c7039
fix(web): use request origin for atproto oauth base url
EvanTechDev Mar 28, 2026
5eecd56
fix(web): make atproto oauth metadata origin-aware at runtime
EvanTechDev Mar 28, 2026
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
17 changes: 0 additions & 17 deletions .env.example

This file was deleted.

4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

# next.js
/.next/
/apps/web/.next/
/apps/ds/.next/
/out/

# production
Expand Down Expand Up @@ -44,4 +46,4 @@ next-env.d.ts
.turbo

# generated locale map
/lib/locales.ts
/packages/i18n/src/locales.ts
38 changes: 34 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@
> A privacy-first, weekly-focused open-source calendar built for clarity and control.

<picture>
<source media="(prefers-color-scheme: dark)" srcset="/public/Banner-dark.jpg">
<source media="(prefers-color-scheme: light)" srcset="/public/Banner.jpg">
<img src="/public/Banner.jpg" alt="Image">
<source media="(prefers-color-scheme: dark)" srcset="./apps/web/public/Banner-dark.jpg">
<source media="(prefers-color-scheme: light)" srcset="./apps/web/public/Banner.jpg">
<img src="./apps/web/public/Banner.jpg" alt="Image">
</picture>

- [Live Product](https://calendar.xyehr.cn)
Expand Down Expand Up @@ -96,6 +96,34 @@ This project is built for individuals and small teams who value clarity over com

## Getting Started

## Monorepo Structure

This repository uses a standard **Turborepo** layout:

- `apps/web`: Next.js application
- `apps/ds`: decentralized storage server (Next.js API + PostgreSQL)
- `packages/config`: shared ts/postcss/tailwind config
- `packages/ui`: shared shadcn ui components
- `packages/i18n`: locales + i18n runtime helpers
- `turbo.json`: task pipeline and cache settings

Useful commands:

```bash
# run the web app through Turbo
bun run dev

# run decentralized storage server
bun run dev:ds

# build all workspaces
bun run build

# run a task only for web
bun run generate:locales
```


### Prerequisites

Required Versions:
Expand All @@ -115,13 +143,15 @@ bun install

# Start the app
bun run dev
# or run directly in the web workspace
bun run --cwd apps/web dev
```

Then visit `http://localhost:3000`

### Environment Variables

Copy `.env.example` to `.env` and fill in.
Copy `apps/web/.env.example` to `apps/web/.env` and fill in.

Key variables:

Expand Down
93 changes: 0 additions & 93 deletions app/(app)/app/page.tsx

This file was deleted.

5 changes: 0 additions & 5 deletions app/(auth)/at-oauth/page.tsx

This file was deleted.

75 changes: 75 additions & 0 deletions apps/ds/app/api/blob/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { NextResponse } from "next/server";
import { ensureTables, pool } from "@/lib/db";
import { requireSignedRequest } from "@/lib/signature";

function statusForError(error: unknown) {
const message = error instanceof Error ? error.message : "Unknown error";
if (
message.includes("Missing signature headers") ||
message.includes("Expired timestamp") ||
message.includes("Invalid signature") ||
message.includes("Failed to resolve DID") ||
message.includes("Missing DID public key")
) {
return 401;
}
return 500;
}

export async function GET(request: Request) {
try {
await ensureTables();
const { did } = await requireSignedRequest(request);
const result = await pool.query(
"SELECT encrypted_data, iv, timestamp FROM calendar_backups WHERE user_id = $1 LIMIT 1",
[did],
);
return NextResponse.json({ data: result.rows[0] ?? null });
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: statusForError(error) },
);
}
}

export async function POST(request: Request) {
const raw = await request.text();
try {
await ensureTables();
const { did } = await requireSignedRequest(request, raw);
const payload = JSON.parse(raw) as {
encrypted_data: string;
iv: string;
timestamp?: number;
};

await pool.query(
`INSERT INTO calendar_backups (user_id, encrypted_data, iv, timestamp)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE SET encrypted_data = EXCLUDED.encrypted_data, iv = EXCLUDED.iv, timestamp = EXCLUDED.timestamp, updated_at = NOW()`,
[did, payload.encrypted_data, payload.iv, payload.timestamp ?? Date.now()],
);

return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: statusForError(error) },
);
}
}

export async function DELETE(request: Request) {
try {
await ensureTables();
const { did } = await requireSignedRequest(request);
await pool.query("DELETE FROM calendar_backups WHERE user_id = $1", [did]);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json(
{ error: (error as Error).message },
{ status: statusForError(error) },
);
}
}
24 changes: 24 additions & 0 deletions apps/ds/app/api/migrate/cleanup/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { ensureTables, pool } from "@/lib/db";
import { requireSignedRequest } from "@/lib/signature";

export async function POST(request: Request) {
const raw = await request.text();
const client = await pool.connect();
try {
await ensureTables();
const { did } = await requireSignedRequest(request, raw);

await client.query("BEGIN");
await client.query("DELETE FROM shares WHERE user_id = $1", [did]);
await client.query("DELETE FROM calendar_backups WHERE user_id = $1", [did]);
await client.query("COMMIT");

return NextResponse.json({ success: true });
} catch (error) {
await client.query("ROLLBACK");
return NextResponse.json({ error: (error as Error).message }, { status: 401 });
} finally {
client.release();
}
}
19 changes: 19 additions & 0 deletions apps/ds/app/api/migrate/export/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NextResponse } from "next/server";
import { ensureTables, pool } from "@/lib/db";
import { requireSignedRequest } from "@/lib/signature";

export async function POST(request: Request) {
const raw = await request.text();
try {
await ensureTables();
const { did } = await requireSignedRequest(request, raw);
const [backups, shares] = await Promise.all([
pool.query("SELECT encrypted_data, iv, timestamp FROM calendar_backups WHERE user_id = $1", [did]),
pool.query("SELECT share_id, data, timestamp FROM shares WHERE user_id = $1", [did]),
]);

return NextResponse.json({ did, backups: backups.rows, shares: shares.rows });
} catch (error) {
return NextResponse.json({ error: (error as Error).message }, { status: 401 });
}
}
42 changes: 42 additions & 0 deletions apps/ds/app/api/migrate/import/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextResponse } from "next/server";
import { ensureTables, pool } from "@/lib/db";
import { requireSignedRequest } from "@/lib/signature";

export async function POST(request: Request) {
const raw = await request.text();
const client = await pool.connect();
try {
await ensureTables();
const { did } = await requireSignedRequest(request, raw);
const payload = JSON.parse(raw) as {
backups: Array<{ encrypted_data: string; iv: string; timestamp: number }>;
shares: Array<{ share_id: string; data: string; timestamp: number }>;
};

await client.query("BEGIN");
await client.query("DELETE FROM calendar_backups WHERE user_id = $1", [did]);
await client.query("DELETE FROM shares WHERE user_id = $1", [did]);

for (const row of payload.backups || []) {
await client.query(
"INSERT INTO calendar_backups (user_id, encrypted_data, iv, timestamp) VALUES ($1, $2, $3, $4)",
[did, row.encrypted_data, row.iv, row.timestamp],
);
}

for (const row of payload.shares || []) {
await client.query(
"INSERT INTO shares (user_id, share_id, data, timestamp) VALUES ($1, $2, $3, $4)",
[did, row.share_id, row.data, row.timestamp],
);
}

await client.query("COMMIT");
return NextResponse.json({ success: true });
} catch (error) {
await client.query("ROLLBACK");
return NextResponse.json({ error: (error as Error).message }, { status: 401 });
} finally {
client.release();
}
}
15 changes: 15 additions & 0 deletions apps/ds/app/api/share/[shareId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { NextResponse } from "next/server";
import { ensureTables, pool } from "@/lib/db";

export async function GET(_: Request, { params }: { params: Promise<{ shareId: string }> }) {
await ensureTables();
const { shareId } = await params;
const result = await pool.query(
"SELECT user_id, share_id, data, timestamp FROM shares WHERE share_id = $1 LIMIT 1",
[shareId],
);
if (result.rowCount === 0) {
return NextResponse.json({ error: "Not found" }, { status: 404 });
}
return NextResponse.json({ share: result.rows[0] });
}
Loading