2 Commits

Author SHA1 Message Date
me 283417fe24 fix: configure tmp postgres data directory differently
CI / Frontend CI (pull_request) Successful in 1m6s
ci.yml / Backend CI (pull_request) Has been cancelled
CI / E2E Tests (pull_request) Has been cancelled
CI / Generate Certificates (pull_request) Successful in 27s
2026-05-05 18:25:05 +05:30
ramvignesh-b df56754c55 ci: introduce tmp filespace to prevent postgres persist and migrate to custom artiifact upload
CI / Frontend CI (pull_request) Successful in 1m8s
CI / Backend CI (pull_request) Failing after 48s
CI / Generate Certificates (pull_request) Successful in 26s
CI / E2E Tests (pull_request) Failing after 2m52s
2026-05-05 18:08:09 +05:30
9 changed files with 456 additions and 539 deletions
+57 -52
View File
@@ -26,6 +26,62 @@ jobs:
path: certs/ path: certs/
retention-days: 1 retention-days: 1
backend:
name: Backend CI
runs-on: ubuntu-latest
needs: setup-environment
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: piku_test
POSTGRES_USER: test
POSTGRES_PASSWORD: password123
# WHY?: gitea act runner seems to persist volumes (veryyy stubborn -_-)
# Redirecting PGDATA to /tmp ensures a fresh init every run.
PGDATA: /tmp/pgdata
ports:
- 5442:5432
options: --tmpfs /var/lib/postgresql/data --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
defaults:
run:
working-directory: ./backend
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: "backend/uv.lock"
- name: Restore certificates
uses: christopherHX/gitea-download-artifact@v4
with:
name: ssl-certs
path: certs/
- name: Setup Environment
run: |
cp ../.env.example ../.env
uv sync
if [ "$GITEA_ACTIONS" = "true" ]; then
export DB_HOST="postgres"
export DB_PORT="5432"
else
export DB_HOST="127.0.0.1"
export DB_PORT="5442"
fi
export DB_PASSWORD='password123'
export DB_NAME="piku_test"
export DB_USER="test"
- name: Lint & Test
run: |
uv run ruff check
uv run python manage.py test
frontend: frontend:
name: Frontend CI name: Frontend CI
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -57,57 +113,6 @@ jobs:
- name: Unit Tests - name: Unit Tests
run: bun run test run: bun run test
backend:
name: Backend CI
runs-on: ubuntu-latest
needs: setup-environment
services:
db:
image: postgres:16-alpine
env:
POSTGRES_DB: piku__test
POSTGRES_USER: test
POSTGRES_PASSWORD: password123
ports:
- 5442:5432
options: --tmpfs /var/lib/postgresql/data --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 5
defaults:
run:
working-directory: ./backend
steps:
- uses: actions/checkout@v4
- name: Install uv
uses: astral-sh/setup-uv@v5
with:
enable-cache: true
cache-dependency-glob: "backend/uv.lock"
- name: Restore certificates
uses: christopherHX/gitea-download-artifact@v4
with:
name: ssl-certs
path: certs/
- name: Setup & Test
run: |
cp ../.env.example ../.env
uv sync
export DB_NAME="piku__test"
export DB_USER="test"
export DB_PASSWORD="password123"
if [ "$GITEA_ACTIONS" = "true" ]; then
export DB_HOST="db"
export DB_PORT="5432"
else
export DB_HOST="127.0.0.1"
export DB_PORT="5442"
fi
uv run ruff check
uv run python manage.py test
e2e: e2e:
name: E2E Tests name: E2E Tests
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -133,7 +138,7 @@ jobs:
# Disable cache when not using GitHub Actions because the runner spends ~3mins trying to upload the cache and failing # Disable cache when not using GitHub Actions because the runner spends ~3mins trying to upload the cache and failing
# TODO: setup cache server in Gitea # TODO: setup cache server in Gitea
if: github.server_url == 'https://github.com' if: github.server_url == 'https://github.com'
uses: actions/cache@v4 uses: actions/cache@v3
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/bun.lock') }} key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/bun.lock') }}
+7 -1
View File
@@ -1,5 +1,11 @@
import { lazy, Suspense, useEffect, useRef } from "react"; import { lazy, Suspense, useEffect, useRef } from "react";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import {
BrowserRouter,
Navigate,
Route,
Routes,
ScrollRestoration,
} from "react-router-dom";
import { ProtectedRoute, PublicRoute } from "./components/RouteGuards"; import { ProtectedRoute, PublicRoute } from "./components/RouteGuards";
import SplashScreen from "./components/SplashScreen"; import SplashScreen from "./components/SplashScreen";
import { ROUTES } from "./config/routes"; import { ROUTES } from "./config/routes";
+10 -10
View File
@@ -1,26 +1,26 @@
import { HourglassSimpleMediumIcon } from "@phosphor-icons/react"; import { LockKeyIcon } from "@phosphor-icons/react";
import { useAuth } from "../../hooks/useAuth";
import { Modal } from "../ui/Modal"; import { Modal } from "../ui/Modal";
export function PasskeyModal() { interface PasskeyModalProps {
const { unlock } = useAuth(); onUnlock: (password: string) => Promise<void>;
}
export function PasskeyModal({ onUnlock }: PasskeyModalProps) {
return ( return (
<Modal isOpen={true}> <Modal isOpen={true}>
<HourglassSimpleMediumIcon <LockKeyIcon
size={48} size={48}
className="text-primary mx-auto mb-8 animate-pulse" className="text-primary mx-auto mb-8 animate-pulse"
weight="duotone"
/> />
<h3 className="font-bold text-lg font-display text-primary"> <h3 className="font-bold text-lg font-display text-primary">
You've been away a while. Authentication Required
</h3> </h3>
<p className="py-4 font-sans"> <p className="py-4 font-sans">
Your letters are still there. Just need the key once more. We need your passkey to open your letters
</p> </p>
<div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div> <div className="divider w-1/2 mx-auto text-xs text-neutral-content/30 mt-0"></div>
<p className="text-xs text-neutral-content/30 font-mono italic"> <p className="text-xs text-neutral-content/30 font-mono italic">
Nothing was lost. Your passkey is used to decrypt your data locally.
</p> </p>
<div className="modal-action items-center gap-4"> <div className="modal-action items-center gap-4">
<form <form
@@ -30,7 +30,7 @@ export function PasskeyModal() {
const formData = new FormData(e.currentTarget); const formData = new FormData(e.currentTarget);
const password = formData.get("password") as string; const password = formData.get("password") as string;
if (!password) return; if (!password) return;
await unlock(password); await onUnlock(password);
}} }}
> >
<input <input
-72
View File
@@ -6,7 +6,6 @@ import { mockUser } from "../../test/fixtures/user.fixture";
import { server } from "../../test/mocks/server"; import { server } from "../../test/mocks/server";
import { useAuthStore } from "../store/useAuthStore"; import { useAuthStore } from "../store/useAuthStore";
import { useKeyStore } from "../store/useKeyStore"; import { useKeyStore } from "../store/useKeyStore";
import { CryptoUtils } from "../utils/crypto";
import { import {
clearMasterKey, clearMasterKey,
loadMasterKey, loadMasterKey,
@@ -15,7 +14,6 @@ import {
import { useAuth } from "./useAuth"; import { useAuth } from "./useAuth";
vi.mock("../utils/keystore"); vi.mock("../utils/keystore");
vi.mock("../utils/crypto");
const VITE_API_URL = "http://piku-server"; const VITE_API_URL = "http://piku-server";
@@ -32,11 +30,6 @@ beforeEach(() => {
isInitializing: true, isInitializing: true,
}); });
useKeyStore.setState({ masterKey: null }); useKeyStore.setState({ masterKey: null });
vi.mocked(CryptoUtils.deriveKeyBundle).mockResolvedValue({
masterKey: mockMasterKey,
authHash: "mock-hash",
});
}); });
describe("isAuthenticated", () => { describe("isAuthenticated", () => {
@@ -208,68 +201,3 @@ describe("initialize", () => {
expect(useKeyStore.getState().masterKey).not.toBeNull(); expect(useKeyStore.getState().masterKey).not.toBeNull();
}); });
}); });
describe("unlock", () => {
beforeEach(() => {
useAuthStore.setState({
accessToken: "valid-token",
user: mockUser,
isInitializing: false,
});
});
it("should derive the master key from the user password, validate it via API, and persist it", async () => {
let loginCalled = false;
server.use(
http.post(`${VITE_API_URL}/api/auth/login/`, async () => {
loginCalled = true;
return HttpResponse.json({ access: "token", user: mockUser });
}),
);
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.unlock("password");
});
expect(CryptoUtils.deriveKeyBundle).toHaveBeenCalledWith(
"password",
mockUser.email,
);
expect(loginCalled).toBe(true);
expect(saveMasterKey).toHaveBeenCalledWith(mockMasterKey);
expect(useKeyStore.getState().masterKey).toEqual(mockMasterKey);
});
it("should logout if user is not present", async () => {
useAuthStore.setState({ user: null });
const { result } = renderHook(() => useAuth());
await act(async () => {
await result.current.unlock("password");
});
expect(CryptoUtils.deriveKeyBundle).not.toHaveBeenCalled();
expect(saveMasterKey).not.toHaveBeenCalled();
expect(useAuthStore.getState().accessToken).toBeNull();
expect(clearMasterKey).toHaveBeenCalled();
});
it("should throw an error and not persist the key if validation fails", async () => {
server.use(
http.post(
`${VITE_API_URL}/api/auth/login/`,
() => new HttpResponse(null, { status: 400 }),
),
);
const { result } = renderHook(() => useAuth());
await act(async () => {
await expect(result.current.unlock("wrong-password")).rejects.toThrow();
});
expect(saveMasterKey).not.toHaveBeenCalled();
expect(useKeyStore.getState().masterKey).toBeNull();
});
});
+10 -17
View File
@@ -57,6 +57,7 @@ export const useAuth = () => {
} }
try { try {
// try session refresh
const { data: refreshData } = await publicApi.post(endpoints.REFRESH); const { data: refreshData } = await publicApi.post(endpoints.REFRESH);
const { data: userData } = await api.get(endpoints.ME, { const { data: userData } = await api.get(endpoints.ME, {
headers: { Authorization: `Bearer ${refreshData.access}` }, headers: { Authorization: `Bearer ${refreshData.access}` },
@@ -70,24 +71,16 @@ export const useAuth = () => {
}, [setMasterKey]); }, [setMasterKey]);
const unlock = async (password: string) => { const unlock = async (password: string) => {
if (!user) { if (!user) return;
await logout();
return;
}
const { masterKey, authHash } = await CryptoUtils.deriveKeyBundle( try {
password, const { masterKey } = await CryptoUtils.deriveKeyBundle(
user.email, password,
); user.email,
);
// Validate password by calling login endpoint await saveMasterKey(masterKey);
await api.post(endpoints.LOGIN, { setMasterKey(masterKey);
email: user.email, } catch {}
password: authHash,
});
await saveMasterKey(masterKey);
setMasterKey(masterKey);
}; };
return { return {
+59 -61
View File
@@ -9,75 +9,73 @@ import Drawer from "./Drawer";
vi.mock("../hooks/useLetters"); vi.mock("../hooks/useLetters");
describe("Drawer Page", () => { describe("Drawer Page", () => {
beforeEach(() => { beforeEach(() => {
// Setup authenticated state for the test // Setup authenticated state for the test
useAuthStore.setState({ useAuthStore.setState({
user: mockUser, user: mockUser,
accessToken: "fake-token", accessToken: "fake-token",
isInitializing: false, isInitializing: false,
});
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: false,
isAuthRequired: false,
});
}); });
it("renders the cabinet sections and empty state message", () => { vi.mocked(useLetters).mockReturnValue({
render( drafts: [],
<MemoryRouter> kept: [],
<Drawer /> sent: [],
</MemoryRouter>, vault: [],
); loading: false,
isAuthRequired: false,
});
});
expect(screen.getByText(/Drafts/i)).toBeInTheDocument(); it("renders the cabinet sections and empty state message", () => {
expect(screen.getAllByText(/Kept/i).length).toBeGreaterThanOrEqual(1); render(
expect(screen.getByText(/Vault/i)).toBeInTheDocument(); <MemoryRouter>
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument(); <Drawer />
</MemoryRouter>,
);
expect(screen.getByText(/Drafts/i)).toBeInTheDocument();
expect(screen.getAllByText(/Kept/i).length).toBeGreaterThanOrEqual(1);
expect(screen.getByText(/Vault/i)).toBeInTheDocument();
expect(screen.getByText(/This drawer remains silent/i)).toBeInTheDocument();
});
it("renders the loading state", () => {
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: true,
isAuthRequired: false,
}); });
it("renders the loading state", () => { render(
vi.mocked(useLetters).mockReturnValue({ <MemoryRouter>
drafts: [], <Drawer />
kept: [], </MemoryRouter>,
sent: [], );
vault: [],
loading: true,
isAuthRequired: false,
});
render( expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument();
<MemoryRouter> });
<Drawer />
</MemoryRouter>,
);
expect(screen.getByText(/Opening your cabinet/i)).toBeInTheDocument(); it("renders the authentication required modal when api requires auth", () => {
vi.mocked(useLetters).mockReturnValue({
drafts: [],
kept: [],
sent: [],
vault: [],
loading: false,
isAuthRequired: true,
}); });
it("renders the authentication required modal when api requires auth", () => { render(
vi.mocked(useLetters).mockReturnValue({ <MemoryRouter>
drafts: [], <Drawer />
kept: [], </MemoryRouter>,
sent: [], );
vault: [],
loading: false,
isAuthRequired: true,
});
render( expect(screen.getByText(/Authentication Required/i)).toBeInTheDocument();
<MemoryRouter> expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
<Drawer /> });
</MemoryRouter>,
);
expect(
screen.getByText(/You've been away a while./i),
).toBeInTheDocument();
expect(screen.getByPlaceholderText(/password/i)).toBeInTheDocument();
});
}); });
+2 -2
View File
@@ -15,7 +15,7 @@ import {
} from "../utils/dateFormat.ts"; } from "../utils/dateFormat.ts";
export default function Drawer() { export default function Drawer() {
const { user, logout } = useAuth(); const { user, logout, unlock } = useAuth();
const [openSection, setOpenSection] = useState<string | null>(null); const [openSection, setOpenSection] = useState<string | null>(null);
const navigate = useNavigate(); const navigate = useNavigate();
@@ -30,7 +30,7 @@ export default function Drawer() {
<div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors"> <div className="min-h-screen w-full bg-base-100 text-base-content flex flex-col items-center py-12 px-5 pb-32 font-serif transition-colors">
<div className="fixed inset-0 bg-vig pointer-events-none z-0" /> <div className="fixed inset-0 bg-vig pointer-events-none z-0" />
{isAuthRequired && <PasskeyModal />} {isAuthRequired && <PasskeyModal onUnlock={unlock} />}
<header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500"> <header className="text-center mb-12 z-10 animate-in fade-in slide-in-from-top-4 duration-500">
<Logo /> <Logo />
<div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2"> <div className="font-sans text-xs tracking-widester uppercase text-base-content/40 mt-2">
+311 -312
View File
@@ -1,9 +1,9 @@
import { InfoIcon } from "@phosphor-icons/react"; import { InfoIcon } from "@phosphor-icons/react";
import { ReactLenis } from "lenis/react";
import { import {
motion, motion,
useMotionValueEvent, useMotionValueEvent,
useScroll, useScroll,
useSpring,
useTransform, useTransform,
} from "motion/react"; } from "motion/react";
import { useRef, useState } from "react"; import { useRef, useState } from "react";
@@ -16,9 +16,14 @@ import { formatDate } from "../utils/dateFormat.ts";
export default function Home() { export default function Home() {
const sectionContainer1 = useRef<HTMLDivElement>(null); const sectionContainer1 = useRef<HTMLDivElement>(null);
const { scrollYProgress } = useScroll({ const { scrollYProgress: section1ScrollProgress } = useScroll({
target: sectionContainer1, target: sectionContainer1,
}); });
const smoothProgress1 = useSpring(section1ScrollProgress, {
stiffness: 100,
damping: 30,
restDelta: 0.001,
});
const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true); const [isEnvelopeFlipped, setIsEnvelopeFlipped] = useState(true);
const [flapOpen, setFlapOpen] = useState(false); const [flapOpen, setFlapOpen] = useState(false);
const [recipient, setRecipient] = useState("someone dear"); const [recipient, setRecipient] = useState("someone dear");
@@ -26,7 +31,7 @@ export default function Home() {
const navigate = useNavigate(); const navigate = useNavigate();
useMotionValueEvent(scrollYProgress, "change", (latestScrollValue) => { useMotionValueEvent(section1ScrollProgress, "change", (latestScrollValue) => {
if (latestScrollValue > 0.54) { if (latestScrollValue > 0.54) {
setFlapOpen(false); setFlapOpen(false);
} else { } else {
@@ -50,348 +55,342 @@ export default function Home() {
}); });
return ( return (
<ReactLenis root options={{ lerp: 0.1, duration: 1.5, smoothWheel: true }}> <section
<section ref={sectionContainer1}
ref={sectionContainer1} className="relative w-full h-[850vh] bg-base-100 font-serif"
className="relative w-full h-[850vh] bg-base-100 font-serif" >
> <div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden">
<div className="sticky top-0 h-screen w-full flex flex-col items-center justify-center overflow-hidden"> {/* Intro */}
{/* Intro */} <motion.div
<motion.div className="absolute flex flex-col items-center justify-center pointer-events-none"
className="absolute flex flex-col items-center justify-center pointer-events-none" style={{
style={{ opacity: useTransform(smoothProgress1, [0, 0.12, 1], [1, 0, 0]),
opacity: useTransform(scrollYProgress, [0, 0.12, 1], [1, 0, 0]), scale: useTransform(smoothProgress1, [0, 0.12], [1, 10]),
scale: useTransform(scrollYProgress, [0, 0.12], [1, 10]), }}
}} >
> <h1 className="text-neutral-content/40 text-4xl md:text-6xl text-center px-6">
<h1 className="text-neutral-content/40 text-4xl md:text-6xl text-center px-6"> You've been carrying something
You've been carrying something </h1>
</h1> <h2 className="text-primary text-5xl md:text-7xl font-extralight mt-4 italic font-display animate-pulse">
<h2 className="text-primary text-5xl md:text-7xl font-extralight mt-4 italic font-display animate-pulse"> unsaid
unsaid </h2>
</h2> </motion.div>
</motion.div>
<motion.div
className="absolute text-center"
style={{
opacity: useTransform(smoothProgress1, [0, 0.15, 0.2], [0, 1, 0]),
y: useTransform(smoothProgress1, [0, 0.15, 0.2], [40, 0, -40]),
scale: useTransform(smoothProgress1, [0, 0.15, 0.2], [0.8, 1, 3]),
}}
>
<div className="mt-6 text-4xl md:text-6xl text-base-content/60 italic">
and that's okay...
</div>
</motion.div>
{/* pi. ku. */}
<motion.div
className="absolute text-center px-6"
style={{
opacity: useTransform(
smoothProgress1,
[0.18, 0.25, 0.3],
[0, 1, 0],
),
y: useTransform(smoothProgress1, [0.18, 0.25, 0.3], [20, 0, -20]),
}}
transition={{ delay: 4 }}
>
<Logo scale={2} />
<motion.div <motion.div
className="absolute text-center" className="mt-6 text-4xl md:text-6xl text-base-content/60 "
style={{
opacity: useTransform(scrollYProgress, [0, 0.15, 0.2], [0, 1, 0]),
y: useTransform(scrollYProgress, [0, 0.15, 0.2], [40, 0, -40]),
scale: useTransform(scrollYProgress, [0, 0.15, 0.2], [0.8, 1, 3]),
}}
>
<div className="mt-6 text-4xl md:text-6xl text-base-content/60 italic">
and that's okay...
</div>
</motion.div>
{/* pi. ku. */}
<motion.div
className="absolute text-center px-6"
style={{ style={{
opacity: useTransform( opacity: useTransform(
scrollYProgress, smoothProgress1,
[0.18, 0.25, 0.3], [0.22, 0.25, 0.35, 0.4],
[0, 1, 0], [0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
[0.25, 0.3, 0.35, 0.4],
[20, 0, 0, -20],
), ),
y: useTransform(scrollYProgress, [0.18, 0.25, 0.3], [20, 0, -20]),
}} }}
transition={{ delay: 4 }}
> >
<Logo scale={2} /> is a{" "}
<motion.div <span className="font-display text-primary font-extralight">
className="mt-6 text-4xl md:text-6xl text-base-content/60 " safe space
style={{ </span>
opacity: useTransform( ,<br />
scrollYProgress, <motion.span
[0.22, 0.25, 0.35, 0.4], className="opacity-0 text-3xl md:text-5xl"
[0, 1, 1, 0], transition={{ delay: 3 }}
), whileInView={{ opacity: 1 }}
y: useTransform( viewport={{ once: false, amount: 0.3 }}
scrollYProgress,
[0.25, 0.3, 0.35, 0.4],
[20, 0, 0, -20],
),
}}
> >
is a{" "} where you can
<span className="font-display text-primary font-extralight"> </motion.span>
safe space
</span>
,<br />
<motion.span
className="opacity-0 text-3xl md:text-5xl"
transition={{ delay: 3 }}
whileInView={{ opacity: 1 }}
viewport={{ once: false, amount: 0.3 }}
>
where you can
</motion.span>
</motion.div>
</motion.div> </motion.div>
</motion.div>
<div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20"> <div className="relative w-full max-w-5xl h-1/2 flex items-center justify-center mt-20">
<motion.h2 <motion.h2
style={{
opacity: useTransform(
smoothProgress1,
[0.3, 0.35, 0.4, 0.45],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
[0.3, 0.35, 0.4, 0.45],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
pen down your unsaid words into{" "}
<span className="font-display text-primary font-extralight">
letters
</span>
.
</motion.h2>
{/* Seal */}
<motion.h2
style={{
opacity: useTransform(
smoothProgress1,
[0.45, 0.5, 0.55, 0.6],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
[0.45, 0.5, 0.55, 0.6],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
seal it{" "}
<span className="text-secondary font-display italic font-extralight">
secure
</span>{" "}
and{" "}
<span className="text-secondary font-display font-extralight italic">
private
</span>
.
</motion.h2>
{/* Send / vault */}
<motion.h2
style={{
opacity: useTransform(
smoothProgress1,
[0.6, 0.63, 0.72, 0.75],
[0, 1, 1, 0],
),
y: useTransform(
smoothProgress1,
[0.6, 0.63, 0.72, 0.75],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
send it to{" "}
<motion.span
className="font-display text-accent"
style={{ style={{
opacity: useTransform( color: useTransform(
scrollYProgress, smoothProgress1,
[0.3, 0.35, 0.4, 0.45], [0.67, 1],
[0, 1, 1, 0], ["var(--color-accent)", "var(--color-neutral)"],
),
y: useTransform(
scrollYProgress,
[0.3, 0.35, 0.4, 0.45],
[40, 0, 0, -40],
), ),
}} }}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
> >
pen down your unsaid words into{" "} someone dear
<span className="font-display text-primary font-extralight"> </motion.span>
letters <motion.span
</span>
.
</motion.h2>
{/* Seal */}
<motion.h2
style={{ style={{
opacity: useTransform( opacity: useTransform(smoothProgress1, [0.66, 0.7], [0, 1]),
scrollYProgress,
[0.45, 0.5, 0.55, 0.6],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.45, 0.5, 0.55, 0.6],
[40, 0, 0, -40],
),
}} }}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
> >
seal it{" "}
<span className="text-secondary font-display italic font-extralight">
secure
</span>{" "}
and{" "}
<span className="text-secondary font-display font-extralight italic">
private
</span>
.
</motion.h2>
{/* Send / vault */}
<motion.h2
style={{
opacity: useTransform(
scrollYProgress,
[0.6, 0.63, 0.72, 0.75],
[0, 1, 1, 0],
),
y: useTransform(
scrollYProgress,
[0.6, 0.63, 0.72, 0.75],
[40, 0, 0, -40],
),
}}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
>
send it to{" "}
<motion.span <motion.span
className="font-display text-accent" className="font-display text-accent"
style={{ style={{
color: useTransform( color: useTransform(
scrollYProgress, smoothProgress1,
[0.67, 1], [0.67, 1],
["var(--color-accent)", "var(--color-neutral)"], ["var(--color-accent)", "var(--color-neutral)"],
), ),
}} }}
> >
someone dear {" "}
or{" "}
</motion.span> </motion.span>
<motion.span <span className="font-display text-success">
style={{ yourself in the future
opacity: useTransform(scrollYProgress, [0.66, 0.7], [0, 1]), </span>
}} .
> </motion.span>
<motion.span </motion.h2>
className="font-display text-accent" {/* Burn */}
style={{ <motion.h2
color: useTransform( style={{
scrollYProgress, opacity: useTransform(
[0.67, 1], smoothProgress1,
["var(--color-accent)", "var(--color-neutral)"], [0.75, 0.8, 0.85, 0.9],
), [0, 1, 1, 0],
}} ),
> y: useTransform(
{" "} smoothProgress1,
or{" "} [0.75, 0.8, 0.85, 0.9],
</motion.span> [40, 0, 0, -40],
<span className="font-display text-success"> ),
yourself in the future }}
</span> className="absolute text-4xl md:text-6xl text-center px-10 leading-tight"
. >
</motion.span> and even <span className="font-display text-error">burn it</span> to
</motion.h2> release the burden.
{/* Burn */} </motion.h2>
<motion.h2 {/* Outro */}
style={{ <motion.h2
opacity: useTransform( className={
scrollYProgress, "italic absolute text-4xl md:text-6xl text-center px-10 leading-tight"
[0.75, 0.8, 0.85, 0.9], }
[0, 1, 1, 0], style={{
), opacity: useTransform(smoothProgress1, [0.9, 1], [0, 1]),
y: useTransform( y: useTransform(smoothProgress1, [0.9, 1], [80, 0]),
scrollYProgress, }}
[0.75, 0.8, 0.85, 0.9], >
[40, 0, 0, -40], You've been carrying it long enough.
), </motion.h2>
}} {/* CTA */}
className="absolute text-4xl md:text-6xl text-center px-10 leading-tight" <motion.div
> className={
and even <span className="font-display text-error">burn it</span>{" "} "z-100 absolute -bottom-12 md:bottom-0 font-display flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center"
to release the burden. }
</motion.h2> style={{
{/* Outro */} opacity: useTransform(smoothProgress1, [0.98, 1], [0, 1]),
<motion.h2 y: useTransform(smoothProgress1, [0.98, 1], [80, 0]),
display: useTransform(
smoothProgress1,
[0.96, 1],
["none", "flex"],
),
}}
>
<button
className={ className={
"italic absolute text-4xl md:text-6xl text-center px-10 leading-tight" "md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
} }
style={{ type={"button"}
opacity: useTransform(scrollYProgress, [0.9, 1], [0, 1]), onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
y: useTransform(scrollYProgress, [0.9, 1], [80, 0]),
}}
> >
You've been carrying it long enough. <InfoIcon className={"text-primary"} />
</motion.h2> Tell me More
{/* CTA */} </button>
<motion.div <button
className={ className={
"z-100 absolute -bottom-12 md:bottom-0 font-display flex flex-wrap md:flex-nowrap gap-4 md:gap-12 justify-center" "md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
} }
style={{ type={"button"}
opacity: useTransform(scrollYProgress, [0.98, 1], [0, 1]), onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
y: useTransform(scrollYProgress, [0.98, 1], [80, 0]),
display: useTransform(
scrollYProgress,
[0.96, 1],
["none", "flex"],
),
}}
> >
<button I'm ready
className={ </button>
"md:opacity-50 hover:opacity-100 btn btn-ghost btn-wide md:btn-xl rounded-full font-extralight md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000" </motion.div>
}
type={"button"}
onClick={() => navigate(ROUTES.ABOUT, { replace: true })}
>
<InfoIcon className={"text-primary"} />
Tell me More
</button>
<button
className={
"md:opacity-50 hover:opacity-100 btn rounded-full btn-primary btn-wide md:btn-xl md:grayscale hover:grayscale-0 hover:-translate-y-1 transition-all duration-1000"
}
type={"button"}
onClick={() => navigate(ROUTES.ONBOARD, { replace: true })}
>
I'm ready
</button>
</motion.div>
</div>
<div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
<motion.div
className={"z-21 absolute"}
style={{
opacity: useTransform(
scrollYProgress,
[0.3, 0.4, 0.5, 0.52],
[0, 1, 0.1, 0],
),
y: useTransform(
scrollYProgress,
[0.3, 0.45, 0.5],
[300, 0, 200],
),
scale: useTransform(
scrollYProgress,
[0.3, 0.4, 0.5],
[1, 1, 0.6],
),
}}
>
<div className="mockup-phone w-[75vw] border-primary">
<div className="mockup-phone-camera"></div>
<div className="mockup-phone-display">
<img alt="letter" src="/screenshots/letter.webp" />
</div>
</div>
</motion.div>
{/* Envelope */}
<motion.div
className="absolute scale-50 md:scale-80 z-10"
style={{
opacity: useTransform(
scrollYProgress,
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
[0, 0.6, 1, 1, 0.3, 0],
),
y: useTransform(scrollYProgress, [0.45, 0.5, 1], [600, 200, 0]),
}}
>
<EnvelopeReveal
isInteractive={false}
ignite={ignite}
recipient={recipient}
date={formatDate(new Date().toISOString())}
onRevealComplete={() => {}}
isFlip={isEnvelopeFlipped}
openFlap={flapOpen}
/>
</motion.div>
{/* Saajan */}
<motion.div
className="fixed bottom-0 z-10 font-sans -mb-6 scale-85 md:scale-100 md:mb-0"
style={{
opacity: useTransform(
scrollYProgress,
[0.98, 0.995, 1],
[0, 0.5, 1],
),
y: useTransform(scrollYProgress, [0.98, 1], [50, -10]),
}}
>
<Saajan
message={
"I think we forget things\nif there is nobody to tell them."
}
position={"top"}
/>
</motion.div>
{/* Orb */}
<motion.div
className="w-48 z-100 h-48 rounded-full blur-3xl opacity-20"
transition={{
backgroundColor: { ease: "easeIn", duration: 2 },
}}
style={{
backgroundColor: useTransform(
scrollYProgress,
[0.45, 0.5, 0.7, 0.75, 1],
[
"var(--color-primary)",
"var(--color-secondary)",
"var(--color-accent)",
"var(--color-success)",
"var(--color-error)",
],
),
scale: useTransform(scrollYProgress, [0, 1], [0.6, 2.5]),
}}
/>
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
</div>
</div> </div>
</section>
</ReactLenis> <div className="relative h-1/4 w-full flex flex-col items-center justify-center pointer-events-none">
<motion.div
className={"z-21 absolute"}
style={{
opacity: useTransform(
smoothProgress1,
[0.3, 0.4, 0.5, 0.52],
[0, 1, 0.1, 0],
),
y: useTransform(smoothProgress1, [0.3, 0.45, 0.5], [300, 0, 200]),
scale: useTransform(
smoothProgress1,
[0.3, 0.4, 0.5],
[1, 1, 0.6],
),
}}
>
<div className="mockup-phone w-[75vw] border-primary">
<div className="mockup-phone-camera"></div>
<div className="mockup-phone-display">
<img alt="letter" src="/screenshots/letter.webp" />
</div>
</div>
</motion.div>
{/* Envelope */}
<motion.div
className="absolute scale-50 md:scale-80 z-10"
style={{
opacity: useTransform(
smoothProgress1,
[0.4, 0.45, 0.5, 0.7, 0.9, 1],
[0, 0.6, 1, 1, 0.3, 0],
),
y: useTransform(smoothProgress1, [0.45, 0.5, 1], [600, 200, 0]),
}}
>
<EnvelopeReveal
isInteractive={false}
ignite={ignite}
recipient={recipient}
date={formatDate(new Date().toISOString())}
onRevealComplete={() => {}}
isFlip={isEnvelopeFlipped}
openFlap={flapOpen}
/>
</motion.div>
{/* Saajan */}
<motion.div
className="fixed bottom-0 z-10 font-sans -mb-6 scale-85 md:scale-100 md:mb-0"
style={{
opacity: useTransform(
smoothProgress1,
[0.98, 0.995, 1],
[0, 0.5, 1],
),
y: useTransform(smoothProgress1, [0.98, 1], [50, -10]),
}}
>
<Saajan
message={
"I think we forget things\nif there is nobody to tell them."
}
position={"top"}
/>
</motion.div>
{/* Orb */}
<motion.div
className="w-48 z-100 h-48 rounded-full blur-3xl opacity-20"
transition={{
backgroundColor: { ease: "easeIn", duration: 2 },
}}
style={{
backgroundColor: useTransform(
smoothProgress1,
[0.45, 0.5, 0.7, 0.75, 1],
[
"var(--color-primary)",
"var(--color-secondary)",
"var(--color-accent)",
"var(--color-success)",
"var(--color-error)",
],
),
scale: useTransform(smoothProgress1, [0, 1], [0.6, 2.5]),
}}
/>
<div className="absolute border border-primary/5 w-64 h-64 rounded-full backdrop-blur-[1px]" />
</div>
</div>
</section>
); );
} }
-12
View File
@@ -55,18 +55,6 @@ until $CONTAINER_BIN exec "$DB_NAME" pg_isready -U "${DB_USER:-test}" >/dev/null
done done
export PIKU_ENV_FILE="$ENV_FILE" export PIKU_ENV_FILE="$ENV_FILE"
# NOTE: When running in Gitea Actions (within container), We must ponint DB and mail to the internal docker host instead.
if [ "$GITEA_ACTIONS" = "true" ]; then
sudo apt-get update && sudo apt-get install -y iproute2
# Sample: "default via <internal docker host IP> dev <network interface> proto dhcp src <IP> metric 100"
HOST_IP=$(ip route show default | awk '/default/ {print $3}')
echo "Running on Gitea. Internal Docker host... $HOST_IP"
export DB_HOST=$HOST_IP
export EMAIL_HOST=$HOST_IP
fi
echo "Starting Backend..." echo "Starting Backend..."
mkdir -p ./tmp/logs mkdir -p ./tmp/logs
( (