mirror of
https://github.com/Mail-0/Zero.git
synced 2026-06-28 06:46:15 +00:00
feat: remixify and whatever else needed to be done
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
NEXT_PUBLIC_APP_URL=http://localhost:3000
|
||||
NEXT_PUBLIC_BACKEND_URL=http://localhost:8787
|
||||
VITE_PUBLIC_APP_URL=http://localhost:3000
|
||||
VITE_PUBLIC_BACKEND_URL=http://localhost:8787
|
||||
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/zerodotemail"
|
||||
|
||||
|
||||
24
.github/CONTRIBUTING.md
vendored
24
.github/CONTRIBUTING.md
vendored
@@ -29,13 +29,19 @@ Thank you for your interest in contributing to 0.email! We're excited to have yo
|
||||
- Clone your fork locally: `git clone https://github.com/YOUR-USERNAME/Zero.git`
|
||||
|
||||
2. **Set Up Development Environment**
|
||||
<<<<<<< HEAD
|
||||
- Install [Bun](https://bun.sh)
|
||||
- Clone the repository and install dependencies: `bun install`
|
||||
- Start the database locally: `bun docker:db:up`
|
||||
=======
|
||||
- Install [pnpm](https://pnpm.io)
|
||||
- Clone the repository and install dependencies: `pnpm install`
|
||||
- Start the database locally: `pnpm docker:up`
|
||||
>>>>>>> 7fb24724 (feat: remixify and whatever else needed to be done)
|
||||
- Copy `.env.example` to `.env` in project root
|
||||
- Setup cloudflare with `bun run cf-install`, you will need to run this everytime there is a `.env` change
|
||||
- Setup cloudflare with `pnpm run cf-install`, you will need to run this everytime there is a `.env` change
|
||||
- Set up your Google OAuth credentials (see [README.md](../README.md))
|
||||
- Initialize the database: `bun db:push`
|
||||
- Initialize the database: `pnpm db:push`
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -43,10 +49,14 @@ Thank you for your interest in contributing to 0.email! We're excited to have yo
|
||||
|
||||
```bash
|
||||
# Start database locally
|
||||
<<<<<<< HEAD
|
||||
bun docker:db:up
|
||||
=======
|
||||
pnpm docker:up
|
||||
>>>>>>> 7fb24724 (feat: remixify and whatever else needed to be done)
|
||||
|
||||
# Start the development server
|
||||
bun dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
2. **Create a New Branch**
|
||||
@@ -121,16 +131,16 @@ Zero uses PostgreSQL with Drizzle ORM. Here's how to work with it:
|
||||
|
||||
```bash
|
||||
# Apply schema changes to development database
|
||||
bun db:push
|
||||
pnpm db:push
|
||||
|
||||
# Create migration files after schema changes
|
||||
bun db:generate
|
||||
pnpm db:generate
|
||||
|
||||
# Apply migrations (for production)
|
||||
bun db:migrate
|
||||
pnpm db:migrate
|
||||
|
||||
# View and edit data with Drizzle Studio
|
||||
bun db:studio
|
||||
pnpm db:studio
|
||||
```
|
||||
|
||||
3. **Database Connection**
|
||||
|
||||
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@@ -15,10 +15,10 @@ jobs:
|
||||
- name: Checkout Code 🛎
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun 🌟
|
||||
uses: oven-sh/setup-bun@v1
|
||||
- name: Setup pnpm 🌟
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
bun-version: latest
|
||||
version: latest
|
||||
|
||||
- name: Setup Node 📦
|
||||
uses: actions/setup-node@v4
|
||||
@@ -26,4 +26,4 @@ jobs:
|
||||
node-version: latest
|
||||
|
||||
- name: Install dependencies 📦
|
||||
run: bun install
|
||||
run: pnpm install
|
||||
|
||||
8
.github/workflows/main.yml
vendored
8
.github/workflows/main.yml
vendored
@@ -27,10 +27,10 @@ jobs:
|
||||
- name: Checkout Code 🛎
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Bun 🌟
|
||||
uses: oven-sh/setup-bun@v1
|
||||
- name: Setup pnpm 🌟
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
bun-version: latest
|
||||
version: latest
|
||||
|
||||
- name: Setup Node 📦
|
||||
uses: actions/setup-node@v4
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
node-version: latest
|
||||
|
||||
- name: Install dependencies 📦
|
||||
run: bun install
|
||||
run: pnpm install
|
||||
|
||||
- name: Run Lingo.dev Localization 🌐
|
||||
if: ${{ !inputs.skip_localization }}
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -1,9 +1,7 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/apps/*/node_modules
|
||||
/packages/*/node_modules
|
||||
node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
@@ -53,4 +51,5 @@ apps/mail/scripts.ts
|
||||
.wrangler
|
||||
worker-configuration.d.ts
|
||||
|
||||
.dev.vars.*
|
||||
.dev.vars.*
|
||||
.react-router
|
||||
60
README.md
60
README.md
@@ -45,7 +45,7 @@ Zero is built with modern and reliable technologies:
|
||||
**Required Versions:**
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download) (v18 or higher)
|
||||
- [Bun](https://bun.sh) (v1.2 or higher)
|
||||
- [pnpm](https://pnpm.io) (v10 or higher)
|
||||
- [Docker](https://docs.docker.com/engine/install/) (v20 or higher)
|
||||
|
||||
Before running the application, you'll need to set up services and configure environment variables. For more details on environment variables, see the [Environment Variables](#environment-variables) section.
|
||||
@@ -67,10 +67,10 @@ You can set up Zero in two ways:
|
||||
cd Zero
|
||||
|
||||
# Install dependencies
|
||||
bun install
|
||||
pnpm install
|
||||
|
||||
# Start database locally
|
||||
bun docker:db:up
|
||||
pnpm docker:up
|
||||
```
|
||||
|
||||
2. **Set Up Environment**
|
||||
@@ -80,14 +80,14 @@ You can set up Zero in two ways:
|
||||
cp .env.example .env
|
||||
```
|
||||
- Configure your environment variables (see below)
|
||||
- Setup cloudflare with `bun run cf-install`, you will need to run this everytime there is a `.env` change
|
||||
- Start the database with the provided docker compose setup: `bun docker:db:up`
|
||||
- Initialize the database: `bun db:push`
|
||||
- Setup cloudflare with `pnpm run cf-install`, you will need to run this everytime there is a `.env` change
|
||||
- Start the database with the provided docker compose setup: `pnpm docker:up`
|
||||
- Initialize the database: `pnpm db:push`
|
||||
|
||||
3. **Start the App**
|
||||
|
||||
```bash
|
||||
bun dev
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
4. **Open in Browser**
|
||||
@@ -95,38 +95,6 @@ You can set up Zero in two ways:
|
||||
Visit [http://localhost:3000](http://localhost:3000)
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>Option 2: Dev Container Setup (For VS Code Users)</b></summary>
|
||||
|
||||
This option uses VS Code's Dev Containers feature to provide a fully configured development environment with all dependencies pre-installed. It's great for ensuring everyone on the team has the same setup.
|
||||
|
||||
1. **Prerequisites**
|
||||
|
||||
- [Docker](https://docs.docker.com/get-docker/)
|
||||
- [VS Code](https://code.visualstudio.com/) or compatible editor
|
||||
- [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)
|
||||
|
||||
2. **Open in Dev Container**
|
||||
|
||||
- Clone the repository: `git clone https://github.com/Mail-0/Zero.git`
|
||||
- Open the folder in VS Code
|
||||
- When prompted, click "Reopen in Container" or run the "Dev Containers: Open Folder in Container" command
|
||||
- VS Code will build and start the dev container (this may take a few minutes the first time)
|
||||
|
||||
3. **Access the App**
|
||||
|
||||
- The app will be available at [http://localhost:3000](http://localhost:3000)
|
||||
|
||||
4. **Troubleshooting**
|
||||
- If you encounter issues with the container, try rebuilding it using the "Dev Containers: Rebuild Container" command
|
||||
- For dependency issues inside the container:
|
||||
`bash
|
||||
rm -rf node_modules
|
||||
rm bun.lockb
|
||||
bun install
|
||||
`
|
||||
</details>
|
||||
|
||||
### Environment Setup
|
||||
|
||||
1. **Better Auth Setup**
|
||||
@@ -211,7 +179,7 @@ Zero uses PostgreSQL for storing data. Here's how to set it up:
|
||||
Run this command to start a local PostgreSQL instance:
|
||||
|
||||
```bash
|
||||
bun docker:db:up
|
||||
pnpm docker:up
|
||||
```
|
||||
|
||||
This creates a database with:
|
||||
@@ -223,7 +191,7 @@ Zero uses PostgreSQL for storing data. Here's how to set it up:
|
||||
|
||||
2. **Set Up Database Connection**
|
||||
|
||||
Make sure your database connection string is in `.env` file. And you have ran `bun run cf-install` to sync the latest env.
|
||||
Make sure your database connection string is in `.env` file. And you have ran `pnpm run cf-install` to sync the latest env.
|
||||
|
||||
For local development use:
|
||||
|
||||
@@ -236,26 +204,26 @@ Zero uses PostgreSQL for storing data. Here's how to set it up:
|
||||
- **Set up database tables**:
|
||||
|
||||
```bash
|
||||
bun db:push
|
||||
pnpm db:push
|
||||
```
|
||||
|
||||
- **Create migration files** (after schema changes):
|
||||
|
||||
```bash
|
||||
bun db:generate
|
||||
pnpm db:generate
|
||||
```
|
||||
|
||||
- **Apply migrations**:
|
||||
|
||||
```bash
|
||||
bun db:migrate
|
||||
pnpm db:migrate
|
||||
```
|
||||
|
||||
- **View database content**:
|
||||
```bash
|
||||
bun db:studio
|
||||
pnpm db:studio
|
||||
```
|
||||
> If you run `bun dev` in your terminal, the studio command should be automatically running with the app.
|
||||
> If you run `pnpm dev` in your terminal, the studio command should be automatically running with the app.
|
||||
|
||||
## Contribute
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useEffect } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, type ReactNode, useState, Suspense } from 'react';
|
||||
import type { EnvVarInfo } from '@zero/server/auth-providers';
|
||||
import ErrorMessage from '@/app/(auth)/login/error-message';
|
||||
@@ -7,9 +5,7 @@ import { signIn, useSession } from '@/lib/auth-client';
|
||||
import { Google, Microsoft } from '@/components/icons/icons';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { env } from '@/lib/env';
|
||||
import Image from 'next/image';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface EnvVarStatus {
|
||||
@@ -48,14 +44,14 @@ const getProviderIcon = (providerId: string, className?: string): ReactNode => {
|
||||
case 'zero':
|
||||
return (
|
||||
<>
|
||||
<Image
|
||||
<img
|
||||
src="/white-icon.svg"
|
||||
alt="Zero"
|
||||
width={15}
|
||||
height={15}
|
||||
className="mr-2 hidden dark:block"
|
||||
/>
|
||||
<Image
|
||||
<img
|
||||
src="/black-icon.svg"
|
||||
alt="Zero"
|
||||
width={15}
|
||||
@@ -70,7 +66,7 @@ const getProviderIcon = (providerId: string, className?: string): ReactNode => {
|
||||
};
|
||||
|
||||
function LoginClientContent({ providers, isProd }: LoginClientProps) {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const [expandedProviders, setExpandedProviders] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
@@ -78,7 +74,7 @@ function LoginClientContent({ providers, isProd }: LoginClientProps) {
|
||||
if (missing?.id) {
|
||||
setExpandedProviders({ [missing.id]: true });
|
||||
}
|
||||
}, [providers, router]);
|
||||
}, [providers]);
|
||||
|
||||
const missingRequiredProviders = providers
|
||||
.filter((p) => p.required && !p.enabled)
|
||||
@@ -110,12 +106,12 @@ function LoginClientContent({ providers, isProd }: LoginClientProps) {
|
||||
|
||||
const handleProviderClick = (provider: Provider) => {
|
||||
if (provider.isCustom && provider.customRedirectPath) {
|
||||
router.push(provider.customRedirectPath);
|
||||
navigate(provider.customRedirectPath);
|
||||
} else {
|
||||
toast.promise(
|
||||
signIn.social({
|
||||
provider: provider.id as any,
|
||||
callbackURL: `${env.NEXT_PUBLIC_APP_URL}/mail`,
|
||||
callbackURL: `${window.location.origin}/mail`,
|
||||
}),
|
||||
{
|
||||
error: 'Login redirect failed',
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { authProviders, customProviders, isProviderEnabled } from '@zero/server/auth-providers';
|
||||
import { authProxy } from '@/lib/auth-proxy';
|
||||
import { LoginClient } from './login-client';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
import { env } from '@/lib/env';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import { env } from 'cloudflare:workers';
|
||||
|
||||
export default async function LoginPage() {
|
||||
const headersList = new Headers(Object.fromEntries(await (await headers()).entries()));
|
||||
const session = await authProxy.api.getSession({ headers: headersList });
|
||||
if (session?.connectionId) {
|
||||
redirect('/mail/inbox');
|
||||
}
|
||||
const envNodeEnv = env.NODE_ENV;
|
||||
const isProd = envNodeEnv === 'production';
|
||||
export function loader() {
|
||||
const isProd = !import.meta.env.DEV;
|
||||
|
||||
const authProviderStatus = authProviders(env as unknown as Record<string, string>).map(
|
||||
(provider) => {
|
||||
@@ -51,6 +43,15 @@ export default async function LoginPage() {
|
||||
|
||||
const allProviders = [...customProviderStatus, ...authProviderStatus];
|
||||
|
||||
return {
|
||||
allProviders,
|
||||
isProd,
|
||||
};
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
const { allProviders, isProd } = useLoaderData<typeof loader>();
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen w-full flex-col bg-white dark:bg-black">
|
||||
<LoginClient providers={allProviders} isProd={isProd} />
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Link } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import Link from 'next/link';
|
||||
import { z } from 'zod';
|
||||
|
||||
const formSchema = z.object({
|
||||
@@ -69,7 +67,7 @@ export default function LoginZero() {
|
||||
<div className="flex items-center justify-between">
|
||||
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||
<Link
|
||||
href="/forgot-password"
|
||||
to="/forgot-password"
|
||||
className="text-muted-foreground text-xs hover:text-white"
|
||||
>
|
||||
Forgot your password?
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel } from '@/components/ui/form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { toast } from 'sonner';
|
||||
import Link from 'next/link';
|
||||
import { z } from 'zod';
|
||||
|
||||
const formSchema = z.object({
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { AlertCircle, ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
export function NotFound() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="dark:bg-background flex w-full items-center justify-center bg-white text-center">
|
||||
<div className="flex-col items-center justify-center md:flex dark:text-gray-100">
|
||||
<div className="relative">
|
||||
<h1 className="text-muted-foreground/20 select-none text-[150px] font-bold">404</h1>
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<AlertCircle className="text-muted-foreground h-20 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{t('pages.error.notFound.title')}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">{t('pages.error.notFound.description')}</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => router.back()}
|
||||
className="text-muted-foreground gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('pages.error.notFound.goBack')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Github, Mail, ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Github,
|
||||
Star,
|
||||
@@ -30,8 +28,6 @@ import { useEffect, useState, useMemo } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface Contributor {
|
||||
login: string;
|
||||
@@ -381,16 +377,14 @@ export default function OpenPage() {
|
||||
<div className="flex items-center gap-2">
|
||||
<a href="/">
|
||||
<div className="relative h-8 w-8">
|
||||
<Image
|
||||
<img
|
||||
src="/black-icon.svg"
|
||||
alt="0.email Logo"
|
||||
fill
|
||||
className="object-contain dark:hidden"
|
||||
/>
|
||||
<Image
|
||||
<img
|
||||
src="/white-icon.svg"
|
||||
alt="0.email Logo"
|
||||
fill
|
||||
className="hidden object-contain dark:block"
|
||||
/>
|
||||
</div>
|
||||
@@ -696,24 +690,24 @@ export default function OpenPage() {
|
||||
{specialRoles[member.login.toLowerCase()]?.role || 'Maintainer'}
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Link
|
||||
<a
|
||||
href={`https://github.com/${member.login}`}
|
||||
target="_blank"
|
||||
className="rounded-md p-1 text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-white"
|
||||
>
|
||||
<Github className="h-4 w-4" />
|
||||
</Link>
|
||||
</a>
|
||||
{specialRoles[member.login.toLowerCase()]?.x && (
|
||||
<Link
|
||||
<a
|
||||
href={`https://x.com/${specialRoles[member.login.toLowerCase()]?.x}`}
|
||||
target="_blank"
|
||||
className="rounded-md p-1 text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-white"
|
||||
>
|
||||
<Twitter className="h-4 w-4" />
|
||||
</Link>
|
||||
</a>
|
||||
)}
|
||||
{specialRoles[member.login.toLowerCase()]?.website && (
|
||||
<Link
|
||||
<a
|
||||
href={specialRoles[member.login.toLowerCase()]?.website || '#'}
|
||||
target="_blank"
|
||||
className="rounded-md p-1 text-neutral-600 transition-colors hover:bg-neutral-100 hover:text-neutral-900 dark:text-neutral-400 dark:hover:bg-neutral-800 dark:hover:text-white"
|
||||
@@ -732,7 +726,7 @@ export default function OpenPage() {
|
||||
d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"
|
||||
/>
|
||||
</svg>
|
||||
</Link>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -789,7 +783,7 @@ export default function OpenPage() {
|
||||
<TabsContent value="grid">
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{filteredContributors?.map((contributor, index) => (
|
||||
<Link
|
||||
<a
|
||||
key={contributor.login}
|
||||
href={contributor.html_url}
|
||||
target="_blank"
|
||||
@@ -823,7 +817,7 @@ export default function OpenPage() {
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
@@ -940,7 +934,7 @@ export default function OpenPage() {
|
||||
asChild
|
||||
className="relative overflow-hidden bg-neutral-900 text-white transition-all hover:bg-neutral-800 dark:bg-white dark:text-neutral-900 dark:hover:bg-neutral-100"
|
||||
>
|
||||
<Link
|
||||
<a
|
||||
href={`https://github.com/${REPOSITORY}/blob/main/.github/CONTRIBUTING.md`}
|
||||
target="_blank"
|
||||
>
|
||||
@@ -948,7 +942,7 @@ export default function OpenPage() {
|
||||
<GitGraph className="mr-2 h-4 w-4" />
|
||||
Start Contributing
|
||||
</span>
|
||||
</Link>
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
asChild
|
||||
@@ -1020,22 +1014,22 @@ export default function OpenPage() {
|
||||
</div>
|
||||
|
||||
<div className="mb-6 mt-2 flex items-center justify-center gap-4">
|
||||
<Link
|
||||
<a
|
||||
href="https://discord.gg/BCFr6FFt"
|
||||
target="_blank"
|
||||
className="text-neutral-500 transition-colors hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200"
|
||||
aria-label="Join our Discord"
|
||||
>
|
||||
<Discord className="h-4 w-4" />
|
||||
</Link>
|
||||
<Link
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/zerodotemail"
|
||||
target="_blank"
|
||||
className="text-neutral-500 transition-colors hover:text-neutral-700 dark:text-neutral-400 dark:hover:text-neutral-200"
|
||||
aria-label="Follow us on X (Twitter)"
|
||||
>
|
||||
<Twitter className="h-4 w-4" />
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,4 +1,5 @@
|
||||
'use client';
|
||||
export default function FullWidthLayout({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
export default function FullWidthLayout() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
NavigationMenu,
|
||||
NavigationMenuItem,
|
||||
@@ -11,22 +9,17 @@ import {
|
||||
} from '@/components/ui/navigation-menu';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/components/ui/sheet';
|
||||
import { PixelatedBackground } from '@/components/home/pixelated-bg';
|
||||
import { CircleCheck, CircleX } from '@/components/icons/icons';
|
||||
import PricingCard from '@/components/pricing/pricing-card';
|
||||
import Comparision from '@/components/pricing/comparision';
|
||||
import { TextShimmer } from '@/components/ui/text-shimmer';
|
||||
import { signIn, useSession } from '@/lib/auth-client';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useBilling } from '@/hooks/use-billing';
|
||||
import { Link, useNavigate } from 'react-router';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Footer from '@/components/home/footer';
|
||||
import { useCustomer } from 'autumn-js/next';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Menu } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import { toast } from 'sonner';
|
||||
import Link from 'next/link';
|
||||
|
||||
const resources = [
|
||||
{
|
||||
@@ -74,7 +67,7 @@ const aboutLinks = [
|
||||
];
|
||||
|
||||
export default function PricingPage() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const { data: session } = useSession();
|
||||
|
||||
@@ -92,12 +85,12 @@ export default function PricingPage() {
|
||||
<header className="fixed z-50 hidden w-full items-center justify-center px-4 pt-6 md:flex">
|
||||
<nav className="border-input/50 relative z-50 flex w-full max-w-3xl items-center justify-between gap-2 rounded-xl border-t bg-[#1E1E1E] p-2 px-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<a href="/" className="relative bottom-1 cursor-pointer">
|
||||
<Image src="white-icon.svg" alt="Zero Email" width={22} height={22} />
|
||||
<Link to="/" className="relative bottom-1 cursor-pointer">
|
||||
<img src="white-icon.svg" alt="Zero Email" width={22} height={22} />
|
||||
<span className="text-muted-foreground absolute -right-[-0.5px] text-[10px]">
|
||||
beta
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList className="gap-1">
|
||||
<NavigationMenuItem>
|
||||
@@ -146,13 +139,13 @@ export default function PricingPage() {
|
||||
onClick={() => {
|
||||
if (session) {
|
||||
// User is logged in, redirect to inbox
|
||||
router.push('/mail/inbox');
|
||||
navigate('/mail/inbox');
|
||||
} else {
|
||||
// User is not logged in, show sign-in dialog
|
||||
toast.promise(
|
||||
signIn.social({
|
||||
provider: 'google',
|
||||
callbackURL: `${process.env.NEXT_PUBLIC_APP_URL}/mail`,
|
||||
callbackURL: `${window.location.origin}/mail`,
|
||||
}),
|
||||
{
|
||||
error: 'Login redirect failed',
|
||||
@@ -182,7 +175,7 @@ export default function PricingPage() {
|
||||
<SheetContent side="left" className="w-[300px] bg-[#111111] sm:w-[400px]">
|
||||
<SheetHeader className="flex flex-row items-center justify-between">
|
||||
<SheetTitle>
|
||||
<Image src="white-icon.svg" alt="Zero Email" width={22} height={22} />
|
||||
<img src="white-icon.svg" alt="Zero Email" width={22} height={22} />
|
||||
</SheetTitle>
|
||||
<a href="/login">
|
||||
<Button className="w-full">Sign in</Button>
|
||||
@@ -206,11 +199,11 @@ export default function PricingPage() {
|
||||
{resources.map((resource) => (
|
||||
<Link
|
||||
key={resource.title}
|
||||
href={resource.href}
|
||||
to={resource.href}
|
||||
className="flex items-center gap-2 font-medium"
|
||||
>
|
||||
{resource.platform && (
|
||||
<Image
|
||||
<img
|
||||
src={`/${resource.platform}.svg`}
|
||||
alt={resource.platform}
|
||||
width={20}
|
||||
@@ -1,19 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Github, Mail, ArrowLeft, Link2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Footer from '@/components/home/footer';
|
||||
import { createSectionId } from '@/lib/utils';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import React from 'react';
|
||||
|
||||
const LAST_UPDATED = 'May 16, 2025';
|
||||
|
||||
export default function PrivacyPolicy() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const { copiedValue: copiedSection, copyToClipboard } = useCopyToClipboard();
|
||||
|
||||
const handleCopyLink = (sectionId: string) => {
|
||||
@@ -1,12 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard';
|
||||
import { Card, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Github, ArrowLeft, Link2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import Footer from '@/components/home/footer';
|
||||
import { createSectionId } from '@/lib/utils';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import React from 'react';
|
||||
|
||||
@@ -14,6 +12,7 @@ const LAST_UPDATED = 'February 13, 2025';
|
||||
|
||||
export default function TermsOfService() {
|
||||
const { copiedValue: copiedSection, copyToClipboard } = useCopyToClipboard();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleCopyLink = (sectionId: string) => {
|
||||
const url = `${window.location.origin}${window.location.pathname}#${sectionId}`;
|
||||
@@ -1,18 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function GET() {
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
associatedApplications: [
|
||||
{
|
||||
applicationId: "ecf043a0-41bb-4c89-bd31-7e3f272f8e3c",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function GET() {
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
associatedApplications: [
|
||||
{
|
||||
applicationId: "80b11343-e52c-4969-81e8-faebfed78a67",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { Github, Book, Users, Terminal, Code2, Webhook, ArrowRight, ArrowLeft } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import { useNavigate } from 'react-router';
|
||||
|
||||
const developerResources = [
|
||||
{
|
||||
@@ -70,7 +67,7 @@ const developerResources = [
|
||||
] as const;
|
||||
|
||||
export default function DeveloperPage() {
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="bg-background flex min-h-screen w-full flex-col">
|
||||
@@ -80,7 +77,7 @@ export default function DeveloperPage() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => router.back()}
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-muted-foreground hover:text-foreground mb-6 gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { HotkeyProviderWrapper } from '@/components/providers/hotkey-provider-wrapper';
|
||||
import { CommandPaletteProvider } from '@/components/context/command-palette-context';
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
export default function Layout() {
|
||||
return (
|
||||
<HotkeyProviderWrapper>
|
||||
<CommandPaletteProvider>
|
||||
<div className="relative flex max-h-screen w-full overflow-hidden">{children}</div>
|
||||
<div className="relative flex max-h-screen w-full overflow-hidden">
|
||||
<Outlet />
|
||||
</div>
|
||||
</CommandPaletteProvider>
|
||||
</HotkeyProviderWrapper>
|
||||
);
|
||||
|
||||
@@ -1,32 +1,21 @@
|
||||
import { MailLayout } from '@/components/mail/mail';
|
||||
import { authProxy } from '@/lib/auth-proxy';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
interface MailPageProps {
|
||||
params: Promise<{
|
||||
folder: string;
|
||||
}>;
|
||||
searchParams: Promise<{
|
||||
threadId: string;
|
||||
}>;
|
||||
}
|
||||
import { useLoaderData } from 'react-router';
|
||||
import type { Route } from './+types/page';
|
||||
|
||||
const ALLOWED_FOLDERS = ['inbox', 'draft', 'sent', 'spam', 'bin', 'archive'];
|
||||
|
||||
export default async function MailPage({ params }: MailPageProps) {
|
||||
const headersList = new Headers(Object.fromEntries(await (await headers()).entries()));
|
||||
const session = await authProxy.api.getSession({ headers: headersList });
|
||||
export async function loader({ params, request }: Route.LoaderArgs) {
|
||||
const session = await authProxy.api.getSession({ headers: request.headers });
|
||||
if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
|
||||
|
||||
if (!session?.user.id) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
const { folder } = await params;
|
||||
|
||||
if (!ALLOWED_FOLDERS.includes(folder)) {
|
||||
return <div>Invalid folder</div>;
|
||||
}
|
||||
return {
|
||||
folder: params.folder,
|
||||
};
|
||||
}
|
||||
|
||||
export default function MailPage() {
|
||||
const { folder } = useLoaderData<typeof loader>();
|
||||
if (!ALLOWED_FOLDERS.includes(folder)) return <div>Invalid folder</div>;
|
||||
return <MailLayout />;
|
||||
}
|
||||
|
||||
@@ -7,40 +7,32 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { CreateEmail } from '@/components/create/create-email';
|
||||
import { authProxy } from '@/lib/auth-proxy';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
import { useLoaderData } from 'react-router';
|
||||
import type { Route } from './+types/page';
|
||||
|
||||
// Define the type for search params
|
||||
interface ComposePageProps {
|
||||
searchParams: Promise<{
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const session = await authProxy.api.getSession({ headers: request.headers });
|
||||
if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
|
||||
const url = new URL(request.url);
|
||||
if (url.searchParams.get('to')?.startsWith('mailto:')) {
|
||||
return Response.redirect(
|
||||
`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose/handle-mailto?mailto=${encodeURIComponent(url.searchParams.get('to') ?? '')}`,
|
||||
);
|
||||
}
|
||||
|
||||
return Object.fromEntries(url.searchParams.entries()) as {
|
||||
to?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
draftId?: string;
|
||||
cc?: string;
|
||||
bcc?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export default async function ComposePage({ searchParams }: ComposePageProps) {
|
||||
const headersList = new Headers(Object.fromEntries(await (await headers()).entries()));
|
||||
const session = await authProxy.api.getSession({ headers: headersList });
|
||||
export default function ComposePage() {
|
||||
const params = useLoaderData<typeof loader>();
|
||||
|
||||
if (!session?.user.id) {
|
||||
redirect('/login');
|
||||
}
|
||||
|
||||
// Need to await searchParams in Next.js 15+
|
||||
const params = await searchParams;
|
||||
|
||||
// Check if this is a mailto URL
|
||||
const toParam = params.to || '';
|
||||
if (toParam.startsWith('mailto:')) {
|
||||
// Redirect to our dedicated mailto handler
|
||||
redirect(`/mail/compose/handle-mailto?mailto=${encodeURIComponent(toParam)}`);
|
||||
}
|
||||
|
||||
// Handle normal compose page (direct or with draftId)
|
||||
return (
|
||||
<Dialog open={true}>
|
||||
<DialogTitle></DialogTitle>
|
||||
|
||||
@@ -1,55 +1,47 @@
|
||||
import { CreateEmail } from '@/components/create/create-email';
|
||||
import { authProxy } from '@/lib/auth-proxy';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
import type { Route } from './+types/page';
|
||||
|
||||
// Define the type for search params
|
||||
interface CreatePageProps {
|
||||
searchParams: Promise<{
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const session = await authProxy.api.getSession({ headers: request.headers });
|
||||
if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
|
||||
|
||||
const url = new URL(request.url);
|
||||
const params = Object.fromEntries(url.searchParams.entries()) as {
|
||||
to?: string;
|
||||
subject?: string;
|
||||
body?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function CreatePage({ searchParams }: CreatePageProps) {
|
||||
const headersList = new Headers(Object.fromEntries(await (await headers()).entries()));
|
||||
const session = await authProxy.api.getSession({ headers: headersList });
|
||||
if (!session?.user.id) {
|
||||
redirect('/login');
|
||||
}
|
||||
const params = await searchParams;
|
||||
};
|
||||
const toParam = params.to || 'someone@someone.com';
|
||||
redirect(
|
||||
`/mail/inbox?isComposeOpen=true&to=${encodeURIComponent(toParam)}${params.subject ? `&subject=${encodeURIComponent(params.subject)}` : ''}`,
|
||||
return Response.redirect(
|
||||
`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/inbox?isComposeOpen=true&to=${encodeURIComponent(toParam)}${params.subject ? `&subject=${encodeURIComponent(params.subject)}` : ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function generateMetadata({ searchParams }: CreatePageProps) {
|
||||
// Need to await searchParams in Next.js 15+
|
||||
const params = await searchParams;
|
||||
// export async function generateMetadata({ searchParams }: any) {
|
||||
// // Need to await searchParams in Next.js 15+
|
||||
// const params = await searchParams;
|
||||
|
||||
const toParam = params.to || 'someone';
|
||||
// const toParam = params.to || 'someone';
|
||||
|
||||
// Create common metadata properties
|
||||
const title = `Email ${toParam} on Zero`;
|
||||
const description = 'Zero - The future of email is here';
|
||||
const imageUrl = `/og-api/create?to=${encodeURIComponent(toParam)}${params.subject ? `&subject=${encodeURIComponent(params.subject)}` : ''}`;
|
||||
// // Create common metadata properties
|
||||
// const title = `Email ${toParam} on Zero`;
|
||||
// const description = 'Zero - The future of email is here';
|
||||
// const imageUrl = `/og-api/create?to=${encodeURIComponent(toParam)}${params.subject ? `&subject=${encodeURIComponent(params.subject)}` : ''}`;
|
||||
|
||||
// Create metadata object
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
images: [imageUrl],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
images: [imageUrl],
|
||||
},
|
||||
};
|
||||
}
|
||||
// // Create metadata object
|
||||
// return {
|
||||
// title,
|
||||
// description,
|
||||
// openGraph: {
|
||||
// title,
|
||||
// description,
|
||||
// images: [imageUrl],
|
||||
// },
|
||||
// twitter: {
|
||||
// card: 'summary_large_image',
|
||||
// title,
|
||||
// description,
|
||||
// images: [imageUrl],
|
||||
// },
|
||||
// };
|
||||
// }
|
||||
|
||||
@@ -2,16 +2,25 @@ import { HotkeyProviderWrapper } from '@/components/providers/hotkey-provider-wr
|
||||
import { OnboardingWrapper } from '@/components/onboarding';
|
||||
import { NotificationProvider } from '@/components/party';
|
||||
import { AppSidebar } from '@/components/ui/app-sidebar';
|
||||
import { headers } from 'next/headers';
|
||||
export default async function MailLayout({ children }: { children: React.ReactNode }) {
|
||||
const headersList = await headers();
|
||||
import { Outlet, useLoaderData } from 'react-router';
|
||||
import type { Route } from './+types/layout';
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
return {
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
};
|
||||
}
|
||||
|
||||
export default function MailLayout() {
|
||||
const { headers } = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<HotkeyProviderWrapper>
|
||||
<AppSidebar />
|
||||
<div className="bg-lightBackground dark:bg-darkBackground w-full">{children}</div>
|
||||
<div className="bg-lightBackground dark:bg-darkBackground w-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
<OnboardingWrapper />
|
||||
<NotificationProvider headers={Object.fromEntries(headersList.entries())} />
|
||||
|
||||
<NotificationProvider headers={headers} />
|
||||
</HotkeyProviderWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
5
apps/mail/app/(routes)/mail/page.tsx
Normal file
5
apps/mail/app/(routes)/mail/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
export function loader() {
|
||||
throw redirect(`/mail/inbox`);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { SidebarToggle } from '@/components/ui/sidebar-toggle';
|
||||
import { Construction } from 'lucide-react';
|
||||
import BackButton from './back-button';
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import NotificationsPage from '../notifications/page';
|
||||
import ConnectionsPage from '../connections/page';
|
||||
import AppearancePage from '../appearance/page';
|
||||
import ShortcutsPage from '../shortcuts/page';
|
||||
import SecurityPage from '../security/page';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import GeneralPage from '../general/page';
|
||||
import { useParams } from 'react-router';
|
||||
import LabelsPage from '../labels/page';
|
||||
|
||||
const settingsPages: Record<string, React.ComponentType> = {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -23,7 +21,7 @@ import { useMutation } from '@tanstack/react-query';
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
import { Laptop, Moon, Sun } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useState } from 'react';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,9 +20,8 @@ import { useThreads } from '@/hooks/use-threads';
|
||||
import { emailProviders } from '@/lib/constants';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function ConnectionsPage() {
|
||||
@@ -90,7 +87,7 @@ export default function ConnectionsPage() {
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
{connection.picture ? (
|
||||
<Image
|
||||
<img
|
||||
src={connection.picture}
|
||||
alt=""
|
||||
className="h-12 w-12 shrink-0 rounded-lg object-cover"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -24,8 +22,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
@@ -42,7 +40,7 @@ const formSchema = z.object({
|
||||
function DeleteAccountDialog() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const trpc = useTRPC();
|
||||
const { refetch } = useSession();
|
||||
const { mutateAsync: deleteAccount, isPending } = useMutation(trpc.user.delete.mutationOptions());
|
||||
@@ -63,7 +61,7 @@ function DeleteAccountDialog() {
|
||||
if (!success) return toast.error(message);
|
||||
refetch();
|
||||
toast.success('Account deleted successfully');
|
||||
router.push('/');
|
||||
navigate('/');
|
||||
setIsOpen(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -16,23 +14,23 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { availableLocales, locales, type Locale } from '@/i18n/config';
|
||||
import { useForm, type ControllerRenderProps } from 'react-hook-form';
|
||||
import { userSettingsSchema } from '@zero/db/user_settings_default';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { SettingsCard } from '@/components/settings/settings-card';
|
||||
import { useState, useEffect, useMemo, memo } from 'react';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useTranslations, useLocale } from 'next-intl';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslations, useLocale } from 'use-intl';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { getBrowserTimezone } from '@/lib/timezones';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
import { Globe, Clock, XIcon } from 'lucide-react';
|
||||
import { availableLocales } from '@/i18n/config';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { changeLocale } from '@/i18n/utils';
|
||||
import { useRevalidator } from 'react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import * as z from 'zod';
|
||||
@@ -123,6 +121,10 @@ export default function GeneralPage() {
|
||||
const trpc = useTRPC();
|
||||
const queryClient = useQueryClient();
|
||||
const { mutateAsync: saveUserSettings } = useMutation(trpc.settings.save.mutationOptions());
|
||||
const { mutateAsync: setLocaleCookie } = useMutation(
|
||||
trpc.cookiePreferences.setLocaleCookie.mutationOptions(),
|
||||
);
|
||||
const { revalidate } = useRevalidator();
|
||||
|
||||
const form = useForm<z.infer<typeof userSettingsSchema>>({
|
||||
resolver: zodResolver(userSettingsSchema),
|
||||
@@ -149,13 +151,13 @@ export default function GeneralPage() {
|
||||
if (!updater) return;
|
||||
return { settings: { ...updater.settings, ...values } };
|
||||
});
|
||||
if (values.language !== locale) {
|
||||
await changeLocale(values.language as Locale);
|
||||
const localeName = new Intl.DisplayNames([values.language], { type: 'language' }).of(
|
||||
values.language,
|
||||
);
|
||||
toast.success(t('common.settings.languageChanged', { locale: localeName }));
|
||||
}
|
||||
|
||||
await setLocaleCookie({ locale: values.language });
|
||||
const localeName = new Intl.DisplayNames([values.language], { type: 'language' }).of(
|
||||
values.language,
|
||||
);
|
||||
toast.success(t('common.settings.languageChanged', { locale: localeName! }));
|
||||
await revalidate();
|
||||
|
||||
toast.success(t('common.settings.saved'));
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -32,7 +30,7 @@ import { GMAIL_COLORS } from '@/lib/constants';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Command } from 'lucide-react';
|
||||
import { COLORS } from './colors';
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
'use client';
|
||||
import { SettingsLayoutContent } from '@/components/ui/settings-content';
|
||||
import { Outlet } from 'react-router';
|
||||
|
||||
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
|
||||
return <SettingsLayoutContent>{children}</SettingsLayoutContent>;
|
||||
export default function SettingsLayout() {
|
||||
return (
|
||||
<SettingsLayoutContent>
|
||||
<Outlet />
|
||||
</SettingsLayoutContent>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
|
||||
5
apps/mail/app/(routes)/settings/page.tsx
Normal file
5
apps/mail/app/(routes)/settings/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
export function loader() {
|
||||
throw redirect(`/settings/general`);
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -19,8 +17,8 @@ import { useMutation } from '@tanstack/react-query';
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -12,7 +10,7 @@ import { SettingsCard } from '@/components/settings/settings-card';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { KeyRound } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
@@ -61,12 +59,12 @@ export default function SecurityPage() {
|
||||
>
|
||||
<Form {...form}>
|
||||
<form id="security-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
|
||||
<div className="flex flex-col md:flex-row w-full items-center gap-5">
|
||||
<div className="flex w-full flex-col items-center gap-5 md:flex-row">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="twoFactorAuth"
|
||||
render={({ field }) => (
|
||||
<FormItem className="bg-popover flex flex-row items-center justify-between rounded-lg border p-4 w-full md:w-auto">
|
||||
<FormItem className="bg-popover flex w-full flex-row items-center justify-between rounded-lg border p-4 md:w-auto">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
{t('pages.settings.security.twoFactorAuth')}
|
||||
@@ -85,7 +83,7 @@ export default function SecurityPage() {
|
||||
control={form.control}
|
||||
name="loginNotifications"
|
||||
render={({ field }) => (
|
||||
<FormItem className="bg-popover flex flex-row items-center justify-between rounded-lg border p-4 w-full md:w-auto">
|
||||
<FormItem className="bg-popover flex w-full flex-row items-center justify-between rounded-lg border p-4 md:w-auto">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel className="text-base">
|
||||
{t('pages.settings.security.loginNotifications')}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import type { MessageKey } from '@/config/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'use-intl';
|
||||
|
||||
interface HotkeyRecorderProps {
|
||||
isOpen: boolean;
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { keyboardShortcuts, type Shortcut } from '@/config/shortcuts';
|
||||
import { SettingsCard } from '@/components/settings/settings-card';
|
||||
import { formatDisplayKeys } from '@/lib/hotkeys/use-hotkey-utils';
|
||||
@@ -9,7 +7,7 @@ import type { MessageKey } from '@/config/navigation';
|
||||
import { HotkeyRecorder } from './hotkey-recorder';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function ShortcutsPage() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// 'use client';
|
||||
// // DEPRECATED -
|
||||
//
|
||||
// // DEPRECATED -
|
||||
// import {
|
||||
// Form,
|
||||
// FormControl,
|
||||
@@ -10,7 +10,7 @@
|
||||
// } from '@/components/ui/form';
|
||||
// import { SettingsCard } from '@/components/settings/settings-card';
|
||||
// import { useState, useEffect, useRef } from 'react';
|
||||
// import { useTranslations } from 'next-intl';
|
||||
// import { useTranslations } from 'use-intl';
|
||||
// import { zodResolver } from '@hookform/resolvers/zod';
|
||||
// import { saveUserSettings } from '@/actions/settings';
|
||||
// import { useSettings } from '@/hooks/use-settings';
|
||||
@@ -63,12 +63,12 @@
|
||||
// // Create a temporary div to parse the HTML
|
||||
// const div = document.createElement('div');
|
||||
// div.innerHTML = html;
|
||||
|
||||
|
||||
// // Return as a document with proper structure preserving paragraphs
|
||||
// // This is a basic implementation - for more complex conversions, consider using a
|
||||
// // This is a basic implementation - for more complex conversions, consider using a
|
||||
// // dedicated HTML-to-ProseMirror conversion library
|
||||
// const content: any[] = [];
|
||||
|
||||
|
||||
// // Process each child element to create proper paragraph nodes
|
||||
// Array.from(div.childNodes).forEach(node => {
|
||||
// if (node.nodeType === Node.TEXT_NODE) {
|
||||
@@ -96,12 +96,12 @@
|
||||
// });
|
||||
// }
|
||||
// });
|
||||
|
||||
|
||||
// // If no content was created, create a default empty paragraph
|
||||
// if (content.length === 0) {
|
||||
// content.push({ type: 'paragraph' });
|
||||
// }
|
||||
|
||||
|
||||
// return {
|
||||
// type: 'doc',
|
||||
// content
|
||||
@@ -122,11 +122,11 @@
|
||||
// editorType: settings.signature.editorType || 'plain',
|
||||
// },
|
||||
// });
|
||||
|
||||
|
||||
// // Set the raw HTML in the state
|
||||
// const signatureHtml = settings.signature.content || '--<br><br>Sent via <a href="https://0.email" target="_blank" style="color: #016FFE; text-decoration: none;">0.email</a>';
|
||||
// setSignatureHtml(signatureHtml);
|
||||
|
||||
|
||||
// // Attempt to parse HTML to JSONContent for the rich editor
|
||||
// // This is a simple approach - a more robust solution would use a proper HTML to ProseMirror converter
|
||||
// setEditorContent(tryParseHtmlToContent(signatureHtml));
|
||||
@@ -140,10 +140,10 @@
|
||||
|
||||
// async function onSubmit(values: z.infer<typeof formSchema>) {
|
||||
// setIsSaving(true);
|
||||
|
||||
|
||||
// // Get the content based on editor type
|
||||
// let contentToSave = signatureHtml;
|
||||
|
||||
|
||||
// // Sanitize HTML before saving
|
||||
// const sanitizedHtml = DOMPurify.sanitize(contentToSave, {
|
||||
// ADD_ATTR: ['target'],
|
||||
@@ -185,7 +185,7 @@
|
||||
// // Try to clean any malformed HTML
|
||||
// const cleanedValue = decodeHtmlEntities(newValue);
|
||||
// setSignatureHtml(cleanedValue);
|
||||
|
||||
|
||||
// // Update the form state
|
||||
// form.setValue('signature.content', cleanedValue);
|
||||
// };
|
||||
@@ -193,21 +193,21 @@
|
||||
// const handleEditorChange = (html: string) => {
|
||||
// // Process the HTML coming from the rich editor
|
||||
// setSignatureHtml(html);
|
||||
|
||||
|
||||
// // Update the form state
|
||||
// form.setValue('signature.content', html);
|
||||
// };
|
||||
|
||||
// const watchSignatureEnabled = form.watch('signature.enabled');
|
||||
// const watchEditorType = form.watch('signature.editorType');
|
||||
|
||||
|
||||
// // Function to decode HTML entities
|
||||
// const decodeHtmlEntities = (html: string): string => {
|
||||
// const textarea = document.createElement('textarea');
|
||||
// textarea.innerHTML = html;
|
||||
// return textarea.value;
|
||||
// };
|
||||
|
||||
|
||||
// // Handle switching between editor types
|
||||
// useEffect(() => {
|
||||
// // When switching editor modes
|
||||
@@ -321,7 +321,7 @@
|
||||
// {/* Signature Editor - either plain text or rich editor */}
|
||||
// <div className="space-y-2">
|
||||
// <label className="text-sm font-medium">{t('pages.settings.signatures.signatureContent')}</label>
|
||||
|
||||
|
||||
// {watchEditorType === 'plain' ? (
|
||||
// <div className="mt-1">
|
||||
// <Textarea
|
||||
@@ -335,10 +335,10 @@
|
||||
// {t('pages.settings.signatures.signatureContentHelp') || "You can use HTML to add formatting, links, and images to your signature."}
|
||||
// </p>
|
||||
// <div className="text-xs bg-muted/50 p-2 mt-1 rounded border">
|
||||
// <strong>Note:</strong> HTML tags are supported for formatting.
|
||||
// For security reasons, script tags are not allowed. Common useful tags:
|
||||
// <code className="mx-1 px-1 bg-muted rounded"><a></code> for links,
|
||||
// <code className="mx-1 px-1 bg-muted rounded"><br></code> for line breaks,
|
||||
// <strong>Note:</strong> HTML tags are supported for formatting.
|
||||
// For security reasons, script tags are not allowed. Common useful tags:
|
||||
// <code className="mx-1 px-1 bg-muted rounded"><a></code> for links,
|
||||
// <code className="mx-1 px-1 bg-muted rounded"><br></code> for line breaks,
|
||||
// <code className="mx-1 px-1 bg-muted rounded"><b></code> for bold text.
|
||||
// </div>
|
||||
// </div>
|
||||
@@ -383,4 +383,4 @@
|
||||
// </SettingsCard>
|
||||
// </div>
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
24
apps/mail/app/entry.client.tsx
Normal file
24
apps/mail/app/entry.client.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import '../instrument';
|
||||
|
||||
import { startTransition, StrictMode } from 'react';
|
||||
import { HydratedRouter } from 'react-router/dom';
|
||||
import { hydrateRoot } from 'react-dom/client';
|
||||
import * as Sentry from '@sentry/react';
|
||||
|
||||
startTransition(() => {
|
||||
hydrateRoot(
|
||||
document,
|
||||
<StrictMode>
|
||||
<HydratedRouter />
|
||||
</StrictMode>,
|
||||
{
|
||||
onUncaughtError: Sentry.reactErrorHandler((error, errorInfo) => {
|
||||
console.warn('Uncaught error', error, errorInfo.componentStack);
|
||||
}),
|
||||
// Callback called when React catches an error in an ErrorBoundary.
|
||||
onCaughtError: Sentry.reactErrorHandler(),
|
||||
// Callback called when React automatically recovers from errors.
|
||||
onRecoverableError: Sentry.reactErrorHandler(),
|
||||
},
|
||||
);
|
||||
});
|
||||
43
apps/mail/app/entry.server.tsx
Normal file
43
apps/mail/app/entry.server.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { AppLoadContext, EntryContext } from 'react-router';
|
||||
import { renderToReadableStream } from 'react-dom/server';
|
||||
import { ServerRouter } from 'react-router';
|
||||
import { isbot } from 'isbot';
|
||||
|
||||
export default async function handleRequest(
|
||||
request: Request,
|
||||
responseStatusCode: number,
|
||||
responseHeaders: Headers,
|
||||
routerContext: EntryContext,
|
||||
_loadContext: AppLoadContext,
|
||||
) {
|
||||
let shellRendered = false;
|
||||
const userAgent = request.headers.get('user-agent');
|
||||
|
||||
const body = await renderToReadableStream(
|
||||
<ServerRouter context={routerContext} url={request.url} />,
|
||||
{
|
||||
onError(error: unknown) {
|
||||
responseStatusCode = 500;
|
||||
// Log streaming rendering errors from inside the shell. Don't log
|
||||
// errors encountered during initial shell rendering since they'll
|
||||
// reject and get logged in handleDocumentRequest.
|
||||
if (shellRendered) {
|
||||
console.error(error);
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
shellRendered = true;
|
||||
|
||||
// Ensure requests from bots and SPA Mode renders wait for all content to load before responding
|
||||
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation
|
||||
if ((userAgent && isbot(userAgent)) || routerContext.isSpaMode) {
|
||||
await body.allReady;
|
||||
}
|
||||
|
||||
responseHeaders.set('Content-Type', 'text/html');
|
||||
return new Response(body, {
|
||||
headers: responseHeaders,
|
||||
status: responseStatusCode,
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { Button } from '@/components/ui/button';
|
||||
import { signOut } from '@/lib/auth-client';
|
||||
import * as Sentry from '@sentry/nextjs';
|
||||
import { useEffect } from 'react';
|
||||
import Error from 'next/error';
|
||||
|
||||
export default function ErrorPage({ error, reset }: { error: Error; reset: () => void }) {
|
||||
useEffect(() => {
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
'use client';
|
||||
import NextError from 'next/error';
|
||||
|
||||
export default function GlobalError() {
|
||||
return (
|
||||
<html>
|
||||
<body>
|
||||
{/* `NextError` is the default Next.js error page component. Its type
|
||||
definition requires a `statusCode` prop. However, since the App Router
|
||||
does not expose status codes for errors, we simply pass 0 to render a
|
||||
generic error message. */}
|
||||
<NextError statusCode={0} />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,10 @@
|
||||
@import '@fontsource-variable/geist';
|
||||
@import '@fontsource-variable/geist-mono';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--foreground-rgb: 0, 0, 0;
|
||||
--background-start-rgb: 214, 219, 220;
|
||||
--background-end-rgb: 255, 255, 255;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--foreground-rgb: 255, 255, 255;
|
||||
--background-start-rgb: 0, 0, 0;
|
||||
--background-end-rgb: 0, 0, 0;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
@@ -24,6 +13,8 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--font-geist-sans: 'Geist Variable', sans-serif;
|
||||
--font-geist-mono: 'Geist Mono Variable', monospace;
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
@@ -103,7 +94,7 @@
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background font-sans text-foreground;
|
||||
@apply bg-background text-foreground font-sans;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,9 +103,9 @@
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
*[class^="text-"] {
|
||||
*[class^='text-'] {
|
||||
color: transparent;
|
||||
@apply animate-pulse select-none rounded-md bg-foreground/20;
|
||||
@apply bg-foreground/20 animate-pulse select-none rounded-md;
|
||||
}
|
||||
|
||||
.skeleton-bg {
|
||||
@@ -122,7 +113,7 @@
|
||||
}
|
||||
|
||||
.skeleton-div {
|
||||
@apply animate-pulse bg-foreground/20;
|
||||
@apply bg-foreground/20 animate-pulse;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,8 +157,8 @@
|
||||
@apply bg-blue-200 hover:bg-blue-300 dark:bg-blue-500/40 dark:text-blue-50 dark:hover:bg-blue-400/50;
|
||||
}
|
||||
|
||||
.dark [data-hide-on-theme="dark"],
|
||||
.light [data-hide-on-theme="light"] {
|
||||
.dark [data-hide-on-theme='dark'],
|
||||
.light [data-hide-on-theme='light'] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -202,26 +193,45 @@
|
||||
}
|
||||
|
||||
.compose-loading {
|
||||
background: #016FFE;
|
||||
background: #016ffe;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.compose-gradient-animated {
|
||||
background: linear-gradient(90deg, rgba(255, 213, 208, 1), rgba(219, 255, 228, 1), rgba(226, 214, 255, 1), rgba(255, 213, 208, 1));
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 213, 208, 1),
|
||||
rgba(219, 255, 228, 1),
|
||||
rgba(226, 214, 255, 1),
|
||||
rgba(255, 213, 208, 1)
|
||||
);
|
||||
background-size: 300% 100%;
|
||||
animation: gradient-animation 8s ease infinite;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
box-shadow:
|
||||
0 10px 25px -5px rgba(0, 0, 0, 0.1),
|
||||
0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes gradient-animation {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.compose-gradient-text {
|
||||
background: linear-gradient(90deg, rgba(255, 213, 208, 1) 0%, rgba(219, 255, 228, 1) 50%, rgba(226, 214, 255, 1) 100%);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 213, 208, 1) 0%,
|
||||
rgba(219, 255, 228, 1) 50%,
|
||||
rgba(226, 214, 255, 1) 100%
|
||||
);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
@@ -245,7 +255,7 @@
|
||||
}
|
||||
|
||||
.compose-gradient-text-shiny::after {
|
||||
content: "";
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -270,12 +280,12 @@
|
||||
|
||||
/* Hide scrollbar but keep functionality */
|
||||
.hide-scrollbar {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.hide-scrollbar::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
}
|
||||
|
||||
/* remove outline */
|
||||
@@ -309,7 +319,8 @@
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
import { ClientProviders } from '@/providers/client-providers';
|
||||
import { ServerProviders } from '@/providers/server-providers';
|
||||
import { SpeedInsights } from '@vercel/speed-insights/next';
|
||||
import { Geist, Geist_Mono } from 'next/font/google';
|
||||
import { PublicEnvScript } from 'next-runtime-env';
|
||||
import { siteConfig } from '@/lib/site-config';
|
||||
import type { PropsWithChildren } from 'react';
|
||||
import type { Viewport } from 'next';
|
||||
import { cn } from '@/lib/utils';
|
||||
import Script from 'next/script';
|
||||
import './globals.css';
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin'],
|
||||
});
|
||||
|
||||
export { siteConfig as metadata };
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: '(prefers-color-scheme: light)', color: '#FFFFFF' },
|
||||
{ media: '(prefers-color-scheme: dark)', color: '#1A1A1A' },
|
||||
],
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: PropsWithChildren) {
|
||||
return (
|
||||
<html suppressHydrationWarning>
|
||||
<head>
|
||||
<Script src="https://unpkg.com/web-streams-polyfill/dist/polyfill.js" />
|
||||
<PublicEnvScript />
|
||||
</head>
|
||||
<body className={cn(geistSans.variable, geistMono.variable, 'antialiased')}>
|
||||
<ServerProviders>
|
||||
<ClientProviders>{children}</ClientProviders>
|
||||
</ServerProviders>
|
||||
<SpeedInsights />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { cleanEmailAddresses } from '../lib/email-utils';
|
||||
import { getContext } from 'hono/context-storage';
|
||||
import type { HonoContext } from '../ctx';
|
||||
import { serverTrpc } from '../trpc';
|
||||
import { trpcClient } from '@/providers/query-provider';
|
||||
import type { Route } from './+types/mailto-handler';
|
||||
import { authProxy } from '@/lib/auth-proxy';
|
||||
|
||||
// Function to parse mailto URLs
|
||||
async function parseMailtoUrl(mailtoUrl: string) {
|
||||
@@ -194,7 +194,7 @@ async function createDraftFromMailto(mailtoData: {
|
||||
try {
|
||||
console.log(`Attempt ${attempt} to create draft...`);
|
||||
|
||||
const result = await serverTrpc().drafts.create(draftData);
|
||||
const result = await trpcClient.drafts.create.mutate(draftData);
|
||||
|
||||
if (result?.id) {
|
||||
console.log('Draft created successfully with ID:', result.id);
|
||||
@@ -246,35 +246,39 @@ async function createDraftFromMailto(mailtoData: {
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function mailtoHandler() {
|
||||
const c = getContext<HonoContext>();
|
||||
if (!c.var.session?.user) return c.redirect(`${c.env.NEXT_PUBLIC_APP_URL}/login`);
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const session = await authProxy.api.getSession({ headers: request.headers });
|
||||
if (!session) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/login`);
|
||||
|
||||
const url = new URL(request.url);
|
||||
|
||||
// Get the mailto parameter from the URL
|
||||
const mailto = c.req.query('mailto');
|
||||
const mailto = url.searchParams.get('mailto');
|
||||
|
||||
if (!mailto) return c.redirect(`${c.env.NEXT_PUBLIC_APP_URL}/mail/compose`);
|
||||
if (!mailto) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`);
|
||||
|
||||
// Parse the mailto URL
|
||||
const mailtoData = await parseMailtoUrl(mailto);
|
||||
|
||||
// If parsing failed, redirect to empty compose
|
||||
if (!mailtoData) return c.redirect(`${c.env.NEXT_PUBLIC_APP_URL}/mail/compose`);
|
||||
if (!mailtoData) return Response.redirect(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`);
|
||||
|
||||
// Create a draft from the mailto data
|
||||
const draftId = await createDraftFromMailto(mailtoData);
|
||||
|
||||
// If draft creation failed, redirect to empty compose with the parsed data as a fallback
|
||||
if (!draftId) {
|
||||
const fallbackUrl = new URL(`${c.env.NEXT_PUBLIC_APP_URL}/mail/compose`);
|
||||
const fallbackUrl = new URL(`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose`);
|
||||
if (mailtoData.to) fallbackUrl.searchParams.append('to', mailtoData.to);
|
||||
if (mailtoData.subject) fallbackUrl.searchParams.append('subject', mailtoData.subject);
|
||||
if (mailtoData.body) fallbackUrl.searchParams.append('body', mailtoData.body);
|
||||
if (mailtoData.cc) fallbackUrl.searchParams.append('cc', mailtoData.cc);
|
||||
if (mailtoData.bcc) fallbackUrl.searchParams.append('bcc', mailtoData.bcc);
|
||||
return c.redirect(fallbackUrl.toString());
|
||||
return Response.redirect(fallbackUrl.toString());
|
||||
}
|
||||
|
||||
// Redirect to compose with the draft ID
|
||||
return c.redirect(`${c.env.NEXT_PUBLIC_APP_URL}/mail/compose?draftId=${draftId}`);
|
||||
return Response.redirect(
|
||||
`${import.meta.env.VITE_PUBLIC_APP_URL}/mail/compose?draftId=${draftId}`,
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
import type { MetadataRoute } from 'next';
|
||||
|
||||
export default function manifest(): MetadataRoute.Manifest {
|
||||
return {
|
||||
export function loader() {
|
||||
return Response.json({
|
||||
name: 'Zero',
|
||||
short_name: '0',
|
||||
description: 'Zero - the first open source email app that puts your privacy and safety first.',
|
||||
@@ -15,14 +13,12 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
src: '/icons-pwa/icon-512.png',
|
||||
sizes: '512x512',
|
||||
type: 'image/png',
|
||||
// @ts-expect-error, not sure why error?
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
{
|
||||
src: '/icons-pwa/icon-192.png',
|
||||
sizes: '192x192',
|
||||
type: 'image/png',
|
||||
// @ts-expect-error, not sure why error?
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
{
|
||||
@@ -31,5 +27,5 @@ export default function manifest(): MetadataRoute.Manifest {
|
||||
type: 'image/png',
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function loader() {
|
||||
return Response.json({
|
||||
associatedApplications: [
|
||||
{
|
||||
applicationId: 'ecf043a0-41bb-4c89-bd31-7e3f272f8e3c',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function loader() {
|
||||
return Response.json({
|
||||
associatedApplications: [
|
||||
{
|
||||
applicationId: '80b11343-e52c-4969-81e8-faebfed78a67',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
7
apps/mail/app/meta-files/not-found.ts
Normal file
7
apps/mail/app/meta-files/not-found.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export function loader() {
|
||||
throw new Response('Not Found', { status: 404 });
|
||||
}
|
||||
|
||||
export default function NotFound() {
|
||||
return null;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
import { NotFound } from './(error)/not-found';
|
||||
|
||||
export default NotFound;
|
||||
@@ -1,16 +1,15 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import type { Route } from './+types/create';
|
||||
import { ImageResponse } from 'workers-og';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET(request: Request) {
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
// Get URL parameters
|
||||
const { searchParams } = new URL(request.url);
|
||||
const toParam = searchParams.get('to') || 'someone';
|
||||
const subjectParam = searchParams.get('subject') || '';
|
||||
|
||||
|
||||
// Use the email directly
|
||||
const recipient = toParam;
|
||||
|
||||
|
||||
// Load fonts
|
||||
async function loadGoogleFont(font: string, weight: string) {
|
||||
const url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weight}&display=swap`;
|
||||
@@ -32,7 +31,7 @@ export async function GET(request: Request) {
|
||||
const logoSvg = `<svg width="191" height="191" viewBox="0 0 191 191" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M38.125 190.625V152.5H0V38.125H38.125V0H152.5V38.125H190.625V152.5H152.5V190.625H38.125ZM38.125 114.375H76.25V150.975H152.5V76.25H114.375V114.375H76.25V76.25H114.375V39.65H38.125V114.375Z" fill="white"/>
|
||||
</svg>`;
|
||||
|
||||
|
||||
const logoDataUrl = `data:image/svg+xml;base64,${Buffer.from(logoSvg).toString('base64')}`;
|
||||
|
||||
const fontWeight400 = await loadGoogleFont('Geist', '400');
|
||||
@@ -54,11 +53,10 @@ export async function GET(request: Request) {
|
||||
<span tw="text-[#A1A1A1] ml-3">{recipient}</span>
|
||||
<span tw="text-[#fff]">on Zero</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div tw="text-[36px] text-center text-neutral-400 mt-10" style={{ fontFamily: 'light' }}>
|
||||
{subjectParam
|
||||
{subjectParam
|
||||
? `Subject: ${subjectParam.length > 50 ? subjectParam.substring(0, 47) + '...' : subjectParam}`
|
||||
: 'Compose a new email'}
|
||||
</div>
|
||||
@@ -113,4 +111,4 @@ export async function GET(request: Request) {
|
||||
],
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
import { ImageResponse } from 'next/og';
|
||||
import { env } from '@/lib/env';
|
||||
import { ImageResponse } from 'workers-og';
|
||||
|
||||
export const runtime = 'edge';
|
||||
|
||||
export async function GET() {
|
||||
export async function loader() {
|
||||
async function loadGoogleFont(font: string, weight: string) {
|
||||
const url = `https://fonts.googleapis.com/css2?family=${font}:wght@${weight}&display=swap`;
|
||||
const css = await (await fetch(url)).text();
|
||||
@@ -20,18 +17,10 @@ export async function GET() {
|
||||
}
|
||||
|
||||
try {
|
||||
const appUrl = env.NEXT_PUBLIC_APP_URL;
|
||||
if (!appUrl) {
|
||||
throw new Error('NEXT_PUBLIC_APP_URL is not defined');
|
||||
}
|
||||
|
||||
const mailResponse = await fetch(new URL(`${appUrl}/white-icon.svg`));
|
||||
if (!mailResponse.ok) {
|
||||
throw new Error('Failed to fetch SVG');
|
||||
}
|
||||
|
||||
const mailBuffer = await mailResponse.arrayBuffer();
|
||||
const mailBase64 = btoa(String.fromCharCode(...new Uint8Array(mailBuffer)));
|
||||
const mailBuffer = await fetch(
|
||||
new URL(`${import.meta.env.VITE_PUBLIC_APP_URL}/white-icon.svg`),
|
||||
).then((res) => res.arrayBuffer());
|
||||
const mailBase64 = Buffer.from(mailBuffer).toString('base64');
|
||||
const mail = `data:image/svg+xml;base64,${mailBase64}`;
|
||||
|
||||
const fontWeight400 = await loadGoogleFont('Geist', '400');
|
||||
@@ -1,15 +1,14 @@
|
||||
import HomeContent from '@/components/home/HomeContent';
|
||||
import { authProxy } from '@/lib/auth-proxy';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { headers } from 'next/headers';
|
||||
import type { Route } from './+types/page';
|
||||
import { redirect } from 'react-router';
|
||||
|
||||
export default async function Home() {
|
||||
const headersList = new Headers(Object.fromEntries(await (await headers()).entries()));
|
||||
const session = await authProxy.api.getSession({ headers: headersList });
|
||||
|
||||
if (session?.connectionId) {
|
||||
redirect('/mail/inbox');
|
||||
}
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const session = await authProxy.api.getSession({ headers: request.headers });
|
||||
if (session?.connectionId) throw redirect('/mail/inbox');
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
return <HomeContent />;
|
||||
}
|
||||
|
||||
163
apps/mail/app/root.tsx
Normal file
163
apps/mail/app/root.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import {
|
||||
isRouteErrorResponse,
|
||||
Links,
|
||||
Meta,
|
||||
Outlet,
|
||||
Scripts,
|
||||
ScrollRestoration,
|
||||
useLoaderData,
|
||||
useNavigate,
|
||||
type MetaFunction,
|
||||
} from 'react-router';
|
||||
import { ClientProviders } from '@/providers/client-providers';
|
||||
import { ServerProviders } from '@/providers/server-providers';
|
||||
import { useEffect, type PropsWithChildren } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { siteConfig } from '@/lib/site-config';
|
||||
import { resolveLocale } from '@/i18n/request';
|
||||
import { getMessages } from '@/i18n/request';
|
||||
import { signOut } from '@/lib/auth-client';
|
||||
import type { Route } from './+types/root';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import './globals.css';
|
||||
|
||||
export const meta: MetaFunction = () => {
|
||||
return [
|
||||
{ title: siteConfig.title },
|
||||
{ name: 'description', content: siteConfig.description },
|
||||
{ property: 'og:title', content: siteConfig.title },
|
||||
{ property: 'og:description', content: siteConfig.description },
|
||||
{ property: 'og:image', content: siteConfig.openGraph.images[0].url },
|
||||
{ property: 'og:url', content: siteConfig.alternates.canonical },
|
||||
{ rel: 'manifest', href: '/manifest.webmanifest' },
|
||||
];
|
||||
};
|
||||
|
||||
export async function loader({ request }: Route.LoaderArgs) {
|
||||
const locale = resolveLocale(request);
|
||||
return {
|
||||
locale,
|
||||
messages: await getMessages(locale),
|
||||
};
|
||||
}
|
||||
|
||||
export function Layout({ children }: PropsWithChildren) {
|
||||
const { locale, messages } = useLoaderData<typeof loader>();
|
||||
return (
|
||||
<html lang={locale} suppressHydrationWarning>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta />
|
||||
<Links />
|
||||
</head>
|
||||
<body className="antialiased">
|
||||
<ServerProviders messages={messages} locale={locale}>
|
||||
<ClientProviders>{children}</ClientProviders>
|
||||
</ServerProviders>
|
||||
<ScrollRestoration />
|
||||
<Scripts />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
|
||||
let message = 'Oops!';
|
||||
let details = 'An unexpected error occurred.';
|
||||
let stack: string | undefined;
|
||||
|
||||
if (isRouteErrorResponse(error)) {
|
||||
message = error.status === 404 ? '404' : 'Error';
|
||||
details =
|
||||
error.status === 404 ? 'The requested page could not be found.' : error.statusText || details;
|
||||
if (error.status === 404) {
|
||||
return <NotFound />;
|
||||
}
|
||||
} else if (import.meta.env.DEV && error && error instanceof Error) {
|
||||
details = error.message;
|
||||
stack = error.stack;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.error(error);
|
||||
console.error({ message, details, stack });
|
||||
}, [error, message, details, stack]);
|
||||
|
||||
return (
|
||||
<div className="dark:bg-background flex w-full items-center justify-center bg-white text-center">
|
||||
<div className="flex-col items-center justify-center md:flex dark:text-gray-100">
|
||||
{/* Message */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">Something went wrong!</h2>
|
||||
<p className="text-muted-foreground">See the console for more information.</p>
|
||||
<pre className="text-muted-foreground">{JSON.stringify(error, null, 2)}</pre>
|
||||
</div>
|
||||
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => window.location.reload()}
|
||||
className="text-muted-foreground gap-2"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
await signOut();
|
||||
window.location.href = '/login';
|
||||
}}
|
||||
className="text-muted-foreground gap-2"
|
||||
>
|
||||
Log Out and Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NotFound() {
|
||||
const navigate = useNavigate();
|
||||
const t = useTranslations();
|
||||
|
||||
return (
|
||||
<div className="dark:bg-background flex w-full items-center justify-center bg-white text-center">
|
||||
<div className="flex-col items-center justify-center md:flex dark:text-gray-100">
|
||||
<div className="relative">
|
||||
<h1 className="text-muted-foreground/20 select-none text-[150px] font-bold">404</h1>
|
||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2">
|
||||
<AlertCircle className="text-muted-foreground h-20 w-20" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<div className="space-y-2">
|
||||
<h2 className="text-2xl font-semibold tracking-tight">
|
||||
{t('pages.error.notFound.title')}
|
||||
</h2>
|
||||
<p className="text-muted-foreground">{t('pages.error.notFound.description')}</p>
|
||||
</div>
|
||||
|
||||
{/* Buttons */}
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => navigate(-1)}
|
||||
className="text-muted-foreground gap-2"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
{t('pages.error.notFound.goBack')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
apps/mail/app/routes.ts
Normal file
66
apps/mail/app/routes.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { type RouteConfig, index, layout, prefix, route } from '@react-router/dev/routes';
|
||||
|
||||
export default [
|
||||
index('page.tsx'),
|
||||
route('/home', 'home/page.tsx'),
|
||||
|
||||
route('/manifest.webmanifest', 'meta-files/manifest.ts'),
|
||||
route(
|
||||
'/.well-known/microsoft-identity-association',
|
||||
'meta-files/microsoft-identity-association.ts',
|
||||
),
|
||||
route(
|
||||
'/.well-known/microsoft-identity-association.json',
|
||||
'meta-files/microsoft-identity-association.json.ts',
|
||||
),
|
||||
route('/api/mailto-handler', 'mailto-handler.ts'),
|
||||
route('/og-api/home', 'og-api/home.tsx'),
|
||||
route('/og-api/create', 'og-api/create.tsx'),
|
||||
|
||||
layout('(full-width)/layout.tsx', [
|
||||
route('/about', '(full-width)/about.tsx'),
|
||||
route('/terms', '(full-width)/terms.tsx'),
|
||||
route('/pricing', '(full-width)/pricing.tsx'),
|
||||
route('/privacy', '(full-width)/privacy.tsx'),
|
||||
route('/contributors', '(full-width)/contributors.tsx'),
|
||||
]),
|
||||
|
||||
route('/login', '(auth)/login/page.tsx'),
|
||||
|
||||
// Enable this when we have a zero signup page
|
||||
// route('/zero/signup', '(auth)/zero/signup/page.tsx'),
|
||||
// route('/zero/login', '(auth)/zero/login/page.tsx'),
|
||||
|
||||
layout('(routes)/layout.tsx', [
|
||||
route('/developer', '(routes)/developer/page.tsx'),
|
||||
layout(
|
||||
'(routes)/mail/layout.tsx',
|
||||
prefix('/mail', [
|
||||
index('(routes)/mail/page.tsx'),
|
||||
route('/create', '(routes)/mail/create/page.tsx'),
|
||||
route('/compose', '(routes)/mail/compose/page.tsx'),
|
||||
route('/under-construction/:path', '(routes)/mail/under-construction/[path]/page.tsx'),
|
||||
route('/:folder', '(routes)/mail/[folder]/page.tsx'),
|
||||
]),
|
||||
),
|
||||
layout(
|
||||
'(routes)/settings/layout.tsx',
|
||||
prefix('/settings', [
|
||||
index('(routes)/settings/page.tsx'),
|
||||
route('/appearance', '(routes)/settings/appearance/page.tsx'),
|
||||
route('/connections', '(routes)/settings/connections/page.tsx'),
|
||||
route('/danger-zone', '(routes)/settings/danger-zone/page.tsx'),
|
||||
route('/general', '(routes)/settings/general/page.tsx'),
|
||||
route('/labels', '(routes)/settings/labels/page.tsx'),
|
||||
route('/notifications', '(routes)/settings/notifications/page.tsx'),
|
||||
route('/privacy', '(routes)/settings/privacy/page.tsx'),
|
||||
route('/security', '(routes)/settings/security/page.tsx'),
|
||||
route('/shortcuts', '(routes)/settings/shortcuts/page.tsx'),
|
||||
route('/*', '(routes)/settings/[...settings]/page.tsx'),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
|
||||
// 404 page
|
||||
route('/*', 'meta-files/not-found.ts'),
|
||||
] satisfies RouteConfig;
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip';
|
||||
import { useAISidebar } from './ui/ai-sidebar';
|
||||
import { Button } from './ui/button';
|
||||
import Image from 'next/image';
|
||||
|
||||
// AI Toggle Button Component
|
||||
const AIToggleButton = () => {
|
||||
@@ -24,14 +23,14 @@ const AIToggleButton = () => {
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<Image
|
||||
<img
|
||||
src="/black-icon.svg"
|
||||
alt="AI Assistant"
|
||||
width={22}
|
||||
height={22}
|
||||
className="block dark:hidden"
|
||||
/>
|
||||
<Image
|
||||
<img
|
||||
src="/white-icon.svg"
|
||||
alt="AI Assistant"
|
||||
width={22}
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
import { useBilling } from '@/hooks/use-billing';
|
||||
import { emailProviders } from '@/lib/constants';
|
||||
import { authClient } from '@/lib/auth-client';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Plus, UserPlus } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useLocation } from 'react-router';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { Button } from '../ui/button';
|
||||
import { motion } from 'motion/react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -34,7 +34,7 @@ export const AddConnectionDialog = ({
|
||||
if (!connections?.remaining && !connections?.unlimited) return false;
|
||||
return (connections?.unlimited && !connections?.remaining) || (connections?.remaining ?? 0) > 0;
|
||||
}, [connections]);
|
||||
const pathname = usePathname();
|
||||
const pathname = useLocation().pathname;
|
||||
|
||||
const handleUpgrade = async () => {
|
||||
if (attach) {
|
||||
@@ -53,20 +53,19 @@ export const AddConnectionDialog = ({
|
||||
|
||||
return (
|
||||
<Dialog onOpenChange={onOpenChange}>
|
||||
|
||||
<DialogTrigger asChild>
|
||||
{children || (
|
||||
<Button
|
||||
size={'dropdownItem'}
|
||||
variant={'dropdownItem'}
|
||||
className={cn('w-full justify-start gap-2', className)}
|
||||
>
|
||||
<UserPlus size={16} strokeWidth={2} className="opacity-60" aria-hidden="true" />
|
||||
<p className="text-[13px] opacity-60">{t('pages.settings.connections.addEmail')}</p>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent showOverlay={true}>
|
||||
<DialogTrigger asChild>
|
||||
{children || (
|
||||
<Button
|
||||
size={'dropdownItem'}
|
||||
variant={'dropdownItem'}
|
||||
className={cn('w-full justify-start gap-2', className)}
|
||||
>
|
||||
<UserPlus size={16} strokeWidth={2} className="opacity-60" aria-hidden="true" />
|
||||
<p className="text-[13px] opacity-60">{t('pages.settings.connections.addEmail')}</p>
|
||||
</Button>
|
||||
)}
|
||||
</DialogTrigger>
|
||||
<DialogContent showOverlay={true}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('pages.settings.connections.connectEmail')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
@@ -11,11 +9,14 @@ import {
|
||||
CommandShortcut,
|
||||
} from '@/components/ui/command';
|
||||
import { DialogTitle, DialogDescription } from '@/components/ui/dialog';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import { useRouter, usePathname } from 'next/navigation';
|
||||
import { navigationConfig } from '@/config/navigation';
|
||||
import { useOpenComposeModal } from '@/hooks/use-open-compose-modal';
|
||||
import { navigationConfig, type NavItem } from '@/config/navigation';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { keyboardShortcuts } from '@/config/shortcuts';
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { CircleHelp } from 'lucide-react';
|
||||
import { VisuallyHidden } from 'radix-ui';
|
||||
import { Pencil2 } from '../icons/icons';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import * as React from 'react';
|
||||
@@ -53,8 +54,8 @@ export function useCommandPalette() {
|
||||
export function CommandPalette({ children }: { children: React.ReactNode }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [, setIsComposeOpen] = useQueryState('isComposeOpen');
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
React.useEffect(() => {
|
||||
const down = (e: KeyboardEvent) => {
|
||||
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
|
||||
@@ -167,10 +168,10 @@ export function CommandPalette({ children }: { children: React.ReactNode }) {
|
||||
}}
|
||||
>
|
||||
<CommandDialog open={open} onOpenChange={setOpen}>
|
||||
<VisuallyHidden>
|
||||
<VisuallyHidden.VisuallyHidden>
|
||||
<DialogTitle>{t('common.commandPalette.title')}</DialogTitle>
|
||||
<DialogDescription>{t('common.commandPalette.description')}</DialogDescription>
|
||||
</VisuallyHidden>
|
||||
</VisuallyHidden.VisuallyHidden>
|
||||
<CommandInput autoFocus placeholder={t('common.commandPalette.placeholder')} />
|
||||
<CommandList>
|
||||
<CommandEmpty>{t('common.commandPalette.noResults')}</CommandEmpty>
|
||||
@@ -186,7 +187,7 @@ export function CommandPalette({ children }: { children: React.ReactNode }) {
|
||||
if (item.onClick) {
|
||||
item.onClick();
|
||||
} else if (item.url) {
|
||||
router.push(item.url);
|
||||
navigate(item.url);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import { useTRPC } from '@/providers/query-provider';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { useLabels } from '@/hooks/use-labels';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { Trash } from '../icons/icons';
|
||||
import { Button } from '../ui/button';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
SIDEBAR_COOKIE_MAX_AGE,
|
||||
SIDEBAR_COOKIE_NAME,
|
||||
|
||||
@@ -30,14 +30,14 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { backgroundQueueAtom } from '@/store/backgroundQueue';
|
||||
import { useThread, useThreads } from '@/hooks/use-threads';
|
||||
import { useSearchValue } from '@/hooks/use-search-value';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { ExclamationCircle } from '../icons/icons';
|
||||
import { useLabels } from '@/hooks/use-labels';
|
||||
import { LABELS, FOLDERS } from '@/lib/utils';
|
||||
import { useStats } from '@/hooks/use-stats';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useMail } from '../mail/use-mail';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import { type ReactNode } from 'react';
|
||||
import { useQueryState } from 'nuqs';
|
||||
@@ -122,6 +122,7 @@ export function ThreadContextMenu({
|
||||
refreshCallback,
|
||||
}: EmailContextMenuProps) {
|
||||
const { folder } = useParams<{ folder: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [mail, setMail] = useMail();
|
||||
const [{ refetch, isLoading, isFetching }, threads] = useThreads();
|
||||
const currentFolder = folder ?? '';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
'use client';
|
||||
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '../ui/avatar';
|
||||
import { useRef, useCallback, useEffect } from 'react';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { PricingDialog } from '../ui/pricing-dialog';
|
||||
import { Markdown } from '@react-email/components';
|
||||
import { useAIFullScreen } from '../ui/ai-sidebar';
|
||||
@@ -10,7 +7,6 @@ import { CurvedArrow, Stop } from '../icons/icons';
|
||||
import { useBilling } from '@/hooks/use-billing';
|
||||
import { TextShimmer } from '../ui/text-shimmer';
|
||||
import { useThread } from '@/hooks/use-threads';
|
||||
import { useLabels } from '@/hooks/use-labels';
|
||||
import { MailLabels } from '../mail/mail-list';
|
||||
import { cn, getEmailLogo } from '@/lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
@@ -19,8 +15,6 @@ import { useQueryState } from 'nuqs';
|
||||
import { Input } from '../ui/input';
|
||||
import { useState } from 'react';
|
||||
import VoiceChat from './voice';
|
||||
import Image from 'next/image';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const renderThread = (thread: { id: string; title: string; snippet: string }) => {
|
||||
const [, setThreadId] = useQueryState('threadId');
|
||||
@@ -205,8 +199,8 @@ export function AIChat({
|
||||
) : !messages.length ? (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center">
|
||||
<div className="relative mb-4 h-[44px] w-[44px]">
|
||||
<Image src="/black-icon.svg" alt="Zero Logo" fill className="dark:hidden" />
|
||||
<Image src="/white-icon.svg" alt="Zero Logo" fill className="hidden dark:block" />
|
||||
<img src="/black-icon.svg" alt="Zero Logo" className="dark:hidden" />
|
||||
<img src="/white-icon.svg" alt="Zero Logo" className="hidden dark:block" />
|
||||
</div>
|
||||
<p className="mb-1 mt-2 hidden text-center text-sm font-medium text-black md:block dark:text-white">
|
||||
Ask anything about your emails
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
import React from 'react';
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
'use client';
|
||||
import { Dialog, DialogClose } from '@/components/ui/dialog';
|
||||
import { useEmailAliases } from '@/hooks/use-email-aliases';
|
||||
import { useConnections } from '@/hooks/use-connections';
|
||||
@@ -11,8 +10,8 @@ import { EmailComposer } from './email-composer';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { serializeFiles } from '@/lib/schemas';
|
||||
import { useDraft } from '@/hooks/use-drafts';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { X } from '../icons/icons';
|
||||
import posthog from 'posthog-js';
|
||||
@@ -63,7 +62,7 @@ export function CreateEmail({
|
||||
error: draftError,
|
||||
} = useDraft(draftId ?? propDraftId ?? null);
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const { enableScope, disableScope } = useHotkeysContext();
|
||||
const [isDraftFailed, setIsDraftFailed] = useState(false);
|
||||
const trpc = useTRPC();
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
'use client';
|
||||
import { Popover, PopoverTrigger } from '@radix-ui/react-popover';
|
||||
import { PopoverContent } from '@/components/ui/popover';
|
||||
import { PopoverContent, Popover, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Check, Trash } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
@@ -11,9 +11,8 @@ import {
|
||||
CheckSquare,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { PopoverContent, PopoverTrigger, Popover } from '@/components/ui/popover';
|
||||
import { EditorBubbleItem, useEditor } from 'novel';
|
||||
import { Popover } from '@radix-ui/react-popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { type Editor } from '@tiptap/react';
|
||||
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
@@ -52,8 +50,8 @@ import { TextSelection } from 'prosemirror-state';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { EditorView } from 'prosemirror-view';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { Markdown } from 'tiptap-markdown';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { Slice } from 'prosemirror-model';
|
||||
import { useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
@@ -12,8 +12,8 @@ import { TextEffect } from '@/components/motion-primitives/text-effect';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import useComposeEditor from '@/hooks/use-compose-editor';
|
||||
import { Loader, Check, X as XIcon } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Command, Paperclip, Plus } from 'lucide-react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Avatar, AvatarFallback } from '../ui/avatar';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
|
||||
@@ -15,22 +15,21 @@ import {
|
||||
TiptapImage,
|
||||
TiptapLink,
|
||||
TiptapUnderline,
|
||||
Twitter,
|
||||
UpdatedImage,
|
||||
Youtube,
|
||||
Mathematics
|
||||
} from 'novel'
|
||||
import { UploadImagesPlugin } from 'novel'
|
||||
Mathematics,
|
||||
} from 'novel';
|
||||
import { UploadImagesPlugin } from 'novel';
|
||||
|
||||
import { cx } from 'class-variance-authority'
|
||||
import { common, createLowlight } from 'lowlight'
|
||||
import { common, createLowlight } from 'lowlight';
|
||||
import { cx } from 'class-variance-authority';
|
||||
|
||||
//TODO I am using cx here to get tailwind autocomplete working, idk if someone else can write a regex to just capture the class key in objects
|
||||
const aiHighlight = AIHighlight
|
||||
const aiHighlight = AIHighlight;
|
||||
//You can overwrite the placeholder with your own configuration
|
||||
const placeholder = Placeholder
|
||||
const placeholder = Placeholder;
|
||||
// Custom link extension that exits the link mark when space is typed
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Extension } from '@tiptap/core';
|
||||
|
||||
// Create a separate extension to handle exiting links on space
|
||||
const ExitLinkOnSpace = Extension.create({
|
||||
@@ -41,143 +40,141 @@ const ExitLinkOnSpace = Extension.create({
|
||||
if (editor.isActive('link')) {
|
||||
// Insert a space character first
|
||||
editor.commands.insertContent(' ');
|
||||
|
||||
|
||||
// Then explicitly unset the link mark
|
||||
editor.commands.unsetLink();
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
})
|
||||
});
|
||||
|
||||
// Configure the link extension with standard options
|
||||
const tiptapLink = TiptapLink.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx(
|
||||
'text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer'
|
||||
)
|
||||
'text-muted-foreground underline underline-offset-[3px] hover:text-primary transition-colors cursor-pointer',
|
||||
),
|
||||
},
|
||||
openOnClick: false,
|
||||
autolink: true,
|
||||
linkOnPaste: true,
|
||||
protocols: ['http', 'https', 'mailto', 'tel']
|
||||
})
|
||||
protocols: ['http', 'https', 'mailto', 'tel'],
|
||||
});
|
||||
|
||||
const tiptapImage = TiptapImage.extend({
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
UploadImagesPlugin({
|
||||
imageClass: cx('opacity-40 rounded-lg border border-stone-200')
|
||||
})
|
||||
]
|
||||
}
|
||||
imageClass: cx('opacity-40 rounded-lg border border-stone-200'),
|
||||
}),
|
||||
];
|
||||
},
|
||||
}).configure({
|
||||
allowBase64: true,
|
||||
HTMLAttributes: {
|
||||
class: cx('rounded-lg border border-muted')
|
||||
}
|
||||
})
|
||||
class: cx('rounded-lg border border-muted'),
|
||||
},
|
||||
});
|
||||
|
||||
const updatedImage = UpdatedImage.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx('rounded-lg border border-muted')
|
||||
}
|
||||
})
|
||||
class: cx('rounded-lg border border-muted'),
|
||||
},
|
||||
});
|
||||
|
||||
const taskList = TaskList.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx('not-prose pl-2 ')
|
||||
}
|
||||
})
|
||||
class: cx('not-prose pl-2 '),
|
||||
},
|
||||
});
|
||||
|
||||
const taskItem = TaskItem.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx('flex gap-2 items-start my-4')
|
||||
class: cx('flex gap-2 items-start my-4'),
|
||||
},
|
||||
nested: true
|
||||
})
|
||||
nested: true,
|
||||
});
|
||||
|
||||
const horizontalRule = HorizontalRule.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx('mt-4 mb-6 border-t border-muted-foreground')
|
||||
}
|
||||
})
|
||||
class: cx('mt-4 mb-6 border-t border-muted-foreground'),
|
||||
},
|
||||
});
|
||||
|
||||
const starterKit = StarterKit.configure({
|
||||
bulletList: {
|
||||
HTMLAttributes: {
|
||||
class: cx('list-disc list-outside leading-3 -mt-2')
|
||||
}
|
||||
class: cx('list-disc list-outside leading-3 -mt-2'),
|
||||
},
|
||||
},
|
||||
orderedList: {
|
||||
HTMLAttributes: {
|
||||
class: cx('list-decimal list-outside leading-3 -mt-2')
|
||||
}
|
||||
class: cx('list-decimal list-outside leading-3 -mt-2'),
|
||||
},
|
||||
},
|
||||
listItem: {
|
||||
HTMLAttributes: {
|
||||
class: cx('leading-normal -mb-2')
|
||||
}
|
||||
class: cx('leading-normal -mb-2'),
|
||||
},
|
||||
},
|
||||
blockquote: {
|
||||
HTMLAttributes: {
|
||||
class: cx('border-l-4 border-primary')
|
||||
}
|
||||
class: cx('border-l-4 border-primary'),
|
||||
},
|
||||
},
|
||||
codeBlock: {
|
||||
HTMLAttributes: {
|
||||
class: cx(
|
||||
'rounded-md bg-muted text-muted-foreground border p-5 font-mono font-medium'
|
||||
)
|
||||
}
|
||||
class: cx('rounded-md bg-muted text-muted-foreground border p-5 font-mono font-medium'),
|
||||
},
|
||||
},
|
||||
code: {
|
||||
HTMLAttributes: {
|
||||
class: cx('rounded-md bg-muted px-1.5 py-1 font-mono font-medium'),
|
||||
spellcheck: 'false'
|
||||
}
|
||||
spellcheck: 'false',
|
||||
},
|
||||
},
|
||||
horizontalRule: false,
|
||||
dropcursor: {
|
||||
color: '#DBEAFE',
|
||||
width: 4
|
||||
width: 4,
|
||||
},
|
||||
gapcursor: false
|
||||
})
|
||||
gapcursor: false,
|
||||
});
|
||||
|
||||
const codeBlockLowlight = CodeBlockLowlight.configure({
|
||||
// configure lowlight: common / all / use highlightJS in case there is a need to specify certain language grammars only
|
||||
// common: covers 37 language grammars which should be good enough in most cases
|
||||
lowlight: createLowlight(common)
|
||||
})
|
||||
lowlight: createLowlight(common),
|
||||
});
|
||||
|
||||
const youtube = Youtube.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx('rounded-lg border border-muted')
|
||||
class: cx('rounded-lg border border-muted'),
|
||||
},
|
||||
inline: false
|
||||
})
|
||||
inline: false,
|
||||
});
|
||||
|
||||
const twitter = Twitter.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx('not-prose')
|
||||
},
|
||||
inline: false
|
||||
})
|
||||
// const twitter = Twitter.configure({
|
||||
// HTMLAttributes: {
|
||||
// class: cx('not-prose')
|
||||
// },
|
||||
// inline: false
|
||||
// })
|
||||
|
||||
const mathematics = Mathematics.configure({
|
||||
HTMLAttributes: {
|
||||
class: cx('text-foreground rounded p-1 hover:bg-accent cursor-pointer')
|
||||
class: cx('text-foreground rounded p-1 hover:bg-accent cursor-pointer'),
|
||||
},
|
||||
katexOptions: {
|
||||
throwOnError: false
|
||||
}
|
||||
})
|
||||
throwOnError: false,
|
||||
},
|
||||
});
|
||||
|
||||
const characterCount = CharacterCount.configure()
|
||||
const characterCount = CharacterCount.configure();
|
||||
|
||||
export const defaultExtensions = [
|
||||
starterKit,
|
||||
@@ -192,7 +189,6 @@ export const defaultExtensions = [
|
||||
aiHighlight,
|
||||
codeBlockLowlight,
|
||||
youtube,
|
||||
twitter,
|
||||
mathematics,
|
||||
characterCount,
|
||||
TiptapUnderline,
|
||||
@@ -200,5 +196,5 @@ export const defaultExtensions = [
|
||||
TextStyle,
|
||||
Color,
|
||||
CustomKeymap,
|
||||
GlobalDragHandle
|
||||
]
|
||||
GlobalDragHandle,
|
||||
];
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { PopoverContent, Popover, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Check, Trash } from 'lucide-react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEditor } from 'novel';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { Popover, PopoverTrigger } from '@radix-ui/react-popover';
|
||||
import { PopoverContent } from '@/components/ui/popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export function isValidUrl(url: string) {
|
||||
try {
|
||||
new URL(url);
|
||||
|
||||
@@ -11,10 +11,8 @@ import {
|
||||
TextIcon,
|
||||
TextQuote,
|
||||
} from 'lucide-react';
|
||||
import { PopoverContent, PopoverTrigger, Popover } from '@/components/ui/popover';
|
||||
import { EditorBubbleItem, useEditor } from 'novel';
|
||||
|
||||
import { PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Popover } from '@radix-ui/react-popover';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export type SelectorItem = {
|
||||
|
||||
61
apps/mail/components/create/selectors/text-buttons.tsx
Normal file
61
apps/mail/components/create/selectors/text-buttons.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { MessageSquare, FileText, Edit } from 'lucide-react';
|
||||
import type { SelectorItem } from './node-selector';
|
||||
import { EditorBubbleItem, useEditor } from 'novel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const TextButtons = () => {
|
||||
const { editor } = useEditor();
|
||||
if (!editor) return null;
|
||||
|
||||
// Define AI action handlers
|
||||
const handleChatWithAI = () => {
|
||||
// Get selected text
|
||||
const selection = editor.state.selection;
|
||||
const selectedText = selection.empty
|
||||
? ''
|
||||
: editor.state.doc.textBetween(selection.from, selection.to);
|
||||
|
||||
console.log('Chat with AI about:', selectedText);
|
||||
// Implement chat with AI functionality
|
||||
};
|
||||
|
||||
const items = [
|
||||
{
|
||||
name: 'chat-with-zero',
|
||||
label: 'Chat with Zero',
|
||||
action: handleChatWithAI,
|
||||
useImage: true,
|
||||
imageSrc: '/ai.svg',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex">
|
||||
{items.map((item) => (
|
||||
<EditorBubbleItem
|
||||
key={item.name}
|
||||
onSelect={() => {
|
||||
item.action();
|
||||
}}
|
||||
>
|
||||
<Button size="sm" className="flex items-center gap-1.5 rounded-none px-3" variant="ghost">
|
||||
{
|
||||
item.useImage ? (
|
||||
<img
|
||||
src={item.imageSrc}
|
||||
alt={item.label}
|
||||
width={16}
|
||||
height={16}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
) : null
|
||||
// <item.icon className="h-4 w-4" />
|
||||
}
|
||||
<span className="text-xs">{item.label}</span>
|
||||
</Button>
|
||||
</EditorBubbleItem>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,8 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useImageLoading } from '@/hooks/use-image-loading';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useImageLoading } from '@/hooks/use-image-loading';
|
||||
|
||||
interface SignatureDisplayProps {
|
||||
html: string;
|
||||
@@ -14,18 +12,18 @@ export default function SignatureDisplay({ html, className = '' }: SignatureDisp
|
||||
|
||||
useEffect(() => {
|
||||
if (!iframeRef.current) return;
|
||||
|
||||
|
||||
const iframe = iframeRef.current;
|
||||
|
||||
|
||||
try {
|
||||
const doc = iframe.contentDocument || iframe.contentWindow?.document;
|
||||
if (!doc) return;
|
||||
|
||||
|
||||
const sanitizedHtml = DOMPurify.sanitize(html, {
|
||||
ADD_ATTR: ['target'],
|
||||
FORBID_TAGS: ['script', 'iframe', 'object', 'embed'],
|
||||
});
|
||||
|
||||
|
||||
doc.open();
|
||||
doc.write(`
|
||||
<!DOCTYPE html>
|
||||
@@ -49,7 +47,7 @@ export default function SignatureDisplay({ html, className = '' }: SignatureDisp
|
||||
</html>
|
||||
`);
|
||||
doc.close();
|
||||
|
||||
|
||||
// Use a more efficient approach for height measurement
|
||||
const measureAndSetHeight = () => {
|
||||
if (doc.body) {
|
||||
@@ -59,16 +57,16 @@ export default function SignatureDisplay({ html, className = '' }: SignatureDisp
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Set height after content loads
|
||||
measureAndSetHeight();
|
||||
|
||||
|
||||
// Set up listeners for images that might change the height
|
||||
const cleanup = useImageLoading(doc, measureAndSetHeight);
|
||||
|
||||
|
||||
// Final height check after a short delay
|
||||
const timeoutId = setTimeout(measureAndSetHeight, 100);
|
||||
|
||||
|
||||
return () => {
|
||||
cleanup();
|
||||
clearTimeout(timeoutId);
|
||||
@@ -87,4 +85,4 @@ export default function SignatureDisplay({ html, className = '' }: SignatureDisp
|
||||
sandbox="allow-same-origin"
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileIcon, X } from 'lucide-react';
|
||||
import Image from 'next/image';
|
||||
import React from 'react';
|
||||
|
||||
const getLogo = (mimetype: string): string => {
|
||||
@@ -37,7 +36,7 @@ export const UploadedFileIcon = ({ removeAttachment, index, file }: Props) => {
|
||||
<div className="relative h-24 w-full">
|
||||
{file.type.startsWith('image/') ? (
|
||||
<>
|
||||
<Image src={URL.createObjectURL(file)} alt={file.name} fill className="object-cover" />
|
||||
<img src={URL.createObjectURL(file)} alt={file.name} className="object-cover" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
@@ -50,7 +49,7 @@ export const UploadedFileIcon = ({ removeAttachment, index, file }: Props) => {
|
||||
) : (
|
||||
<div className="bg-muted/20 flex h-full w-full items-center justify-center">
|
||||
{getLogo(file.type) && (
|
||||
<Image src={getLogo(file.type)} alt={file.name} width={80} height={80} />
|
||||
<img src={getLogo(file.type)} alt={file.name} width={80} height={80} />
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
|
||||
// ElevenLabs
|
||||
@@ -8,18 +6,14 @@ import { useConversation } from '@11labs/react';
|
||||
// UI
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Mic, MicOff, Volume2, VolumeX, XIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
// Auth
|
||||
import { trpcClient } from '@/providers/query-provider';
|
||||
import { useThreads } from '@/hooks/use-threads';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import type { Sender } from '@/types';
|
||||
import dedent from 'dedent';
|
||||
|
||||
// Utils
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
interface EmailContent {
|
||||
metadata: {
|
||||
isUnread: boolean;
|
||||
@@ -132,7 +126,7 @@ const VoiceChat = ({ onClose }: VoiceChatProps) => {
|
||||
const emailContext = emailContent.join('\n\n');
|
||||
|
||||
const conversationId = await conversation.startSession({
|
||||
agentId: env.NEXT_PUBLIC_ELEVENLABS_AGENT_ID,
|
||||
agentId: import.meta.env.VITE_PUBLIC_ELEVENLABS_AGENT_ID!,
|
||||
dynamicVariables: {
|
||||
user_name: userName,
|
||||
email_context: emailContext,
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
ArrowRight,
|
||||
ChevronDown,
|
||||
@@ -46,22 +44,19 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from '@/co
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Form, FormControl, FormField, FormItem } from '@/components/ui/form';
|
||||
import { use, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useInView, motion } from 'motion/react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Balancer } from 'react-wrap-balancer';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Command, Menu } from 'lucide-react';
|
||||
import { Separator } from '../ui/separator';
|
||||
import Balancer from 'react-wrap-balancer';
|
||||
import { signIn } from '@/lib/auth-client';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useInView } from 'motion/react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Link } from 'react-router';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { env } from '@/lib/env';
|
||||
import Image from 'next/image';
|
||||
import { toast } from 'sonner';
|
||||
import Footer from './footer';
|
||||
import Link from 'next/link';
|
||||
import React from 'react';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -181,12 +176,12 @@ export default function HomeContent() {
|
||||
<header className="fixed z-50 hidden w-full items-center justify-center px-4 pt-6 md:flex">
|
||||
<nav className="border-input/50 bg-[#1E1E1E] flex w-full max-w-3xl items-center justify-between gap-2 rounded-xl border-t p-2 px-4">
|
||||
<div className="flex items-center gap-6">
|
||||
<a href="/" className="relative bottom-1 cursor-pointer">
|
||||
<Image src="white-icon.svg" alt="Zero Email" width={22} height={22} />
|
||||
<Link to="/" className="relative bottom-1 cursor-pointer">
|
||||
<img src="white-icon.svg" alt="Zero Email" width={22} height={22} />
|
||||
<span className="text-muted-foreground absolute -right-[-0.5px] text-[10px]">
|
||||
beta
|
||||
</span>
|
||||
</a>
|
||||
</Link>
|
||||
<NavigationMenu>
|
||||
<NavigationMenuList className="gap-1">
|
||||
<NavigationMenuItem>
|
||||
@@ -236,7 +231,7 @@ export default function HomeContent() {
|
||||
toast.promise(
|
||||
signIn.social({
|
||||
provider: 'google',
|
||||
callbackURL: `${env.NEXT_PUBLIC_APP_URL}/mail`,
|
||||
callbackURL: `${window.location.origin}/mail`,
|
||||
}),
|
||||
{
|
||||
error: 'Login redirect failed',
|
||||
@@ -265,7 +260,7 @@ export default function HomeContent() {
|
||||
<SheetContent side="left" className="w-[300px] bg-[#111111] sm:w-[400px]">
|
||||
<SheetHeader className="flex flex-row items-center justify-between">
|
||||
<SheetTitle>
|
||||
<Image src="white-icon.svg" alt="Zero Email" width={22} height={22} />
|
||||
<img src="white-icon.svg" alt="Zero Email" width={22} height={22} />
|
||||
</SheetTitle>
|
||||
<a href="/login">
|
||||
<Button className="w-full">Sign in</Button>
|
||||
@@ -291,7 +286,7 @@ export default function HomeContent() {
|
||||
return (
|
||||
<Link
|
||||
key={resource.title}
|
||||
href={resource.href}
|
||||
to={resource.href}
|
||||
className="flex items-center gap-2 font-medium"
|
||||
>
|
||||
{resource.platform && <Icon className="h-5 w-5" />}
|
||||
@@ -311,7 +306,7 @@ export default function HomeContent() {
|
||||
className="border-input/50 mb-6 inline-flex items-center gap-4 rounded-full border border-[#2A2A2A] bg-[#1E1E1E] px-4 py-1"
|
||||
>
|
||||
<span className="flex items-center gap-2 text-sm">
|
||||
<Image
|
||||
<img
|
||||
src="/yc-small.svg"
|
||||
alt="Y Combinator"
|
||||
className="rounded-[2px]"
|
||||
@@ -358,7 +353,7 @@ export default function HomeContent() {
|
||||
toast.promise(
|
||||
signIn.social({
|
||||
provider: 'google',
|
||||
callbackURL: `${env.NEXT_PUBLIC_APP_URL}/mail`,
|
||||
callbackURL: `${window.location.origin}/mail`,
|
||||
}),
|
||||
{
|
||||
error: 'Login redirect failed',
|
||||
@@ -420,13 +415,13 @@ export default function HomeContent() {
|
||||
<div className="bg-border absolute -right-px -top-4 hidden h-4 w-px md:block" /> */}
|
||||
{tabs.map((tab) => (
|
||||
<TabsContent key={tab.value} value={tab.value}>
|
||||
<Image
|
||||
<img
|
||||
src="/email-preview.png"
|
||||
alt="Zero Email Preview"
|
||||
width={1920}
|
||||
height={1080}
|
||||
className="relative hidden md:block"
|
||||
priority
|
||||
loading="eager"
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
@@ -436,13 +431,13 @@ export default function HomeContent() {
|
||||
</section>
|
||||
|
||||
<div className="flex items-center justify-center px-4 md:hidden">
|
||||
<Image
|
||||
<img
|
||||
src="/email-preview.png"
|
||||
alt="Zero Email Preview"
|
||||
width={1920}
|
||||
height={1080}
|
||||
className="mt-10 h-fit w-full rounded-xl border"
|
||||
priority
|
||||
loading="eager"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -483,7 +478,7 @@ export default function HomeContent() {
|
||||
<div className="text-base-gray-500/50 justify-start text-sm leading-none">To:</div>
|
||||
<div className="flex flex-1 items-center justify-start gap-1">
|
||||
<div className="outline-tokens-badge-default/10 flex items-center justify-start gap-1.5 rounded-full border border-[#2B2B2B] py-1 pl-1 pr-1.5">
|
||||
<Image
|
||||
<img
|
||||
height={20}
|
||||
width={20}
|
||||
className="h-5 w-5 rounded-full"
|
||||
@@ -499,7 +494,7 @@ export default function HomeContent() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="outline-tokens-badge-default/10 flex items-center justify-start gap-1.5 rounded-full border border-[#2B2B2B] py-1 pl-1 pr-1.5">
|
||||
<Image
|
||||
<img
|
||||
height={20}
|
||||
width={20}
|
||||
className="h-5 w-5 rounded-full"
|
||||
@@ -701,12 +696,11 @@ export default function HomeContent() {
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start gap-1.5 self-stretch px-1.5">
|
||||
<div className="inline-flex items-center justify-start gap-2.5 self-stretch rounded-md p-2.5">
|
||||
<Image
|
||||
<img
|
||||
alt="Nizzy"
|
||||
height={250}
|
||||
width={250}
|
||||
objectFit="cover"
|
||||
className="h-6 w-6 rounded-full"
|
||||
className="h-6 w-6 rounded-full object-cover"
|
||||
src="/nizzy.jpg"
|
||||
/>
|
||||
<div className="inline-flex h-7 flex-1 flex-col items-start justify-start gap-2">
|
||||
@@ -945,7 +939,7 @@ export default function HomeContent() {
|
||||
</div>
|
||||
<div className="border-tokens-stroke-light/5 flex-col items-start justify-start gap-6 self-stretch overflow-hidden border-b-[0.35px] p-3.5">
|
||||
<div className="inline-flex items-center justify-start gap-3 self-stretch">
|
||||
<Image
|
||||
<img
|
||||
alt="Ahmet"
|
||||
height={200}
|
||||
width={200}
|
||||
@@ -980,7 +974,7 @@ export default function HomeContent() {
|
||||
<div className="absolute left-0 top-[121px] inline-flex w-[650px] flex-col items-start justify-start gap-4 overflow-hidden rounded-3xl border border-[#8B5CF6] bg-[#2A1D48] p-6 outline outline-[#3F325F]">
|
||||
<div className="inline-flex items-center justify-start gap-1.5">
|
||||
<div className="relative h-3.5 w-3.5">
|
||||
<Image src="/star.svg" alt="AI Summary" width={16} height={16} className="" />
|
||||
<img src="/star.svg" alt="AI Summary" width={16} height={16} className="" />
|
||||
</div>
|
||||
<div className="flex items-center justify-start gap-1 text-xs leading-3 text-[#948CA4]">
|
||||
AI Summary
|
||||
@@ -1040,7 +1034,7 @@ export default function HomeContent() {
|
||||
<div className="inline-flex items-center justify-start gap-3 self-stretch rounded-lg p-3">
|
||||
<div className="relative h-8 w-8 rounded-full bg-indigo-500/10">
|
||||
<div className="absolute left-[10.2px] top-[4px] h-7 w-3 overflow-hidden">
|
||||
<Image
|
||||
<img
|
||||
src="/stripe.svg"
|
||||
alt="Stripe"
|
||||
width={12}
|
||||
@@ -1077,7 +1071,7 @@ export default function HomeContent() {
|
||||
<div className="relative h-8 w-8 rounded-full bg-red-600/10">
|
||||
<div className="absolute left-0 top-0 h-8 w-8 rounded-full" />
|
||||
<div className="absolute left-[11px] top-[4px] h-7 w-2.5">
|
||||
<Image
|
||||
<img
|
||||
src="/netflix.svg"
|
||||
alt="Stripe"
|
||||
width={12}
|
||||
@@ -1111,7 +1105,7 @@ export default function HomeContent() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex items-center justify-start gap-3 self-stretch rounded-[10px] bg-[#202020] p-3">
|
||||
<Image
|
||||
<img
|
||||
className="h-8 w-8 rounded-full"
|
||||
src="/dudu.jpg"
|
||||
alt="Dudu"
|
||||
@@ -1330,7 +1324,7 @@ export default function HomeContent() {
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start gap-2 self-stretch px-2 pb-2">
|
||||
<div className="inline-flex items-center justify-start gap-3 self-stretch rounded-lg p-3">
|
||||
<Image
|
||||
<img
|
||||
src="/adam.jpg"
|
||||
alt="avatar"
|
||||
width={32}
|
||||
@@ -1449,7 +1443,7 @@ export default function HomeContent() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex flex-1 flex-col items-center justify-center gap-8 self-stretch overflow-hidden px-5 py-4">
|
||||
<Image
|
||||
<img
|
||||
src="/white-icon.svg"
|
||||
alt="chat"
|
||||
width={28}
|
||||
@@ -1520,7 +1514,7 @@ export default function HomeContent() {
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
<Image
|
||||
<img
|
||||
src="/pixel.svg"
|
||||
alt="hero"
|
||||
width={1920}
|
||||
@@ -1551,21 +1545,21 @@ export default function HomeContent() {
|
||||
<span className="text-white underline">save hours every week.</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-center">
|
||||
<Image
|
||||
<img
|
||||
className="relative bottom-12 right-[162px]"
|
||||
src="/verified-home.png"
|
||||
alt="tasks"
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
<Image
|
||||
<img
|
||||
className="relative bottom-[150px] right-[-25px]"
|
||||
src="/snooze-home.png"
|
||||
alt="tasks"
|
||||
width={50}
|
||||
height={50}
|
||||
/>
|
||||
<Image
|
||||
<img
|
||||
className="relative bottom-[195px] left-[278px]"
|
||||
src="/star-home.png"
|
||||
alt="tasks"
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { LinkedIn, Twitter, Discord } from '../icons/icons';
|
||||
import { motion, useInView } from 'motion/react';
|
||||
import { Button } from '../ui/button';
|
||||
import Image from 'next/image';
|
||||
import { Link } from 'react-router';
|
||||
import { useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
|
||||
const socialLinks = [
|
||||
{
|
||||
@@ -83,12 +80,12 @@ export default function Footer() {
|
||||
<div className="inline-flex flex-col items-start justify-between self-stretch">
|
||||
<div className="inline-flex w-8 items-center justify-start gap-3">
|
||||
<a href="/">
|
||||
<Image src="/white-icon.svg" alt="logo" width={100} height={100} />
|
||||
<img src="/white-icon.svg" alt="logo" width={100} height={100} />
|
||||
</a>
|
||||
</div>
|
||||
<div className="inline-flex items-center justify-start gap-4">
|
||||
{socialLinks.map((social) => (
|
||||
<Link
|
||||
<a
|
||||
key={social.name}
|
||||
href={social.href}
|
||||
target="_blank"
|
||||
@@ -98,7 +95,7 @@ export default function Footer() {
|
||||
<div className="relative h-3.5 w-3.5 overflow-hidden">
|
||||
<social.icon className="absolute h-3.5 w-3.5 fill-white" />
|
||||
</div>
|
||||
</Link>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center justify-start gap-3">
|
||||
@@ -107,7 +104,7 @@ export default function Footer() {
|
||||
</div>
|
||||
<a href="https://www.ycombinator.com" target="_blank" rel="noopener noreferrer">
|
||||
<div className="relative w-36 overflow-hidden">
|
||||
<Image
|
||||
<img
|
||||
src="/yc.svg"
|
||||
className="bg-transparent"
|
||||
alt="logo"
|
||||
@@ -124,7 +121,7 @@ export default function Footer() {
|
||||
Product
|
||||
</div>
|
||||
<div className="flex flex-col items-start justify-start gap-4 self-stretch">
|
||||
<Link
|
||||
<a
|
||||
href="https://x.com/nizzyabi/status/1918064165530550286"
|
||||
className="w-full"
|
||||
target="_blank"
|
||||
@@ -132,8 +129,8 @@ export default function Footer() {
|
||||
<div className="justify-start self-stretch text-base leading-none text-white opacity-80 transition-opacity hover:opacity-100">
|
||||
Product
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/nizzyabi/status/1918051282881069229"
|
||||
className="w-full"
|
||||
target="_blank"
|
||||
@@ -141,8 +138,8 @@ export default function Footer() {
|
||||
<div className="justify-start self-stretch text-base leading-none text-white opacity-80 transition-opacity hover:opacity-100">
|
||||
Zero AI
|
||||
</div>
|
||||
</Link>
|
||||
<Link
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/nizzyabi/status/1919292505260249486"
|
||||
className="w-full"
|
||||
target="_blank"
|
||||
@@ -150,7 +147,7 @@ export default function Footer() {
|
||||
<div className="justify-start self-stretch text-base leading-none text-white opacity-80 transition-opacity hover:opacity-100">
|
||||
Shortcuts
|
||||
</div>
|
||||
</Link>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div className="inline-flex flex-col items-start justify-start gap-5">
|
||||
@@ -185,7 +182,7 @@ export default function Footer() {
|
||||
</div>
|
||||
<div className="flex items-center justify-start gap-4">
|
||||
<Link
|
||||
href="/about"
|
||||
to="/about"
|
||||
className="justify-start text-sm font-normal leading-tight text-white/70 opacity-80 transition-opacity hover:opacity-100"
|
||||
>
|
||||
About
|
||||
@@ -193,14 +190,14 @@ export default function Footer() {
|
||||
<div className="h-5 w-0 outline outline-1 outline-offset-[-0.50px] outline-white/20" />
|
||||
|
||||
<Link
|
||||
href="/terms"
|
||||
to="/terms"
|
||||
className="justify-start text-sm font-normal leading-tight text-white/70 opacity-80 transition-opacity hover:opacity-100"
|
||||
>
|
||||
Terms & Conditions
|
||||
</Link>
|
||||
<div className="h-5 w-0 outline outline-1 outline-offset-[-0.50px] outline-white/20" />
|
||||
<Link
|
||||
href="/privacy"
|
||||
to="/privacy"
|
||||
className="justify-start text-sm font-normal leading-tight text-white/70 opacity-80 transition-opacity hover:opacity-100"
|
||||
>
|
||||
Privacy Policy
|
||||
|
||||
@@ -11,7 +11,6 @@ export function PixelatedBackground(props: SVGProps<SVGSVGElement>) {
|
||||
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||
{...props}
|
||||
>
|
||||
|
||||
<g mask="url(#mask0_11_932)">
|
||||
<rect
|
||||
width="1438.5"
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
import { PixelatedBackground } from "./pixelated-bg";
|
||||
import { PixelatedBackground } from './pixelated-bg';
|
||||
|
||||
export default function Speed() {
|
||||
return (
|
||||
<div className="">
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <div className=""></div>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import type { Transition, Variants } from 'motion/react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import type React from 'react';
|
||||
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { forwardRef, useCallback, useImperativeHandle, useRef } from 'react';
|
||||
import { motion, useAnimation } from 'motion/react';
|
||||
import type { Variants } from 'motion/react';
|
||||
|
||||
@@ -43,16 +43,15 @@ import { useSession } from '@/lib/auth-client';
|
||||
import { RenderLabels } from './render-labels';
|
||||
import ReplyCompose from './reply-composer';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { MailIframe } from './mail-iframe';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { useParams } from 'react-router';
|
||||
import { MailLabels } from './mail-list';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { format, set } from 'date-fns';
|
||||
import { Button } from '../ui/button';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { Badge } from '../ui/badge';
|
||||
import Image from 'next/image';
|
||||
|
||||
// Add formatFileSize utility function
|
||||
const formatFileSize = (size: number) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { getBrowserTimezone } from '@/lib/timezones';
|
||||
import { template } from '@/lib/email-utils.client';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { useSettings } from '@/hooks/use-settings';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Archive2,
|
||||
Bell,
|
||||
@@ -47,7 +45,7 @@ import { useSearchValue } from '@/hooks/use-search-value';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { highlightText } from '@/lib/email-utils.client';
|
||||
import { useHotkeysContext } from 'react-hotkeys-hook';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { useThreadLabels } from '@/hooks/use-labels';
|
||||
import { useKeyState } from '@/hooks/use-hot-key';
|
||||
@@ -56,14 +54,13 @@ import { RenderLabels } from './render-labels';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useDraft } from '@/hooks/use-drafts';
|
||||
import { useStats } from '@/hooks/use-stats';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Button } from '../ui/button';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { Categories } from './mail';
|
||||
import items from './demo.json';
|
||||
import { useAtom } from 'jotai';
|
||||
import Image from 'next/image';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const HOVER_DELAY = 1000; // ms before prefetching
|
||||
@@ -749,7 +746,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
|
||||
const { folder } = useParams<{ folder: string }>();
|
||||
const { data: session } = useSession();
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const [, setThreadId] = useQueryState('threadId');
|
||||
const [, setDraftId] = useQueryState('draftId');
|
||||
const [category, setCategory] = useQueryState('category');
|
||||
@@ -969,7 +966,7 @@ export const MailList = memo(({ isCompact }: MailListProps) => {
|
||||
) : !items || items.length === 0 ? (
|
||||
<div className="flex h-[calc(100dvh-9rem)] w-full items-center justify-center md:h-[calc(100dvh-4rem)]">
|
||||
<div className="flex flex-col items-center justify-center gap-2 text-center">
|
||||
<Image
|
||||
<img
|
||||
suppressHydrationWarning
|
||||
src={resolvedTheme === 'dark' ? '/empty-state.svg' : '/empty-state-light.svg'}
|
||||
alt="Empty Inbox"
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { moveThreadsTo, type ThreadDestination } from '@/lib/thread-actions';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useThread, useThreads } from '@/hooks/use-threads';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { Archive, Mail, Inbox } from 'lucide-react';
|
||||
import { useCallback, memo, useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useStats } from '@/hooks/use-stats';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { cn, FOLDERS } from '@/lib/utils';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { toast } from 'sonner';
|
||||
@@ -50,7 +48,7 @@ export const MailQuickActions = memo(
|
||||
const [{ refetch, isLoading }] = useThreads();
|
||||
const { refetch: mutateStats } = useStats();
|
||||
const t = useTranslations();
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const [threadId, setThreadId] = useQueryState('threadId');
|
||||
|
||||
@@ -71,7 +69,7 @@ export const MailQuickActions = memo(
|
||||
if (resetNavigation) {
|
||||
resetNavigation();
|
||||
}
|
||||
}, [threadId, latestMessage, router, currentFolder, resetNavigation]);
|
||||
}, [threadId, latestMessage, navigate, currentFolder, resetNavigation]);
|
||||
|
||||
const handleArchive = useCallback(
|
||||
async (e?: React.MouseEvent) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Archive, Copy, Maximize2, Minimize2, X, Reply, MoreVertical } from 'lucide-react';
|
||||
import { Separator } from '@radix-ui/react-separator';
|
||||
import { Separator } from '../ui/separator';
|
||||
import { Skeleton } from '../ui/skeleton';
|
||||
import { Button } from '../ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -38,7 +36,7 @@ import { useSearchValue } from '@/hooks/use-search-value';
|
||||
import { useConnections } from '@/hooks/use-connections';
|
||||
import { MailList } from '@/components/mail/mail-list';
|
||||
import { useHotkeysContext } from 'react-hotkeys-hook';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useParams, useNavigate } from 'react-router';
|
||||
import { useMail } from '@/components/mail/use-mail';
|
||||
import { SidebarToggle } from '../ui/sidebar-toggle';
|
||||
import { useBrainState } from '@/hooks/use-summary';
|
||||
@@ -52,7 +50,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import { useStats } from '@/hooks/use-stats';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import { SearchBar } from './search-bar';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { useAtom } from 'jotai';
|
||||
@@ -318,7 +316,7 @@ export function MailLayout() {
|
||||
const [mail, setMail] = useMail();
|
||||
const [, clearBulkSelection] = useAtom(clearBulkSelectionAtom);
|
||||
const isMobile = useIsMobile();
|
||||
const router = useRouter();
|
||||
const navigate = useNavigate();
|
||||
const { data: session, isPending } = useSession();
|
||||
const { data: connections } = useConnections();
|
||||
const t = useTranslations();
|
||||
@@ -341,7 +339,7 @@ export function MailLayout() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.user && !isPending) {
|
||||
router.push('/login');
|
||||
navigate('/login');
|
||||
}
|
||||
}, [session?.user, isPending]);
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import React, { useState } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { buttonVariants } from '@/components/ui/button';
|
||||
import { type LucideIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface NavProps {
|
||||
@@ -28,7 +24,7 @@ export function Nav({ links, isCollapsed }: NavProps) {
|
||||
isCollapsed ? (
|
||||
<Tooltip key={index} delayDuration={0}>
|
||||
<TooltipTrigger asChild>
|
||||
<Link
|
||||
<a
|
||||
href="#"
|
||||
className={cn(
|
||||
buttonVariants({ variant: link.variant, size: 'icon' }),
|
||||
@@ -39,7 +35,7 @@ export function Nav({ links, isCollapsed }: NavProps) {
|
||||
>
|
||||
<link.icon className="h-4 w-4" />
|
||||
<span className="sr-only">{link.title}</span>
|
||||
</Link>
|
||||
</a>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" className="flex items-center gap-4">
|
||||
{link.title}
|
||||
@@ -47,7 +43,7 @@ export function Nav({ links, isCollapsed }: NavProps) {
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Link
|
||||
<a
|
||||
key={index}
|
||||
href="#"
|
||||
className={cn(
|
||||
@@ -69,7 +65,7 @@ export function Nav({ links, isCollapsed }: NavProps) {
|
||||
{link.label}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
</a>
|
||||
),
|
||||
)}
|
||||
</nav>
|
||||
|
||||
@@ -66,8 +66,8 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { useTranslations, useFormatter } from 'next-intl';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { useTranslations, useFormatter } from 'use-intl';
|
||||
import { useTRPC } from '@/providers/query-provider';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { PopoverContent, PopoverTrigger } from '@radix-ui/react-popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { useSearchValue } from '@/hooks/use-search-value';
|
||||
import { Popover } from '../ui/popover';
|
||||
import type { Label } from '@/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import * as React from 'react';
|
||||
@@ -41,7 +38,7 @@ export const RenderLabels = ({ count = 1, labels }: { count?: number; labels: La
|
||||
key={label.id}
|
||||
onClick={handleFilterByLabel(label)}
|
||||
className={cn(
|
||||
'dark:bg-subtleBlack bg-subtleWhite text-primary inline-block truncate rounded border px-1.5 py-0.5 text-xs font-medium overflow-hidden',
|
||||
'dark:bg-subtleBlack bg-subtleWhite text-primary inline-block overflow-hidden truncate rounded border px-1.5 py-0.5 text-xs font-medium',
|
||||
searchValue.value.includes(`label:${label.name}`) &&
|
||||
'border-neutral-800 dark:border-white',
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
'use client';
|
||||
|
||||
import { useEmailAliases } from '@/hooks/use-email-aliases';
|
||||
import { EmailComposer } from '../create/email-composer';
|
||||
import { useHotkeysContext } from 'react-hotkeys-hook';
|
||||
@@ -10,8 +8,8 @@ import { useThread } from '@/hooks/use-threads';
|
||||
import { useSession } from '@/lib/auth-client';
|
||||
import { serializeFiles } from '@/lib/schemas';
|
||||
import { useDraft } from '@/hooks/use-drafts';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslations } from 'use-intl';
|
||||
import type { Sender } from '@/types';
|
||||
import { useQueryState } from 'nuqs';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSearchValue } from '@/hooks/use-search-value';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { type DateRange } from 'react-day-picker';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useLocation } from 'react-router';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Search, X } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
@@ -29,7 +29,8 @@ export function SearchBar() {
|
||||
// const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [, setSearchValue] = useSearchValue();
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const location = useLocation();
|
||||
const pathname = location.pathname;
|
||||
|
||||
const form = useForm<SearchForm>({
|
||||
defaultValues: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user