first commit
This commit is contained in:
commit
cce9a8c9ba
73 changed files with 8665 additions and 0 deletions
3
frontend/.dockerignore
Normal file
3
frontend/.dockerignore
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
dist/
|
||||
*.log
|
||||
17
frontend/Dockerfile
Normal file
17
frontend/Dockerfile
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
FROM node:24-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY index.html ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM nginx:1.29-alpine
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --from=build /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
|
||||
CMD wget -q --spider http://127.0.0.1/ || exit 1
|
||||
14
frontend/index.html
Normal file
14
frontend/index.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#060622" />
|
||||
<meta name="description" content="Agmail is the focused inbox built for people and their AI agents." />
|
||||
<title>Agmail — An email for your AI agent</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
27
frontend/nginx.conf
Normal file
27
frontend/nginx.conf
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name _;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://backend:8000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
location = /health {
|
||||
proxy_pass http://backend:8000/health;
|
||||
}
|
||||
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache, no-store, must-revalidate";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
763
frontend/package-lock.json
generated
Normal file
763
frontend/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,763 @@
|
|||
{
|
||||
"name": "agmail-frontend",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agmail-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"lucide-react": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"vite": "latest"
|
||||
},
|
||||
"devDependencies": {}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.143.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.143.0.tgz",
|
||||
"integrity": "sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.3.tgz",
|
||||
"integrity": "sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.3.tgz",
|
||||
"integrity": "sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.3.tgz",
|
||||
"integrity": "sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.3.tgz",
|
||||
"integrity": "sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.3.tgz",
|
||||
"integrity": "sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.3.tgz",
|
||||
"integrity": "sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.3.tgz",
|
||||
"integrity": "sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.3.tgz",
|
||||
"integrity": "sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.3.tgz",
|
||||
"integrity": "sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.3.tgz",
|
||||
"integrity": "sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.3.tgz",
|
||||
"integrity": "sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.3.tgz",
|
||||
"integrity": "sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.3.tgz",
|
||||
"integrity": "sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.3.tgz",
|
||||
"integrity": "sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw=="
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.5.tgz",
|
||||
"integrity": "sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@rolldown/plugin-babel": "^0.1.7 || ^0.2.0",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"vite": "^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@rolldown/plugin-babel": {
|
||||
"optional": true
|
||||
},
|
||||
"babel-plugin-react-compiler": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
|
||||
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.33.0",
|
||||
"lightningcss-darwin-arm64": "1.33.0",
|
||||
"lightningcss-darwin-x64": "1.33.0",
|
||||
"lightningcss-freebsd-x64": "1.33.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.33.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.33.0",
|
||||
"lightningcss-linux-arm64-musl": "1.33.0",
|
||||
"lightningcss-linux-x64-gnu": "1.33.0",
|
||||
"lightningcss-linux-x64-musl": "1.33.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.33.0",
|
||||
"lightningcss-win32-x64-msvc": "1.33.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
|
||||
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
|
||||
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
|
||||
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
|
||||
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
|
||||
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
|
||||
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.29.0.tgz",
|
||||
"integrity": "sha512-Xs9QFG5+9sNX04MdKVT4++umA+hJ2qsJVlRlRWHQ7qZobXgMiNHSpZ5eZm8JUoGCdNyoEdXoEwa8HVr0DNjOQg==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.17",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.17.tgz",
|
||||
"integrity": "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
||||
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.26",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||
"integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.17",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.8.tgz",
|
||||
"integrity": "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.8.tgz",
|
||||
"integrity": "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.3.tgz",
|
||||
"integrity": "sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.143.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.2.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.3",
|
||||
"@rolldown/binding-darwin-x64": "1.2.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.2.0.tgz",
|
||||
"integrity": "sha512-pn+CFpM0lwDeKwmOq1ZaBK/9sjorZcgqxki6MbY/jPEVd9vichIlmlD4HmQ5wdP5EgqQCFRaACBxMC7uEGc6lQ==",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.5",
|
||||
"postcss": "^8.5.23",
|
||||
"rolldown": "~1.2.0",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.4.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
"sugarss": "^5.0.0",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitejs/devtools": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
19
frontend/package.json
Normal file
19
frontend/package.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"name": "agmail-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@vitejs/plugin-react": "latest",
|
||||
"vite": "latest",
|
||||
"react": "latest",
|
||||
"react-dom": "latest",
|
||||
"lucide-react": "latest"
|
||||
},
|
||||
"devDependencies": {}
|
||||
}
|
||||
27
frontend/src/App.jsx
Normal file
27
frontend/src/App.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import DashboardPage from "./components/DashboardPage";
|
||||
import LoginPage from "./components/LoginPage";
|
||||
import MobileLanding from "./components/MobileLanding";
|
||||
import SignupPage from "./components/SignupPage";
|
||||
import SmtpCredentialsPage from "./components/SmtpCredentialsPage";
|
||||
import WebLanding from "./components/WebLanding";
|
||||
|
||||
export default function App() {
|
||||
if (window.location.pathname === "/app") {
|
||||
return <DashboardPage />;
|
||||
}
|
||||
if (window.location.pathname === "/login") {
|
||||
return <LoginPage />;
|
||||
}
|
||||
if (window.location.pathname === "/signup") {
|
||||
return <SignupPage />;
|
||||
}
|
||||
if (window.location.pathname === "/smtp-credentials") {
|
||||
return <SmtpCredentialsPage />;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="web-view"><WebLanding /></div>
|
||||
<div className="mobile-view"><MobileLanding /></div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
106
frontend/src/api.js
Normal file
106
frontend/src/api.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
const API_BASE = "/api/v1";
|
||||
|
||||
function basicAuthHeader({ username, password }) {
|
||||
return `Basic ${btoa(`${username}:${password}`)}`;
|
||||
}
|
||||
|
||||
async function request(path, { method = "GET", payload, token, basicAuth } = {}) {
|
||||
const response = await fetch(`${API_BASE}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
...(payload ? { "Content-Type": "application/json" } : {}),
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...(basicAuth
|
||||
? { Authorization: basicAuthHeader(basicAuth) }
|
||||
: {}),
|
||||
},
|
||||
...(payload ? { body: JSON.stringify(payload) } : {}),
|
||||
});
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok) {
|
||||
const message = Array.isArray(data.detail)
|
||||
? data.detail[0]?.msg
|
||||
: data.detail;
|
||||
throw new Error(message || "Something went wrong. Please try again.");
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const login = (payload) =>
|
||||
request("/auth/login", { method: "POST", payload });
|
||||
export const register = (payload) =>
|
||||
request("/auth/register", { method: "POST", payload });
|
||||
export const getCurrentAccount = (token) =>
|
||||
request("/auth/me", { token });
|
||||
export const getMyCompanyDomain = (token) =>
|
||||
request("/domains/me", { token });
|
||||
export const verifyCompanyDns = (token) =>
|
||||
request("/domains/me/verify-dns", { method: "POST", token });
|
||||
export const sendEmail = (payload, token) =>
|
||||
request("/emails", { method: "POST", payload, token });
|
||||
|
||||
function emailQuery(parameters = {}) {
|
||||
const query = new URLSearchParams();
|
||||
Object.entries(parameters).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null && value !== "") {
|
||||
query.set(key, String(value));
|
||||
}
|
||||
});
|
||||
const encoded = query.toString();
|
||||
return encoded ? `?${encoded}` : "";
|
||||
}
|
||||
|
||||
export const listEmails = (parameters, token) =>
|
||||
request(`/emails${emailQuery(parameters)}`, { token });
|
||||
export const getEmail = (emailId, token) =>
|
||||
request(`/emails/${encodeURIComponent(emailId)}`, { token });
|
||||
export const setEmailRead = (emailId, isRead, token) =>
|
||||
request(`/emails/${encodeURIComponent(emailId)}/read`, {
|
||||
method: "PATCH",
|
||||
payload: { is_read: isRead },
|
||||
token,
|
||||
});
|
||||
export const setEmailStar = (emailId, isStarred, token) =>
|
||||
request(`/emails/${encodeURIComponent(emailId)}/star`, {
|
||||
method: "PATCH",
|
||||
payload: { is_starred: isStarred },
|
||||
token,
|
||||
});
|
||||
export const trashEmail = (emailId, token) =>
|
||||
request(`/emails/${encodeURIComponent(emailId)}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
});
|
||||
export const restoreEmail = (emailId, token) =>
|
||||
request(`/emails/${encodeURIComponent(emailId)}/restore`, {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
export const permanentlyDeleteEmail = (emailId, token) =>
|
||||
request(`/emails/${encodeURIComponent(emailId)}/permanent`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
});
|
||||
|
||||
export const listStickyNotes = (token) =>
|
||||
request("/sticky-notes", { token });
|
||||
export const createStickyNote = (payload, token) =>
|
||||
request("/sticky-notes", { method: "POST", payload, token });
|
||||
export const updateStickyNote = (noteId, payload, token) =>
|
||||
request(`/sticky-notes/${encodeURIComponent(noteId)}`, {
|
||||
method: "PATCH",
|
||||
payload,
|
||||
token,
|
||||
});
|
||||
export const deleteStickyNote = (noteId, token) =>
|
||||
request(`/sticky-notes/${encodeURIComponent(noteId)}`, {
|
||||
method: "DELETE",
|
||||
token,
|
||||
});
|
||||
|
||||
export const previewDnsSetup = (payload, basicAuth) =>
|
||||
request("/dns-setup/preview", {
|
||||
method: "POST",
|
||||
payload,
|
||||
basicAuth,
|
||||
});
|
||||
27
frontend/src/components/AuthLayout.jsx
Normal file
27
frontend/src/components/AuthLayout.jsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { ArrowLeft, Bot, ShieldCheck, Sparkles } from "lucide-react";
|
||||
import Brand from "./Brand";
|
||||
|
||||
export default function AuthLayout({ children, eyebrow, title, description }) {
|
||||
return (
|
||||
<main className="auth-page">
|
||||
<nav className="auth-nav">
|
||||
<Brand />
|
||||
<a className="back-link" href="/"><ArrowLeft size={15} /> Back home</a>
|
||||
</nav>
|
||||
<div className="auth-orb auth-orb-one" />
|
||||
<div className="auth-orb auth-orb-two" />
|
||||
<section className="auth-shell">
|
||||
<div className="auth-story">
|
||||
<div className="eyebrow"><Sparkles size={13} /> {eyebrow}</div>
|
||||
<h1>{title}</h1>
|
||||
<p>{description}</p>
|
||||
<div className="auth-points">
|
||||
<span><Bot size={17} /> Ready for your AI agents</span>
|
||||
<span><ShieldCheck size={17} /> Private by design</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="auth-card">{children}</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
11
frontend/src/components/Brand.jsx
Normal file
11
frontend/src/components/Brand.jsx
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export default function Brand({ compact = false }) {
|
||||
return (
|
||||
<a className={`brand${compact ? " brand-compact" : ""}`} href="/" aria-label="Agmail home">
|
||||
<span className="brand-mark" aria-hidden="true">
|
||||
<span className="robot-eyes"><i /><i /></span>
|
||||
<span className="robot-smile" />
|
||||
</span>
|
||||
{!compact && <span>agmail</span>}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
139
frontend/src/components/CompanyDnsSetup.jsx
Normal file
139
frontend/src/components/CompanyDnsSetup.jsx
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import { ArrowRight, CheckCircle2, Copy, XCircle } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { verifyCompanyDns } from "../api";
|
||||
import Brand from "./Brand";
|
||||
|
||||
function DnsRecordTable({ records }) {
|
||||
async function copyValue(value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
/* clipboard may be unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dns-table-wrap">
|
||||
<table className="dns-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Host</th>
|
||||
<th>Value</th>
|
||||
<th>TTL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const rowKey = `${record.record_type}-${record.host}-${record.purpose}`;
|
||||
const displayValue = record.record_type === "MX"
|
||||
? `10 ${record.value}`
|
||||
: record.value;
|
||||
return (
|
||||
<tr key={rowKey}>
|
||||
<td>{record.record_type}</td>
|
||||
<td><code>{record.host}</code></td>
|
||||
<td>
|
||||
<div className="dns-value-cell">
|
||||
<code>{displayValue}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="dns-copy"
|
||||
aria-label="Copy value"
|
||||
onClick={() => copyValue(displayValue)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>{record.ttl}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CompanyDnsSetup({ domain, account, onComplete, onLogout }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
const [checks, setChecks] = useState(null);
|
||||
|
||||
async function handleVerify() {
|
||||
setStatus({ loading: true, error: "" });
|
||||
setChecks(null);
|
||||
try {
|
||||
const result = await verifyCompanyDns(token);
|
||||
setChecks(result.checks);
|
||||
if (result.verified) {
|
||||
setStatus({ loading: false, error: "" });
|
||||
onComplete(result);
|
||||
return;
|
||||
}
|
||||
setStatus({
|
||||
loading: false,
|
||||
error: "Some DNS records are missing or still propagating. Update your registrar and try again.",
|
||||
});
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="company-dns-setup">
|
||||
<header className="company-dns-header">
|
||||
<Brand />
|
||||
<button type="button" onClick={onLogout}>Log out</button>
|
||||
</header>
|
||||
|
||||
<section className="company-dns-card">
|
||||
<p className="eyebrow">Company setup</p>
|
||||
<h1>Connect <em>{domain.domain_name}</em></h1>
|
||||
<p className="company-dns-lead">
|
||||
Add these DNS records at your domain registrar for <strong>{account.email}</strong>.
|
||||
Mail uses shared hosting at <strong>{domain.mail_host}</strong>, like Zoho or Namecheap Private Email.
|
||||
</p>
|
||||
|
||||
<DnsRecordTable records={domain.dns_records} />
|
||||
|
||||
<ul className="dns-notes">
|
||||
<li>Point MX to <strong>{domain.mail_host}</strong> — you do not need a separate mail A record.</li>
|
||||
<li>SPF uses <code>include:_spf.getall.help</code> — that record must exist on <strong>getall.help</strong> (Host <code>_spf</code>, TXT <code>v=spf1 ip4:<mail-server-ip> ~all</code>).</li>
|
||||
<li>DNS changes can take a few minutes to propagate.</li>
|
||||
</ul>
|
||||
|
||||
{checks && checks.length > 0 && (
|
||||
<div className="company-dns-checks">
|
||||
{checks.map((check) => (
|
||||
<div
|
||||
key={check.purpose}
|
||||
className={`company-dns-check${check.verified ? " ok" : " fail"}`}
|
||||
>
|
||||
{check.verified ? <CheckCircle2 size={16} /> : <XCircle size={16} />}
|
||||
<div>
|
||||
<strong>{check.purpose.toUpperCase()}</strong>
|
||||
<span>{check.message}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status.error && (
|
||||
<p className="form-error" role="alert">{status.error}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="button button-coral company-dns-next"
|
||||
type="button"
|
||||
disabled={status.loading}
|
||||
onClick={handleVerify}
|
||||
>
|
||||
{status.loading ? "Checking DNS…" : "Next — verify DNS"} <ArrowRight size={16} />
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
166
frontend/src/components/ComposeModal.jsx
Normal file
166
frontend/src/components/ComposeModal.jsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
Send,
|
||||
Sparkles,
|
||||
Underline,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { sendEmail } from "../api";
|
||||
|
||||
function parseAddresses(value) {
|
||||
return value
|
||||
.split(/[,\n;]/)
|
||||
.map((address) => address.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function plainTextFromHtml(value) {
|
||||
const document = new DOMParser().parseFromString(value, "text/html");
|
||||
return document.body.textContent || "";
|
||||
}
|
||||
|
||||
export default function ComposeModal({ account, onClose, onSent }) {
|
||||
const [format, setFormat] = useState("plain");
|
||||
const [showCopies, setShowCopies] = useState(false);
|
||||
const [body, setBody] = useState("");
|
||||
const [aiPrompt, setAiPrompt] = useState("");
|
||||
const [status, setStatus] = useState({ sending: false, error: "" });
|
||||
const bodyRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
function closeOnEscape(event) {
|
||||
if (event.key === "Escape" && !status.sending) onClose();
|
||||
}
|
||||
document.addEventListener("keydown", closeOnEscape);
|
||||
return () => document.removeEventListener("keydown", closeOnEscape);
|
||||
}, [onClose, status.sending]);
|
||||
|
||||
function helpWrite() {
|
||||
const request = aiPrompt.trim();
|
||||
if (!request) return;
|
||||
const draft = format === "html"
|
||||
? `<p>Hi,</p><p>${request}</p><p>Best,<br>${account.first_name}</p>`
|
||||
: `Hi,\n\n${request}\n\nBest,\n${account.first_name}`;
|
||||
setBody(draft);
|
||||
setAiPrompt("");
|
||||
}
|
||||
|
||||
function applyHtml(openingTag, closingTag) {
|
||||
const editor = bodyRef.current;
|
||||
if (!editor || format !== "html") return;
|
||||
const start = editor.selectionStart;
|
||||
const end = editor.selectionEnd;
|
||||
const selected = body.slice(start, end) || "text";
|
||||
setBody(`${body.slice(0, start)}${openingTag}${selected}${closingTag}${body.slice(end)}`);
|
||||
window.requestAnimationFrame(() => editor.focus());
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
const form = new FormData(event.currentTarget);
|
||||
setStatus({ sending: true, error: "" });
|
||||
try {
|
||||
await sendEmail(
|
||||
{
|
||||
recipients: parseAddresses(form.get("to")),
|
||||
cc: parseAddresses(form.get("cc") || ""),
|
||||
bcc: parseAddresses(form.get("bcc") || ""),
|
||||
subject: form.get("subject"),
|
||||
body_text: format === "plain" ? body : plainTextFromHtml(body),
|
||||
body_html: format === "html" ? body : null,
|
||||
},
|
||||
sessionStorage.getItem("agmail_access_token"),
|
||||
);
|
||||
onSent();
|
||||
} catch (error) {
|
||||
setStatus({ sending: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="compose-overlay" role="presentation">
|
||||
<section className="compose-modal" role="dialog" aria-modal="true" aria-labelledby="compose-title">
|
||||
<header className="compose-header">
|
||||
<div>
|
||||
<span><Sparkles size={14} /></span>
|
||||
<div><h2 id="compose-title">New message</h2><small>From {account.email}</small></div>
|
||||
</div>
|
||||
<button type="button" aria-label="Close compose" onClick={onClose} disabled={status.sending}>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form className="compose-form" onSubmit={submit}>
|
||||
<div className="compose-recipient-row">
|
||||
<label htmlFor="compose-to">To</label>
|
||||
<input id="compose-to" name="to" type="text" placeholder="name@example.com" required autoFocus />
|
||||
<button type="button" onClick={() => setShowCopies((visible) => !visible)}>Cc · Bcc</button>
|
||||
</div>
|
||||
{showCopies && (
|
||||
<div className="compose-copy-fields">
|
||||
<label>Cc<input name="cc" type="text" placeholder="Copy recipients" /></label>
|
||||
<label>Bcc<input name="bcc" type="text" placeholder="Hidden recipients" /></label>
|
||||
</div>
|
||||
)}
|
||||
<input className="compose-subject" name="subject" placeholder="Subject" maxLength="998" />
|
||||
|
||||
<div className="compose-format-row">
|
||||
<span>Message format</span>
|
||||
<div role="tablist" aria-label="Message format">
|
||||
<button className={format === "plain" ? "active" : ""} type="button" onClick={() => setFormat("plain")}>Plain</button>
|
||||
<button className={format === "html" ? "active" : ""} type="button" onClick={() => setFormat("html")}>HTML</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
className="compose-body"
|
||||
ref={bodyRef}
|
||||
value={body}
|
||||
onChange={(event) => setBody(event.target.value)}
|
||||
placeholder={format === "html" ? "<p>Write your HTML email…</p>" : "Write your message…"}
|
||||
required
|
||||
/>
|
||||
|
||||
<div className="compose-tools" aria-label="Message tools">
|
||||
<button type="button" title="Bold" disabled={format !== "html"} onClick={() => applyHtml("<strong>", "</strong>")}><Bold size={16} /></button>
|
||||
<button type="button" title="Italic" disabled={format !== "html"} onClick={() => applyHtml("<em>", "</em>")}><Italic size={16} /></button>
|
||||
<button type="button" title="Underline" disabled={format !== "html"} onClick={() => applyHtml("<u>", "</u>")}><Underline size={16} /></button>
|
||||
<button type="button" title="List" disabled={format !== "html"} onClick={() => applyHtml("<ul><li>", "</li></ul>")}><List size={17} /></button>
|
||||
<button type="button" title="Add link" disabled={format !== "html"} onClick={() => applyHtml('<a href="https://">', "</a>")}><Link size={17} /></button>
|
||||
{format === "plain" && <span>Formatting tools are available in HTML mode</span>}
|
||||
</div>
|
||||
|
||||
<div className="compose-ai">
|
||||
<Sparkles size={18} />
|
||||
<input
|
||||
value={aiPrompt}
|
||||
onChange={(event) => setAiPrompt(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
helpWrite();
|
||||
}
|
||||
}}
|
||||
placeholder="Describe what you want to say"
|
||||
/>
|
||||
<button type="button" onClick={helpWrite} disabled={!aiPrompt.trim()}>
|
||||
Help me write
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{status.error && <p className="compose-error" role="alert">{status.error}</p>}
|
||||
<footer className="compose-footer">
|
||||
<span>Sent securely through agmail SMTP</span>
|
||||
<button className="compose-send" type="submit" disabled={status.sending}>
|
||||
{status.sending ? "Sending…" : "Send"} <Send size={16} />
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
frontend/src/components/DashboardPage.jsx
Normal file
213
frontend/src/components/DashboardPage.jsx
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import {
|
||||
Bot,
|
||||
Clock3,
|
||||
Inbox,
|
||||
ListFilter,
|
||||
LogOut,
|
||||
Mail,
|
||||
Menu,
|
||||
Plug,
|
||||
Send,
|
||||
Settings,
|
||||
ShieldAlert,
|
||||
Sparkles,
|
||||
Tags,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getCurrentAccount, getMyCompanyDomain } from "../api";
|
||||
import Brand from "./Brand";
|
||||
import CompanyDnsSetup from "./CompanyDnsSetup";
|
||||
import ComposeModal from "./ComposeModal";
|
||||
import MailboxView from "./MailboxView";
|
||||
|
||||
const mailItems = [
|
||||
[Inbox, "Inbox"],
|
||||
[Send, "Outbox"],
|
||||
[ShieldAlert, "Spam"],
|
||||
[Trash2, "Trash"],
|
||||
[Clock3, "Scheduled"],
|
||||
[Settings, "Settings"],
|
||||
];
|
||||
|
||||
const agentItems = [
|
||||
[Tags, "Tags"],
|
||||
[ListFilter, "Filters"],
|
||||
[Sparkles, "Summary"],
|
||||
[Plug, "Integration"],
|
||||
];
|
||||
|
||||
function clearSession() {
|
||||
sessionStorage.removeItem("agmail_access_token");
|
||||
sessionStorage.removeItem("agmail_account");
|
||||
}
|
||||
|
||||
function NavGroup({ icon: Icon, title, items, active, onSelect }) {
|
||||
return (
|
||||
<section className="dashboard-nav-group">
|
||||
<h2><Icon size={15} />{title}</h2>
|
||||
<nav aria-label={title}>
|
||||
{items.map(([ItemIcon, label]) => (
|
||||
<button
|
||||
className={active === label ? "active" : ""}
|
||||
key={label}
|
||||
onClick={() => onSelect(label)}
|
||||
type="button"
|
||||
>
|
||||
<ItemIcon size={17} />
|
||||
<span>{label}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [account, setAccount] = useState(null);
|
||||
const [companyDomain, setCompanyDomain] = useState(null);
|
||||
const [needsDnsSetup, setNeedsDnsSetup] = useState(false);
|
||||
const [domainError, setDomainError] = useState("");
|
||||
const [active, setActive] = useState("Inbox");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
const [composeOpen, setComposeOpen] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mailRefreshKey, setMailRefreshKey] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
if (!token) {
|
||||
window.location.replace("/login");
|
||||
return;
|
||||
}
|
||||
|
||||
getCurrentAccount(token)
|
||||
.then(async (currentAccount) => {
|
||||
sessionStorage.setItem("agmail_account", JSON.stringify(currentAccount));
|
||||
setAccount(currentAccount);
|
||||
if (currentAccount.account_type === "company") {
|
||||
try {
|
||||
const domain = await getMyCompanyDomain(token);
|
||||
setCompanyDomain(domain);
|
||||
setNeedsDnsSetup(domain.status === "pending_dns");
|
||||
} catch (error) {
|
||||
setDomainError(error.message || "Could not load your company domain.");
|
||||
}
|
||||
}
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => {
|
||||
clearSession();
|
||||
window.location.replace("/login");
|
||||
});
|
||||
}, []);
|
||||
|
||||
function logout() {
|
||||
clearSession();
|
||||
window.location.assign("/login");
|
||||
}
|
||||
|
||||
function selectItem(item) {
|
||||
setActive(item);
|
||||
setMenuOpen(false);
|
||||
}
|
||||
|
||||
function emailSent() {
|
||||
setComposeOpen(false);
|
||||
setActive("Outbox");
|
||||
setMailRefreshKey((key) => key + 1);
|
||||
setNotice("Message accepted by the mail server. Delivery can take a few minutes — check spam if it does not arrive.");
|
||||
window.setTimeout(() => setNotice(""), 4000);
|
||||
}
|
||||
|
||||
if (loading || !account) {
|
||||
return <main className="dashboard-loading"><Brand /><span>Opening your workspace…</span></main>;
|
||||
}
|
||||
|
||||
if (account.account_type === "company" && domainError) {
|
||||
return (
|
||||
<main className="company-dns-setup">
|
||||
<header className="company-dns-header">
|
||||
<Brand />
|
||||
<button type="button" onClick={logout}>Log out</button>
|
||||
</header>
|
||||
<section className="company-dns-card">
|
||||
<p className="eyebrow">Company setup</p>
|
||||
<h1>Domain not ready</h1>
|
||||
<p className="form-error" role="alert">{domainError}</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
if (needsDnsSetup && companyDomain) {
|
||||
return (
|
||||
<CompanyDnsSetup
|
||||
account={account}
|
||||
domain={companyDomain}
|
||||
onComplete={() => {
|
||||
setCompanyDomain({ ...companyDomain, status: "active" });
|
||||
setNeedsDnsSetup(false);
|
||||
}}
|
||||
onLogout={logout}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const workspaceLabel = account.account_type === "company" ? "Company" : "Individual";
|
||||
const workspaceTitle = account.account_type === "company" && companyDomain
|
||||
? companyDomain.domain_name
|
||||
: "Personal workspace";
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<header className="dashboard-mobile-header">
|
||||
<Brand />
|
||||
<button aria-label="Open navigation" type="button" onClick={() => setMenuOpen(true)}>
|
||||
<Menu size={21} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{menuOpen && <button className="dashboard-scrim" aria-label="Close navigation" onClick={() => setMenuOpen(false)} />}
|
||||
<aside className={menuOpen ? "dashboard-sidebar open" : "dashboard-sidebar"}>
|
||||
<div className="dashboard-brand-row">
|
||||
<Brand />
|
||||
<button aria-label="Close navigation" type="button" onClick={() => setMenuOpen(false)}><X size={19} /></button>
|
||||
</div>
|
||||
<button className="dashboard-compose" type="button" onClick={() => setComposeOpen(true)}>
|
||||
<Mail size={17} />Compose
|
||||
</button>
|
||||
<NavGroup icon={Mail} title="Mail" items={mailItems} active={active} onSelect={selectItem} />
|
||||
<NavGroup icon={Bot} title="Agent" items={agentItems} active={active} onSelect={selectItem} />
|
||||
<div className="dashboard-profile">
|
||||
<span>{account.first_name[0]}{account.last_name[0]}</span>
|
||||
<div><strong>{account.first_name} {account.last_name}</strong><small>{account.email}</small></div>
|
||||
<button aria-label="Log out" title="Log out" type="button" onClick={logout}><LogOut size={17} /></button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="dashboard-content">
|
||||
{!["Inbox", "Outbox", "Trash"].includes(active) && (
|
||||
<div className="dashboard-content-heading">
|
||||
<div><p>{workspaceTitle}</p><h1>{active}</h1></div>
|
||||
<span className="dashboard-account-pill">{workspaceLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{active === "Inbox" && <MailboxView mailbox="inbox" title="Inbox" refreshKey={mailRefreshKey} />}
|
||||
{active === "Outbox" && <MailboxView mailbox="sent" title="Sent" refreshKey={mailRefreshKey} />}
|
||||
{active === "Trash" && <MailboxView mailbox="trash" title="Trash" refreshKey={mailRefreshKey} />}
|
||||
{!["Inbox", "Outbox", "Trash"].includes(active) && (
|
||||
<section className="dashboard-empty">
|
||||
<span><Sparkles size={24} /></span>
|
||||
<h2>{active} is ready for you</h2>
|
||||
<p>Your personal mail and agent tools will appear here.</p>
|
||||
</section>
|
||||
)}
|
||||
</main>
|
||||
{notice && <div className="dashboard-notice" role="status">{notice}</div>}
|
||||
{composeOpen && <ComposeModal account={account} onClose={() => setComposeOpen(false)} onSent={emailSent} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
frontend/src/components/LoginPage.jsx
Normal file
46
frontend/src/components/LoginPage.jsx
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { ArrowRight, LockKeyhole, Mail } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { login } from "../api";
|
||||
import AuthLayout from "./AuthLayout";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
setStatus({ loading: true, error: "" });
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
const result = await login({
|
||||
email: form.get("email"),
|
||||
password: form.get("password"),
|
||||
});
|
||||
sessionStorage.setItem("agmail_access_token", result.access_token);
|
||||
sessionStorage.setItem("agmail_account", JSON.stringify(result.account));
|
||||
window.location.assign("/app");
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
eyebrow="Welcome back"
|
||||
title={<>Your inbox<br /><em>missed you.</em></>}
|
||||
description="Sign in to continue working alongside your agents."
|
||||
>
|
||||
<>
|
||||
<div className="card-heading"><h2>Log in to agmail</h2><p>Enter your account details below.</p></div>
|
||||
<form className="auth-form" onSubmit={handleSubmit}>
|
||||
<label>Email address<div className="input-wrap"><Mail size={16} /><input name="email" type="email" placeholder="you@getall.help" required autoComplete="email" /></div></label>
|
||||
<label>Password<div className="input-wrap"><LockKeyhole size={16} /><input name="password" type="password" placeholder="Your password" required autoComplete="current-password" /></div></label>
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
<button className="button button-coral auth-submit" disabled={status.loading}>
|
||||
{status.loading ? "Signing in…" : "Log in"} <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-switch">New to agmail? <a href="/signup">Create an account</a></p>
|
||||
</>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
110
frontend/src/components/MailDetail.jsx
Normal file
110
frontend/src/components/MailDetail.jsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import {
|
||||
ArrowLeft,
|
||||
Mail,
|
||||
MailOpen,
|
||||
RotateCcw,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import SenderAvatar from "./SenderAvatar";
|
||||
import { senderDisplayName } from "../utils/senderAvatar";
|
||||
|
||||
function joinAddresses(value) {
|
||||
if (!value) return "";
|
||||
const addresses = Array.isArray(value) ? value : [value];
|
||||
return addresses
|
||||
.map((item) => (typeof item === "string" ? item : item?.email || item?.address))
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function detailDate(email) {
|
||||
const value =
|
||||
email?.received_at || email?.sent_at || email?.created_at || email?.updated_at;
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default function MailDetail({
|
||||
email,
|
||||
loading,
|
||||
error,
|
||||
mailbox,
|
||||
busy,
|
||||
onClose,
|
||||
onToggleRead,
|
||||
onTrash,
|
||||
onRestore,
|
||||
onDelete,
|
||||
}) {
|
||||
return (
|
||||
<article className={`mail-detail${email || loading ? " open" : ""}`} aria-live="polite">
|
||||
<header className="mail-detail-toolbar">
|
||||
<button type="button" onClick={onClose} aria-label="Back to message list">
|
||||
<ArrowLeft size={18} />
|
||||
</button>
|
||||
<div>
|
||||
{email && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => onToggleRead(!email.is_read)}
|
||||
title={email.is_read ? "Mark unread" : "Mark read"}
|
||||
aria-label={email.is_read ? "Mark unread" : "Mark read"}
|
||||
>
|
||||
{email.is_read ? <Mail size={17} /> : <MailOpen size={17} />}
|
||||
</button>
|
||||
)}
|
||||
{email && mailbox === "trash" && (
|
||||
<button type="button" disabled={busy} onClick={onRestore} title="Restore" aria-label="Restore message">
|
||||
<RotateCcw size={17} />
|
||||
</button>
|
||||
)}
|
||||
{email && (
|
||||
<button
|
||||
className={mailbox === "trash" ? "danger" : ""}
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={mailbox === "trash" ? onDelete : onTrash}
|
||||
title={mailbox === "trash" ? "Delete permanently" : "Move to trash"}
|
||||
aria-label={mailbox === "trash" ? "Delete message permanently" : "Move message to trash"}
|
||||
>
|
||||
<Trash2 size={17} />
|
||||
</button>
|
||||
)}
|
||||
<button className="mail-detail-close" type="button" onClick={onClose} aria-label="Close message">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{loading && <div className="mail-detail-state">Opening message…</div>}
|
||||
{error && <div className="mail-detail-state error" role="alert">{error}</div>}
|
||||
{email && !loading && (
|
||||
<div className="mail-detail-content">
|
||||
<h2>{email.subject?.trim() || "(No subject)"}</h2>
|
||||
<div className="mail-detail-meta">
|
||||
<div>
|
||||
<div className="mail-detail-sender">
|
||||
<SenderAvatar email={email} />
|
||||
<strong>{senderDisplayName(email)}</strong>
|
||||
</div>
|
||||
<small>From: {joinAddresses(email.sender) || "Unknown"}</small>
|
||||
<small>To: {joinAddresses(email.recipients || email.to) || "Undisclosed"}</small>
|
||||
{joinAddresses(email.cc) && <small>Cc: {joinAddresses(email.cc)}</small>}
|
||||
</div>
|
||||
<time>{detailDate(email)}</time>
|
||||
</div>
|
||||
<div className="mail-detail-body">
|
||||
{email.body_text || email.text_body || email.plain_text || "This message has no plain-text content."}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
75
frontend/src/components/MailRow.jsx
Normal file
75
frontend/src/components/MailRow.jsx
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { MailOpen, Star } from "lucide-react";
|
||||
import SenderAvatar from "./SenderAvatar";
|
||||
import { senderDisplayName } from "../utils/senderAvatar";
|
||||
|
||||
function formatTimestamp(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
const now = new Date();
|
||||
const sameDay = date.toDateString() === now.toDateString();
|
||||
if (sameDay) {
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
...(date.getFullYear() !== now.getFullYear() ? { year: "numeric" } : {}),
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export default function MailRow({ email, selected, onOpen, onToggleStar }) {
|
||||
const unread = email.is_read === false;
|
||||
const starred = email.is_starred === true;
|
||||
const subject = email.subject?.trim() || "(No subject)";
|
||||
const snippet = email.snippet || email.preview || email.body_text || "";
|
||||
const timestamp =
|
||||
email.received_at || email.sent_at || email.created_at || email.updated_at;
|
||||
const senderName = senderDisplayName(email);
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onOpen(email);
|
||||
}
|
||||
}
|
||||
|
||||
function handleStarClick(event) {
|
||||
event.stopPropagation();
|
||||
onToggleStar?.(email, !starred);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mailbox-row${unread ? " unread" : ""}${selected ? " selected" : ""}${starred ? " starred" : ""}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => onOpen(email)}
|
||||
onKeyDown={handleKeyDown}
|
||||
aria-label={`${unread ? "Unread" : "Read"} email from ${senderName}: ${subject}`}
|
||||
>
|
||||
<span className="mailbox-read-dot" aria-hidden="true">
|
||||
{unread ? <i /> : <MailOpen size={14} />}
|
||||
</span>
|
||||
<SenderAvatar email={email} className="mailbox-avatar" />
|
||||
<span className="mailbox-sender">{senderName}</span>
|
||||
<span className="mailbox-summary">
|
||||
<strong>{subject}</strong>
|
||||
{snippet && <span><b aria-hidden="true">—</b> {snippet}</span>}
|
||||
</span>
|
||||
<time dateTime={timestamp || undefined}>{formatTimestamp(timestamp)}</time>
|
||||
<button
|
||||
type="button"
|
||||
className={`mailbox-star${starred ? " active" : ""}`}
|
||||
onClick={handleStarClick}
|
||||
aria-label={starred ? "Unstar message" : "Star message"}
|
||||
aria-pressed={starred}
|
||||
>
|
||||
<Star size={15} fill={starred ? "currentColor" : "none"} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
304
frontend/src/components/MailboxView.jsx
Normal file
304
frontend/src/components/MailboxView.jsx
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Filter,
|
||||
Inbox,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
getEmail,
|
||||
listEmails,
|
||||
permanentlyDeleteEmail,
|
||||
restoreEmail,
|
||||
setEmailRead,
|
||||
setEmailStar,
|
||||
trashEmail,
|
||||
} from "../api";
|
||||
import MailDetail from "./MailDetail";
|
||||
import MailRow from "./MailRow";
|
||||
import StickyNotesPanel from "./StickyNotesPanel";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
function resultPage(result) {
|
||||
if (Array.isArray(result)) return { items: result, nextCursor: null };
|
||||
return {
|
||||
items: result.items || result.emails || result.results || [],
|
||||
nextCursor: result.next_cursor || result.nextCursor || null,
|
||||
};
|
||||
}
|
||||
|
||||
export default function MailboxView({ mailbox, title, refreshKey = 0 }) {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [emails, setEmails] = useState([]);
|
||||
const [nextCursor, setNextCursor] = useState(null);
|
||||
const [cursor, setCursor] = useState(null);
|
||||
const [cursorHistory, setCursorHistory] = useState([]);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [status, setStatus] = useState({ loading: true, error: "" });
|
||||
const [detailStatus, setDetailStatus] = useState({ loading: false, error: "", busy: false });
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
const [filters, setFilters] = useState({
|
||||
query: "",
|
||||
read: "all",
|
||||
sender: "",
|
||||
dateFrom: "",
|
||||
dateTo: "",
|
||||
});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setStatus({ loading: true, error: "" });
|
||||
try {
|
||||
const result = await listEmails(
|
||||
{
|
||||
mailbox,
|
||||
limit: PAGE_SIZE,
|
||||
cursor,
|
||||
q: filters.query.trim(),
|
||||
is_read: filters.read === "all" ? undefined : filters.read === "read",
|
||||
sender: filters.sender.trim(),
|
||||
date_from: filters.dateFrom || undefined,
|
||||
date_to: filters.dateTo || undefined,
|
||||
},
|
||||
token,
|
||||
);
|
||||
const page = resultPage(result);
|
||||
setEmails(page.items);
|
||||
setNextCursor(page.nextCursor);
|
||||
setStatus({ loading: false, error: "" });
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message });
|
||||
}
|
||||
}, [cursor, filters, mailbox, token]);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = window.setTimeout(load, filters.query ? 250 : 0);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [load, refreshKey]);
|
||||
|
||||
function changeFilter(name, value) {
|
||||
setFilters((current) => ({ ...current, [name]: value }));
|
||||
setCursor(null);
|
||||
setCursorHistory([]);
|
||||
}
|
||||
|
||||
async function openEmail(email) {
|
||||
setSelected(email.id);
|
||||
setDetail(email);
|
||||
setDetailStatus({ loading: true, error: "", busy: false });
|
||||
if (email.is_read === false) {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_read: true } : item),
|
||||
);
|
||||
setDetail((current) => ({ ...current, is_read: true }));
|
||||
setEmailRead(email.id, true, token).catch(() => {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_read: false } : item),
|
||||
);
|
||||
setDetail((current) =>
|
||||
current?.id === email.id ? { ...current, is_read: false } : current,
|
||||
);
|
||||
});
|
||||
}
|
||||
try {
|
||||
const completeEmail = await getEmail(email.id, token);
|
||||
setDetail((current) => ({ ...current, ...completeEmail, is_read: current?.is_read ?? completeEmail.is_read }));
|
||||
setDetailStatus({ loading: false, error: "", busy: false });
|
||||
} catch (error) {
|
||||
setDetailStatus({ loading: false, error: error.message, busy: false });
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStar(email, isStarred) {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_starred: isStarred } : item),
|
||||
);
|
||||
if (detail?.id === email.id) {
|
||||
setDetail((current) => ({ ...current, is_starred: isStarred }));
|
||||
}
|
||||
try {
|
||||
await setEmailStar(email.id, isStarred, token);
|
||||
} catch (error) {
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === email.id ? { ...item, is_starred: !isStarred } : item),
|
||||
);
|
||||
if (detail?.id === email.id) {
|
||||
setDetail((current) => ({ ...current, is_starred: !isStarred }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleRead(isRead) {
|
||||
if (!detail) return;
|
||||
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
|
||||
try {
|
||||
await setEmailRead(detail.id, isRead, token);
|
||||
setDetail((current) => ({ ...current, is_read: isRead }));
|
||||
setEmails((current) =>
|
||||
current.map((item) => item.id === detail.id ? { ...item, is_read: isRead } : item),
|
||||
);
|
||||
setDetailStatus((current) => ({ ...current, busy: false }));
|
||||
} catch (error) {
|
||||
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
async function mutateSelected(action) {
|
||||
if (!detail) return;
|
||||
setDetailStatus((current) => ({ ...current, busy: true, error: "" }));
|
||||
try {
|
||||
await action(detail.id, token);
|
||||
setEmails((current) => current.filter((item) => item.id !== detail.id));
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
setDetailStatus({ loading: false, error: "", busy: false });
|
||||
} catch (error) {
|
||||
setDetailStatus((current) => ({ ...current, busy: false, error: error.message }));
|
||||
}
|
||||
}
|
||||
|
||||
function deletePermanently() {
|
||||
if (window.confirm("Permanently delete this message? This cannot be undone.")) {
|
||||
mutateSelected(permanentlyDeleteEmail);
|
||||
}
|
||||
}
|
||||
|
||||
function nextPage() {
|
||||
if (!nextCursor) return;
|
||||
setCursorHistory((history) => [...history, cursor]);
|
||||
setCursor(nextCursor);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
}
|
||||
|
||||
function previousPage() {
|
||||
if (!cursorHistory.length) return;
|
||||
const previous = cursorHistory[cursorHistory.length - 1];
|
||||
setCursorHistory((history) => history.slice(0, -1));
|
||||
setCursor(previous);
|
||||
setSelected(null);
|
||||
setDetail(null);
|
||||
}
|
||||
|
||||
const hasFilters =
|
||||
filters.read !== "all" || filters.sender || filters.dateFrom || filters.dateTo;
|
||||
|
||||
return (
|
||||
<section className="mailbox-workspace">
|
||||
<div className="mailbox-main-column">
|
||||
<div className="dashboard-content-heading">
|
||||
<div><p>Personal workspace</p><h1>{title}</h1></div>
|
||||
<span className="dashboard-account-pill">Individual</span>
|
||||
</div>
|
||||
<div className={`mailbox-shell${selected ? " detail-visible" : ""}`}>
|
||||
<div className="mailbox-list-pane">
|
||||
<div className="mailbox-toolbar">
|
||||
<label>
|
||||
<Search size={16} />
|
||||
<input
|
||||
type="search"
|
||||
value={filters.query}
|
||||
onChange={(event) => changeFilter("query", event.target.value)}
|
||||
placeholder={`Search ${title.toLowerCase()}`}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className={filtersOpen || hasFilters ? "active" : ""}
|
||||
type="button"
|
||||
onClick={() => setFiltersOpen((open) => !open)}
|
||||
aria-expanded={filtersOpen}
|
||||
>
|
||||
<Filter size={16} /> <span>Filters</span>
|
||||
</button>
|
||||
<button type="button" onClick={load} aria-label="Refresh messages" title="Refresh">
|
||||
<RefreshCw size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filtersOpen && (
|
||||
<div className="mailbox-filters">
|
||||
<label>Read status
|
||||
<select value={filters.read} onChange={(event) => changeFilter("read", event.target.value)}>
|
||||
<option value="all">All messages</option>
|
||||
<option value="unread">Unread</option>
|
||||
<option value="read">Read</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Sender
|
||||
<input value={filters.sender} onChange={(event) => changeFilter("sender", event.target.value)} placeholder="name@example.com" />
|
||||
</label>
|
||||
<label>From
|
||||
<input type="date" value={filters.dateFrom} onChange={(event) => changeFilter("dateFrom", event.target.value)} />
|
||||
</label>
|
||||
<label>To
|
||||
<input type="date" value={filters.dateTo} onChange={(event) => changeFilter("dateTo", event.target.value)} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mailbox-rows" aria-busy={status.loading}>
|
||||
{status.loading && <div className="mailbox-state">Loading messages…</div>}
|
||||
{status.error && (
|
||||
<div className="mailbox-state error" role="alert">
|
||||
<p>{status.error}</p><button type="button" onClick={load}>Try again</button>
|
||||
</div>
|
||||
)}
|
||||
{!status.loading && !status.error && emails.length === 0 && (
|
||||
<div className="mailbox-state empty">
|
||||
<span><Inbox size={22} /></span>
|
||||
<strong>No messages here</strong>
|
||||
<p>{hasFilters || filters.query ? "Try changing your search or filters." : `Your ${title.toLowerCase()} is empty.`}</p>
|
||||
</div>
|
||||
)}
|
||||
{!status.loading && !status.error && emails.map((email) => (
|
||||
<MailRow
|
||||
key={email.id}
|
||||
email={email}
|
||||
selected={selected === email.id}
|
||||
onOpen={openEmail}
|
||||
onToggleStar={toggleStar}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!status.loading && !status.error && emails.length > 0 && (
|
||||
<footer className="mailbox-list-footer">
|
||||
<p>
|
||||
{emails.length} message{emails.length === 1 ? "" : "s"} in this view
|
||||
{filters.query.trim() ? " · filtered" : ""}
|
||||
</p>
|
||||
<span>You're all caught up</span>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
{(cursorHistory.length > 0 || nextCursor) && (
|
||||
<footer className="mailbox-pagination">
|
||||
<button type="button" disabled={!cursorHistory.length} onClick={previousPage}><ChevronLeft size={16} /> Previous</button>
|
||||
<span>Page {cursorHistory.length + 1}</span>
|
||||
<button type="button" disabled={!nextCursor} onClick={nextPage}>Next <ChevronRight size={16} /></button>
|
||||
</footer>
|
||||
)}
|
||||
|
||||
<MailDetail
|
||||
email={detail}
|
||||
loading={detailStatus.loading}
|
||||
error={detailStatus.error}
|
||||
busy={detailStatus.busy}
|
||||
mailbox={mailbox}
|
||||
onClose={() => { setSelected(null); setDetail(null); }}
|
||||
onToggleRead={toggleRead}
|
||||
onTrash={() => mutateSelected(trashEmail)}
|
||||
onRestore={() => mutateSelected(restoreEmail)}
|
||||
onDelete={deletePermanently}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StickyNotesPanel />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
34
frontend/src/components/MobileLanding.jsx
Normal file
34
frontend/src/components/MobileLanding.jsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { ArrowRight, Bot, Check, Inbox, Menu, Send, Sparkles } from "lucide-react";
|
||||
import Brand from "./Brand";
|
||||
|
||||
export default function MobileLanding() {
|
||||
return (
|
||||
<main className="mobile-landing">
|
||||
<nav className="mobile-nav">
|
||||
<Brand />
|
||||
<button className="icon-button" aria-label="Open menu"><Menu size={21} /></button>
|
||||
</nav>
|
||||
<section className="mobile-hero">
|
||||
<div className="eyebrow"><Sparkles size={13} /> Email for humans + agents</div>
|
||||
<h1>Your inbox,<br /><em>one step ahead.</em></h1>
|
||||
<p>A focused home for every message, task, and AI agent you work with.</p>
|
||||
<a className="button button-coral" href="/signup">Create your agmail <ArrowRight size={17} /></a>
|
||||
<a className="mobile-login" href="/login">Already have an account? <strong>Log in</strong></a>
|
||||
</section>
|
||||
<section className="phone-preview" aria-label="Agmail mobile inbox preview">
|
||||
<div className="phone-top"><span>9:41</span><span className="phone-pill" /><span>•••</span></div>
|
||||
<header><div><small>Good morning</small><strong>Alex</strong></div><span className="avatar">AK</span></header>
|
||||
<div className="mobile-summary">
|
||||
<span className="agent-icon"><Bot size={18} /></span>
|
||||
<div><strong>Your agent caught you up</strong><small>8 emails handled this morning</small></div>
|
||||
<ArrowRight size={15} />
|
||||
</div>
|
||||
<div className="mobile-inbox-title"><h3>Priority inbox</h3><span>4 new</span></div>
|
||||
<article className="mobile-mail"><span className="sender-avatar coral">R</span><div><strong>Research agent</strong><p>Market brief is ready</p></div><time>10:42</time></article>
|
||||
<article className="mobile-mail"><span className="sender-avatar blue">M</span><div><strong>Maya Chen</strong><p>Re: Product direction</p></div><time>9:18</time></article>
|
||||
<div className="mobile-tabbar"><span className="active"><Inbox size={19} /></span><button><span>+</span></button><span><Send size={19} /></span></div>
|
||||
</section>
|
||||
<section className="mobile-benefits"><span><Check size={15} /> Free to start</span><span><Check size={15} /> Private by design</span></section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
23
frontend/src/components/SenderAvatar.jsx
Normal file
23
frontend/src/components/SenderAvatar.jsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { senderAvatar } from "../utils/senderAvatar";
|
||||
|
||||
export default function SenderAvatar({ email, className = "" }) {
|
||||
const { name, initials, color, imageUrl } = senderAvatar(email);
|
||||
|
||||
if (imageUrl) {
|
||||
return (
|
||||
<span className={`sender-avatar${className ? ` ${className}` : ""}`}>
|
||||
<img src={imageUrl} alt="" aria-hidden="true" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`sender-avatar${className ? ` ${className}` : ""}`}
|
||||
style={{ backgroundColor: color }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{initials}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
80
frontend/src/components/SignupPage.jsx
Normal file
80
frontend/src/components/SignupPage.jsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import {
|
||||
ArrowRight, Building2, CalendarDays, Eye, LockKeyhole, Mail, Phone, UserRound,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { register } from "../api";
|
||||
import AuthLayout from "./AuthLayout";
|
||||
|
||||
export default function SignupPage() {
|
||||
const [accountType, setAccountType] = useState("individual");
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
setStatus({ loading: true, error: "" });
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
const result = await register({
|
||||
account_type: form.get("account_type"),
|
||||
email_name: form.get("email_name"),
|
||||
company_domain: form.get("company_domain") || null,
|
||||
first_name: form.get("first_name"),
|
||||
last_name: form.get("last_name"),
|
||||
date_of_birth: form.get("date_of_birth"),
|
||||
phone_number: form.get("phone_number"),
|
||||
password: form.get("password"),
|
||||
});
|
||||
sessionStorage.setItem("agmail_access_token", result.access_token);
|
||||
sessionStorage.setItem("agmail_account", JSON.stringify(result.account));
|
||||
window.location.assign("/app");
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
eyebrow="Your new digital address"
|
||||
title={<>An inbox built<br /><em>around you.</em></>}
|
||||
description="Create a personal address or bring your company's domain."
|
||||
>
|
||||
<>
|
||||
<div className="card-heading"><span>01 — Account setup</span><h2>Create your account</h2><p>Every field is securely processed by agmail.</p></div>
|
||||
<form className="auth-form signup-form" onSubmit={handleSubmit}>
|
||||
<fieldset>
|
||||
<legend>Account type</legend>
|
||||
<div className="account-types">
|
||||
<label className={accountType === "individual" ? "type-card selected" : "type-card"}>
|
||||
<input type="radio" name="account_type" value="individual" checked={accountType === "individual"} onChange={(event) => setAccountType(event.target.value)} />
|
||||
<UserRound size={19} /><span><strong>Individual</strong><small>Your personal agent inbox</small></span>
|
||||
</label>
|
||||
<label className={accountType === "company" ? "type-card selected" : "type-card"}>
|
||||
<input type="radio" name="account_type" value="company" checked={accountType === "company"} onChange={(event) => setAccountType(event.target.value)} />
|
||||
<Building2 size={19} /><span><strong>Company</strong><small>Use your business domain</small></span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{accountType === "company" && (
|
||||
<label>Company domain<div className="input-wrap"><Building2 size={16} /><input name="company_domain" placeholder="yourcompany.com" required /></div></label>
|
||||
)}
|
||||
<label>Choose your email name
|
||||
<div className="email-builder input-wrap"><Mail size={16} /><input name="email_name" placeholder="alex" pattern="[A-Za-z0-9](?:[A-Za-z0-9._-]{0,62}[A-Za-z0-9])?" required /><span>{accountType === "individual" ? "@getall.help" : "@your domain"}</span></div>
|
||||
</label>
|
||||
<div className="field-row">
|
||||
<label>First name<div className="input-wrap"><UserRound size={16} /><input name="first_name" placeholder="Alex" required /></div></label>
|
||||
<label>Last name<div className="input-wrap"><UserRound size={16} /><input name="last_name" placeholder="Morgan" required /></div></label>
|
||||
</div>
|
||||
<label>Date of birth<div className="input-wrap"><CalendarDays size={16} /><input name="date_of_birth" type="date" required /></div></label>
|
||||
<label>Phone number<div className="input-wrap"><Phone size={16} /><input name="phone_number" type="tel" placeholder="+1 555 123 4567" required /></div></label>
|
||||
<label>Password<div className="input-wrap"><LockKeyhole size={16} /><input name="password" type="password" placeholder="8+ characters" minLength="8" required autoComplete="new-password" /><Eye size={15} /></div><small className="field-hint">Use uppercase, lowercase, and a number.</small></label>
|
||||
{status.error && <p className="form-error" role="alert">{status.error}</p>}
|
||||
<button className="button button-coral auth-submit" disabled={status.loading}>
|
||||
{status.loading ? "Creating account…" : "Create account"} <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
<p className="auth-switch">Already have an account? <a href="/login">Log in</a></p>
|
||||
</>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
271
frontend/src/components/SmtpCredentialsPage.jsx
Normal file
271
frontend/src/components/SmtpCredentialsPage.jsx
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { ArrowRight, Copy, Globe, Server } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { previewDnsSetup } from "../api";
|
||||
import AuthLayout from "./AuthLayout";
|
||||
|
||||
const STORAGE_KEY = "agmail_dns_setup_auth";
|
||||
|
||||
function loadStoredAuth() {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed?.username || !parsed?.password) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function DnsRecordTable({ title, records }) {
|
||||
if (!records?.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function copyValue(value) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
} catch {
|
||||
/* clipboard may be unavailable */
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="dns-section">
|
||||
<h3>{title}</h3>
|
||||
<div className="dns-table-wrap">
|
||||
<table className="dns-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Host</th>
|
||||
<th>Value</th>
|
||||
<th>TTL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record) => {
|
||||
const rowKey = `${record.record_type}-${record.host}-${record.purpose}`;
|
||||
const displayValue = record.priority
|
||||
? `${record.priority} ${record.value}`
|
||||
: record.value;
|
||||
return (
|
||||
<tr key={rowKey}>
|
||||
<td>{record.record_type}</td>
|
||||
<td><code>{record.host}</code></td>
|
||||
<td>
|
||||
<div className="dns-value-cell">
|
||||
<code>{displayValue}</code>
|
||||
<button
|
||||
type="button"
|
||||
className="dns-copy"
|
||||
aria-label="Copy value"
|
||||
onClick={() => copyValue(displayValue)}
|
||||
>
|
||||
<Copy size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td>{record.ttl}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SmtpCredentialsPage() {
|
||||
const [auth, setAuth] = useState(() => loadStoredAuth());
|
||||
const [status, setStatus] = useState({ loading: false, error: "" });
|
||||
const [result, setResult] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth) {
|
||||
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(auth));
|
||||
} else {
|
||||
sessionStorage.removeItem(STORAGE_KEY);
|
||||
setResult(null);
|
||||
}
|
||||
}, [auth]);
|
||||
|
||||
async function handleLogin(event) {
|
||||
event.preventDefault();
|
||||
setStatus({ loading: true, error: "" });
|
||||
const form = new FormData(event.currentTarget);
|
||||
const nextAuth = {
|
||||
username: String(form.get("username") || ""),
|
||||
password: String(form.get("password") || ""),
|
||||
};
|
||||
try {
|
||||
await previewDnsSetup(
|
||||
{ domain: "example.com", mail_server_ip: "127.0.0.1" },
|
||||
nextAuth,
|
||||
);
|
||||
setAuth(nextAuth);
|
||||
setStatus({ loading: false, error: "" });
|
||||
} catch (error) {
|
||||
setStatus({ loading: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
async function handleGenerate(event) {
|
||||
event.preventDefault();
|
||||
if (!auth) {
|
||||
return;
|
||||
}
|
||||
setStatus({ loading: true, error: "" });
|
||||
const form = new FormData(event.currentTarget);
|
||||
try {
|
||||
const data = await previewDnsSetup(
|
||||
{
|
||||
domain: String(form.get("domain") || ""),
|
||||
mail_server_ip: String(form.get("mail_server_ip") || ""),
|
||||
},
|
||||
auth,
|
||||
);
|
||||
setResult(data);
|
||||
setStatus({ loading: false, error: "" });
|
||||
} catch (error) {
|
||||
if (error.message.toLowerCase().includes("credential")) {
|
||||
setAuth(null);
|
||||
}
|
||||
setStatus({ loading: false, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (!auth) {
|
||||
return (
|
||||
<AuthLayout
|
||||
eyebrow="Operations"
|
||||
title={<>DNS setup<br /><em>for mail.</em></>}
|
||||
description="Sign in to generate Namecheap-ready DNS records for self-hosted inbound and outbound mail."
|
||||
>
|
||||
<>
|
||||
<div className="card-heading">
|
||||
<h2>DNS setup access</h2>
|
||||
<p>Restricted to deployment operators.</p>
|
||||
</div>
|
||||
<form className="auth-form" onSubmit={handleLogin}>
|
||||
<label>
|
||||
Username
|
||||
<div className="input-wrap">
|
||||
<Server size={16} />
|
||||
<input
|
||||
name="username"
|
||||
type="text"
|
||||
placeholder="smtp-credentia"
|
||||
required
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
Passkey
|
||||
<div className="input-wrap">
|
||||
<Globe size={16} />
|
||||
<input
|
||||
name="password"
|
||||
type="password"
|
||||
placeholder="Your passkey"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{status.error && (
|
||||
<p className="form-error" role="alert">{status.error}</p>
|
||||
)}
|
||||
<button className="button button-coral auth-submit" disabled={status.loading}>
|
||||
{status.loading ? "Checking…" : "Continue"} <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
</>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthLayout
|
||||
eyebrow="Operations"
|
||||
title={<>DNS records<br /><em>ready to paste.</em></>}
|
||||
description="Enter the master domain and this server's public IP. Records are formatted for Namecheap Advanced DNS."
|
||||
>
|
||||
<>
|
||||
<div className="card-heading">
|
||||
<h2>Generate DNS configuration</h2>
|
||||
<p>Inbound delivery and outbound authentication records for this mail host.</p>
|
||||
</div>
|
||||
<form className="auth-form" onSubmit={handleGenerate}>
|
||||
<label>
|
||||
Master domain
|
||||
<div className="input-wrap">
|
||||
<Globe size={16} />
|
||||
<input
|
||||
name="domain"
|
||||
type="text"
|
||||
placeholder="getall.help"
|
||||
defaultValue={result?.domain || "getall.help"}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
<label>
|
||||
Mail server public IP
|
||||
<div className="input-wrap">
|
||||
<Server size={16} />
|
||||
<input
|
||||
name="mail_server_ip"
|
||||
type="text"
|
||||
placeholder="203.0.113.10"
|
||||
defaultValue={result?.mail_server_ip || ""}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</label>
|
||||
{status.error && (
|
||||
<p className="form-error" role="alert">{status.error}</p>
|
||||
)}
|
||||
<button className="button button-coral auth-submit" disabled={status.loading}>
|
||||
{status.loading ? "Generating…" : "Generate DNS records"} <ArrowRight size={16} />
|
||||
</button>
|
||||
</form>
|
||||
{result && (
|
||||
<div className="dns-results">
|
||||
<p className="dns-summary">
|
||||
<strong>{result.mail_host}</strong> → {result.mail_server_ip}
|
||||
{result.app_public_host && (
|
||||
<> · web UI at <strong>{result.app_public_host}</strong></>
|
||||
)}
|
||||
{" · self-hosted inbound + outbound"}
|
||||
</p>
|
||||
<ul className="dns-notes">
|
||||
{result.notes.map((note) => (
|
||||
<li key={note}>{note}</li>
|
||||
))}
|
||||
</ul>
|
||||
<DnsRecordTable
|
||||
title="Inbound (MX, A — deliver mail to this host)"
|
||||
records={result.sections.inbound}
|
||||
/>
|
||||
<DnsRecordTable
|
||||
title="Outbound (SPF, DKIM, DMARC — authenticate sending)"
|
||||
records={result.sections.outbound}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<p className="auth-switch">
|
||||
<button type="button" className="link-button" onClick={() => setAuth(null)}>
|
||||
Sign out
|
||||
</button>
|
||||
</p>
|
||||
</>
|
||||
</AuthLayout>
|
||||
);
|
||||
}
|
||||
283
frontend/src/components/StickyNotesPanel.jsx
Normal file
283
frontend/src/components/StickyNotesPanel.jsx
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
import {
|
||||
MoreHorizontal,
|
||||
Pin,
|
||||
Plus,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
createStickyNote,
|
||||
deleteStickyNote,
|
||||
listStickyNotes,
|
||||
} from "../api";
|
||||
import bluePin from "../data/blue.png";
|
||||
import greenPin from "../data/green.png";
|
||||
import pinkPin from "../data/pink.png";
|
||||
import yellowPin from "../data/yellow.png";
|
||||
|
||||
const NOTE_COLORS = ["yellow", "green", "blue", "pink"];
|
||||
const PIN_IMAGES = {
|
||||
yellow: yellowPin,
|
||||
green: greenPin,
|
||||
blue: bluePin,
|
||||
pink: pinkPin,
|
||||
};
|
||||
|
||||
function formatNoteTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "";
|
||||
return new Intl.DateTimeFormat(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function NoteCard({ note, onDelete, menuOpen, onMenuToggle }) {
|
||||
return (
|
||||
<article className={`sticky-note sticky-note-${note.color}`}>
|
||||
<span className="sticky-note-pin" aria-hidden="true">
|
||||
<img src={PIN_IMAGES[note.color] || yellowPin} alt="" />
|
||||
</span>
|
||||
<header className="sticky-note-header">
|
||||
<h3>{note.title}</h3>
|
||||
<div className="sticky-note-menu-wrap">
|
||||
<button
|
||||
type="button"
|
||||
className="sticky-note-menu-btn"
|
||||
aria-label="Note options"
|
||||
aria-expanded={menuOpen}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onMenuToggle(note.id);
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal size={16} />
|
||||
</button>
|
||||
{menuOpen && (
|
||||
<div className="sticky-note-menu">
|
||||
<button type="button" onClick={() => onDelete(note.id)}>
|
||||
<Trash2 size={14} /> Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
{note.body && <p>{note.body}</p>}
|
||||
<footer className="sticky-note-footer">
|
||||
<div className="sticky-note-tags">
|
||||
{(note.tags.length ? note.tags : ["General"]).map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<time dateTime={note.updated_at}>{formatNoteTime(note.updated_at)}</time>
|
||||
</footer>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export default function StickyNotesPanel() {
|
||||
const token = sessionStorage.getItem("agmail_access_token");
|
||||
const [notes, setNotes] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [menuOpenId, setMenuOpenId] = useState(null);
|
||||
const [draft, setDraft] = useState({
|
||||
title: "",
|
||||
body: "",
|
||||
tags: "",
|
||||
color: "yellow",
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const items = await listStickyNotes(token);
|
||||
setNotes(items);
|
||||
} catch (loadError) {
|
||||
setError(loadError.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
function closeMenu() {
|
||||
setMenuOpenId(null);
|
||||
}
|
||||
window.addEventListener("click", closeMenu);
|
||||
return () => window.removeEventListener("click", closeMenu);
|
||||
}, []);
|
||||
|
||||
async function handleCreate(event) {
|
||||
event.preventDefault();
|
||||
if (!draft.title.trim()) return;
|
||||
setSaving(true);
|
||||
setError("");
|
||||
try {
|
||||
const created = await createStickyNote(
|
||||
{
|
||||
title: draft.title.trim(),
|
||||
body: draft.body.trim(),
|
||||
tags: draft.tags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean),
|
||||
color: draft.color,
|
||||
},
|
||||
token,
|
||||
);
|
||||
setNotes((current) => [created, ...current]);
|
||||
setDraft({
|
||||
title: "",
|
||||
body: "",
|
||||
tags: "",
|
||||
color: NOTE_COLORS[(notes.length + 1) % NOTE_COLORS.length],
|
||||
});
|
||||
setAdding(false);
|
||||
} catch (createError) {
|
||||
setError(createError.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(noteId) {
|
||||
setMenuOpenId(null);
|
||||
setError("");
|
||||
try {
|
||||
await deleteStickyNote(noteId, token);
|
||||
setNotes((current) => current.filter((note) => note.id !== noteId));
|
||||
} catch (deleteError) {
|
||||
setError(deleteError.message);
|
||||
}
|
||||
}
|
||||
|
||||
function handleBodyKeyDown(event) {
|
||||
if (event.key !== "Enter" || event.shiftKey) return;
|
||||
event.preventDefault();
|
||||
const textarea = event.currentTarget;
|
||||
const { selectionStart, selectionEnd, value } = textarea;
|
||||
const insertion = "\n• ";
|
||||
const nextValue =
|
||||
value.slice(0, selectionStart) + insertion + value.slice(selectionEnd);
|
||||
setDraft((current) => ({ ...current, body: nextValue }));
|
||||
window.requestAnimationFrame(() => {
|
||||
textarea.selectionStart = selectionStart + insertion.length;
|
||||
textarea.selectionEnd = selectionStart + insertion.length;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="mailbox-notes-pane" aria-label="Sticky notes">
|
||||
<header className="sticky-notes-header">
|
||||
<div className="sticky-notes-title">
|
||||
<Pin size={15} />
|
||||
<h2>Sticky Notes</h2>
|
||||
</div>
|
||||
<div className="sticky-notes-actions">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Add sticky note"
|
||||
onClick={() => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
color: NOTE_COLORS[notes.length % NOTE_COLORS.length],
|
||||
}));
|
||||
setAdding(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="sticky-notes-body">
|
||||
{loading && <div className="sticky-notes-state">Loading notes…</div>}
|
||||
{error && !loading && (
|
||||
<div className="sticky-notes-state error" role="alert">{error}</div>
|
||||
)}
|
||||
{!loading && notes.map((note) => (
|
||||
<NoteCard
|
||||
key={note.id}
|
||||
note={note}
|
||||
menuOpen={menuOpenId === note.id}
|
||||
onMenuToggle={(id) => setMenuOpenId((current) => (current === id ? null : id))}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
))}
|
||||
|
||||
{adding ? (
|
||||
<form className="sticky-note-form" onSubmit={handleCreate}>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.title}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))}
|
||||
placeholder="Note title"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<textarea
|
||||
value={draft.body}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, body: event.target.value }))}
|
||||
onKeyDown={handleBodyKeyDown}
|
||||
placeholder="Jot down a quick note…"
|
||||
rows={3}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={draft.tags}
|
||||
onChange={(event) => setDraft((current) => ({ ...current, tags: event.target.value }))}
|
||||
placeholder="Tags (comma separated)"
|
||||
/>
|
||||
<div className="sticky-note-colors">
|
||||
{NOTE_COLORS.map((color) => (
|
||||
<button
|
||||
key={color}
|
||||
type="button"
|
||||
className={`sticky-note-color${draft.color === color ? " active" : ""}`}
|
||||
data-color={color}
|
||||
aria-label={`${color} note`}
|
||||
onClick={() => setDraft((current) => ({ ...current, color }))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="sticky-note-form-actions">
|
||||
<button type="button" onClick={() => setAdding(false)} disabled={saving}>
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" disabled={saving || !draft.title.trim()}>
|
||||
{saving ? "Saving…" : "Save note"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="sticky-note-add"
|
||||
onClick={() => {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
color: NOTE_COLORS[notes.length % NOTE_COLORS.length],
|
||||
}));
|
||||
setAdding(true);
|
||||
}}
|
||||
>
|
||||
<Plus size={20} />
|
||||
<span>Add sticky note</span>
|
||||
<small>Jot down a quick note…</small>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
65
frontend/src/components/WebLanding.jsx
Normal file
65
frontend/src/components/WebLanding.jsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import {
|
||||
ArrowRight, Bot, Check, ChevronDown, Clock3, Inbox, Menu,
|
||||
Paperclip, Search, Send, ShieldCheck, Sparkles,
|
||||
} from "lucide-react";
|
||||
import Brand from "./Brand";
|
||||
|
||||
const mail = [
|
||||
{ sender: "Research agent", subject: "Market brief is ready", time: "10:42", color: "coral" },
|
||||
{ sender: "Maya Chen", subject: "Re: Product direction", time: "9:18", color: "blue" },
|
||||
{ sender: "Calendar", subject: "Design review in 30 minutes", time: "8:30", color: "gold" },
|
||||
];
|
||||
|
||||
export default function WebLanding() {
|
||||
return (
|
||||
<main className="landing">
|
||||
<nav className="nav shell">
|
||||
<Brand />
|
||||
<div className="nav-links"><a href="#product">Product</a><a href="#security">Security</a><a href="#about">About</a></div>
|
||||
<div className="nav-actions"><a className="button button-quiet" href="/login">Log in</a><a className="button button-dark" href="/signup">Create account <ArrowRight size={15} /></a></div>
|
||||
</nav>
|
||||
<section className="hero shell">
|
||||
<div className="hero-copy">
|
||||
<div className="eyebrow"><Sparkles size={14} /> Built for you and your agents</div>
|
||||
<h1>Email that works<br /><em>at your speed.</em></h1>
|
||||
<p>A calm, intelligent inbox where you and your AI agents can send, receive, organize, and get work done.</p>
|
||||
<div className="hero-actions"><a className="button button-coral" href="/signup">Get your @agmail <ArrowRight size={17} /></a><a className="button button-outline" href="#product">See how it works</a></div>
|
||||
<div className="trust-line"><span><Check size={13} /> Free to start</span><span><Check size={13} /> No card required</span></div>
|
||||
</div>
|
||||
<div className="product-stage" aria-label="Agmail inbox preview">
|
||||
<div className="orb orb-one" /><div className="orb orb-two" />
|
||||
<div className="inbox-window">
|
||||
<aside className="inbox-sidebar">
|
||||
<div className="mini-brand"><Brand compact /></div>
|
||||
<button className="compose"><span>+</span></button>
|
||||
<div className="side-icons"><span className="active"><Inbox size={18} /></span><span><Send size={18} /></span><span><Clock3 size={18} /></span></div>
|
||||
<div className="avatar">AK</div>
|
||||
</aside>
|
||||
<div className="mail-panel">
|
||||
<header className="mail-toolbar"><div><small>Good morning,</small><strong>Alex</strong></div><div className="toolbar-actions"><Search size={17} /><Menu size={17} /></div></header>
|
||||
<div className="inbox-heading"><div><h3>Inbox</h3><span>12</span></div><button>Priority <ChevronDown size={13} /></button></div>
|
||||
<div className="agent-summary"><div className="agent-icon"><Bot size={19} /></div><div><strong>Your agent handled 8 emails</strong><p>3 replies drafted · 2 tasks completed</p></div><ArrowRight size={16} /></div>
|
||||
<div className="mail-list">
|
||||
{mail.map((item, index) => (
|
||||
<article className={index === 0 ? "mail-item selected" : "mail-item"} key={item.sender}>
|
||||
<span className={`sender-avatar ${item.color}`}>{item.sender[0]}</span><div><strong>{item.sender}</strong><p>{item.subject}</p></div><time>{item.time}</time>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<div className="quick-reply"><Paperclip size={16} /><span>Ask your agent to draft a reply…</span><button><ArrowRight size={15} /></button></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="floating-note"><span><Check size={14} /></span><div><strong>Follow-up sent</strong><small>Handled by your agent</small></div></div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="feature-strip shell" id="product">
|
||||
<div className="feature-intro"><span>One inbox. Less noise.</span><h2>Made for the way work is changing.</h2></div>
|
||||
<div className="feature-grid">
|
||||
<article><span className="feature-icon peach"><Bot size={21} /></span><h3>Agent-ready</h3><p>Give every agent a secure address and clear permissions.</p></article>
|
||||
<article><span className="feature-icon cyan"><Sparkles size={21} /></span><h3>Quietly intelligent</h3><p>Summaries, sorting, and replies—without the clutter.</p></article>
|
||||
<article id="security"><span className="feature-icon violet"><ShieldCheck size={21} /></span><h3>Private by design</h3><p>Your conversations stay encrypted and in your control.</p></article>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
678
frontend/src/data/az_2_letter_color_map.json
Normal file
678
frontend/src/data/az_2_letter_color_map.json
Normal file
|
|
@ -0,0 +1,678 @@
|
|||
{
|
||||
"AA": "#3E0C0C",
|
||||
"AB": "#3F100C",
|
||||
"AC": "#40150B",
|
||||
"AD": "#41190B",
|
||||
"AE": "#421E0B",
|
||||
"AF": "#43230A",
|
||||
"AG": "#44280A",
|
||||
"AH": "#452E0A",
|
||||
"AI": "#463409",
|
||||
"AJ": "#473A09",
|
||||
"AK": "#484008",
|
||||
"AL": "#494708",
|
||||
"AM": "#464908",
|
||||
"AN": "#414A07",
|
||||
"AO": "#3B4B07",
|
||||
"AP": "#364C07",
|
||||
"AQ": "#304E06",
|
||||
"AR": "#2A4F06",
|
||||
"AS": "#245005",
|
||||
"AT": "#1E5105",
|
||||
"AU": "#175204",
|
||||
"AV": "#105304",
|
||||
"AW": "#085404",
|
||||
"AX": "#035505",
|
||||
"AY": "#03560C",
|
||||
"AZ": "#025713",
|
||||
"BA": "#40180C",
|
||||
"BB": "#401D0C",
|
||||
"BC": "#41210B",
|
||||
"BD": "#42260B",
|
||||
"BE": "#432C0B",
|
||||
"BF": "#44310A",
|
||||
"BG": "#45370A",
|
||||
"BH": "#463D0A",
|
||||
"BI": "#474309",
|
||||
"BJ": "#474809",
|
||||
"BK": "#424909",
|
||||
"BL": "#3D4A08",
|
||||
"BM": "#384B08",
|
||||
"BN": "#324C08",
|
||||
"BO": "#2D4D07",
|
||||
"BP": "#274E07",
|
||||
"BQ": "#204F06",
|
||||
"BR": "#1A5006",
|
||||
"BS": "#135105",
|
||||
"BT": "#0C5205",
|
||||
"BU": "#055305",
|
||||
"BV": "#04540B",
|
||||
"BW": "#045512",
|
||||
"BX": "#035619",
|
||||
"BY": "#035820",
|
||||
"BZ": "#025928",
|
||||
"CA": "#41250C",
|
||||
"CB": "#422A0C",
|
||||
"CC": "#432F0C",
|
||||
"CD": "#44340B",
|
||||
"CE": "#453A0B",
|
||||
"CF": "#46400B",
|
||||
"CG": "#47460A",
|
||||
"CH": "#43480A",
|
||||
"CI": "#3E490A",
|
||||
"CJ": "#394A09",
|
||||
"CK": "#344B09",
|
||||
"CL": "#2F4C08",
|
||||
"CM": "#294D08",
|
||||
"CN": "#234E08",
|
||||
"CO": "#1D4F07",
|
||||
"CP": "#175007",
|
||||
"CQ": "#105106",
|
||||
"CR": "#095206",
|
||||
"CS": "#065309",
|
||||
"CT": "#055410",
|
||||
"CU": "#055517",
|
||||
"CV": "#04561E",
|
||||
"CW": "#045725",
|
||||
"CX": "#03582D",
|
||||
"CY": "#035935",
|
||||
"CZ": "#025A3D",
|
||||
"DA": "#42320D",
|
||||
"DB": "#43370C",
|
||||
"DC": "#443D0C",
|
||||
"DD": "#45430C",
|
||||
"DE": "#44460B",
|
||||
"DF": "#3F470B",
|
||||
"DG": "#3B480B",
|
||||
"DH": "#36490A",
|
||||
"DI": "#314A0A",
|
||||
"DJ": "#2B4B09",
|
||||
"DK": "#264C09",
|
||||
"DL": "#204D09",
|
||||
"DM": "#1A4E08",
|
||||
"DN": "#134F08",
|
||||
"DO": "#0D5007",
|
||||
"DP": "#075108",
|
||||
"DQ": "#07520E",
|
||||
"DR": "#065315",
|
||||
"DS": "#06541C",
|
||||
"DT": "#055523",
|
||||
"DU": "#05572A",
|
||||
"DV": "#045832",
|
||||
"DW": "#045939",
|
||||
"DX": "#035A42",
|
||||
"DY": "#035B4A",
|
||||
"DZ": "#025C53",
|
||||
"EA": "#44400D",
|
||||
"EB": "#44450D",
|
||||
"EC": "#40460C",
|
||||
"ED": "#3C470C",
|
||||
"EE": "#37480C",
|
||||
"EF": "#32490B",
|
||||
"EG": "#2D4A0B",
|
||||
"EH": "#284B0A",
|
||||
"EI": "#224C0A",
|
||||
"EJ": "#1D4D0A",
|
||||
"EK": "#174E09",
|
||||
"EL": "#104F09",
|
||||
"EM": "#0A5008",
|
||||
"EN": "#08510D",
|
||||
"EO": "#085213",
|
||||
"EP": "#07531A",
|
||||
"EQ": "#075420",
|
||||
"ER": "#065527",
|
||||
"ES": "#06562F",
|
||||
"ET": "#055736",
|
||||
"EU": "#05583E",
|
||||
"EV": "#045946",
|
||||
"EW": "#045A4E",
|
||||
"EX": "#035B57",
|
||||
"EY": "#03595D",
|
||||
"EZ": "#02525E",
|
||||
"FA": "#3D450D",
|
||||
"FB": "#38460D",
|
||||
"FC": "#34470D",
|
||||
"FD": "#2F480C",
|
||||
"FE": "#2A490C",
|
||||
"FF": "#254A0B",
|
||||
"FG": "#1F4B0B",
|
||||
"FH": "#1A4C0B",
|
||||
"FI": "#144D0A",
|
||||
"FJ": "#0D4E0A",
|
||||
"FK": "#094F0C",
|
||||
"FL": "#095012",
|
||||
"FM": "#095118",
|
||||
"FN": "#08521E",
|
||||
"FO": "#085325",
|
||||
"FP": "#07542C",
|
||||
"FQ": "#075633",
|
||||
"FR": "#06573B",
|
||||
"FS": "#065842",
|
||||
"FT": "#05594A",
|
||||
"FU": "#055A53",
|
||||
"FV": "#045A5B",
|
||||
"FW": "#04545C",
|
||||
"FX": "#034D5D",
|
||||
"FY": "#03465E",
|
||||
"FZ": "#023E5F",
|
||||
"GA": "#31470D",
|
||||
"GB": "#2C480D",
|
||||
"GC": "#27490D",
|
||||
"GD": "#224A0C",
|
||||
"GE": "#1D4B0C",
|
||||
"GF": "#174C0C",
|
||||
"GG": "#114D0B",
|
||||
"GH": "#0B4E0B",
|
||||
"GI": "#0A4F11",
|
||||
"GJ": "#0A5016",
|
||||
"GK": "#0A511D",
|
||||
"GL": "#095223",
|
||||
"GM": "#09532A",
|
||||
"GN": "#085430",
|
||||
"GO": "#085538",
|
||||
"GP": "#07563F",
|
||||
"GQ": "#075747",
|
||||
"GR": "#06584F",
|
||||
"GS": "#065957",
|
||||
"GT": "#06555A",
|
||||
"GU": "#054F5B",
|
||||
"GV": "#05485D",
|
||||
"GW": "#04415E",
|
||||
"GX": "#04395F",
|
||||
"GY": "#033160",
|
||||
"GZ": "#022961",
|
||||
"HA": "#24480E",
|
||||
"HB": "#1F490D",
|
||||
"HC": "#1A4A0D",
|
||||
"HD": "#144B0D",
|
||||
"HE": "#0E4C0C",
|
||||
"HF": "#0C4D10",
|
||||
"HG": "#0B4E15",
|
||||
"HH": "#0B4F1B",
|
||||
"HI": "#0B5021",
|
||||
"HJ": "#0A5127",
|
||||
"HK": "#0A522E",
|
||||
"HL": "#095335",
|
||||
"HM": "#09543C",
|
||||
"HN": "#085643",
|
||||
"HO": "#08574B",
|
||||
"HP": "#085853",
|
||||
"HQ": "#075759",
|
||||
"HR": "#07505A",
|
||||
"HS": "#064A5B",
|
||||
"HT": "#06435C",
|
||||
"HU": "#053C5D",
|
||||
"HV": "#05345E",
|
||||
"HW": "#042D5F",
|
||||
"HX": "#042560",
|
||||
"HY": "#031C62",
|
||||
"HZ": "#031463",
|
||||
"IA": "#174A0E",
|
||||
"IB": "#124B0E",
|
||||
"IC": "#0D4C0F",
|
||||
"ID": "#0D4D14",
|
||||
"IE": "#0C4E19",
|
||||
"IF": "#0C4F1F",
|
||||
"IG": "#0C5025",
|
||||
"IH": "#0B512B",
|
||||
"II": "#0B5232",
|
||||
"IJ": "#0A5339",
|
||||
"IK": "#0A5440",
|
||||
"IL": "#0A5547",
|
||||
"IM": "#09564F",
|
||||
"IN": "#095756",
|
||||
"IO": "#085258",
|
||||
"IP": "#084C59",
|
||||
"IQ": "#07455A",
|
||||
"IR": "#073E5B",
|
||||
"IS": "#06375D",
|
||||
"IT": "#06305E",
|
||||
"IU": "#05285F",
|
||||
"IV": "#052060",
|
||||
"IW": "#041861",
|
||||
"IX": "#041062",
|
||||
"IY": "#030763",
|
||||
"IZ": "#070364",
|
||||
"JA": "#0E4B13",
|
||||
"JB": "#0E4C18",
|
||||
"JC": "#0E4D1E",
|
||||
"JD": "#0D4E23",
|
||||
"JE": "#0D4F29",
|
||||
"JF": "#0C502F",
|
||||
"JG": "#0C5136",
|
||||
"JH": "#0B523D",
|
||||
"JI": "#0B5344",
|
||||
"JJ": "#0B544B",
|
||||
"JK": "#0A5552",
|
||||
"JL": "#0A5357",
|
||||
"JM": "#094D58",
|
||||
"JN": "#094759",
|
||||
"JO": "#08405A",
|
||||
"JP": "#083A5B",
|
||||
"JQ": "#07335C",
|
||||
"JR": "#072B5D",
|
||||
"JS": "#06245E",
|
||||
"JT": "#061C5F",
|
||||
"JU": "#051460",
|
||||
"JV": "#050B62",
|
||||
"JW": "#060463",
|
||||
"JX": "#0E0464",
|
||||
"JY": "#160365",
|
||||
"JZ": "#1F0366",
|
||||
"KA": "#0F4D22",
|
||||
"KB": "#0E4E27",
|
||||
"KC": "#0E4F2D",
|
||||
"KD": "#0D5033",
|
||||
"KE": "#0D513A",
|
||||
"KF": "#0D5240",
|
||||
"KG": "#0C5347",
|
||||
"KH": "#0C544E",
|
||||
"KI": "#0B5455",
|
||||
"KJ": "#0B4E56",
|
||||
"KK": "#0A4957",
|
||||
"KL": "#0A4258",
|
||||
"KM": "#093C59",
|
||||
"KN": "#09355A",
|
||||
"KO": "#082E5B",
|
||||
"KP": "#08275C",
|
||||
"KQ": "#07205E",
|
||||
"KR": "#07185F",
|
||||
"KS": "#061060",
|
||||
"KT": "#060761",
|
||||
"KU": "#0C0562",
|
||||
"KV": "#140563",
|
||||
"KW": "#1C0464",
|
||||
"KX": "#250465",
|
||||
"KY": "#2D0367",
|
||||
"KZ": "#360368",
|
||||
"LA": "#0F4E31",
|
||||
"LB": "#0E4F37",
|
||||
"LC": "#0E503D",
|
||||
"LD": "#0E5144",
|
||||
"LE": "#0D524B",
|
||||
"LF": "#0D5352",
|
||||
"LG": "#0C4F54",
|
||||
"LH": "#0C4A55",
|
||||
"LI": "#0B4456",
|
||||
"LJ": "#0B3E58",
|
||||
"LK": "#0B3859",
|
||||
"LL": "#0A315A",
|
||||
"LM": "#0A2A5B",
|
||||
"LN": "#09235C",
|
||||
"LO": "#091C5D",
|
||||
"LP": "#08145E",
|
||||
"LQ": "#080C5F",
|
||||
"LR": "#0A0760",
|
||||
"LS": "#120761",
|
||||
"LT": "#1A0663",
|
||||
"LU": "#220564",
|
||||
"LV": "#2A0565",
|
||||
"LW": "#330466",
|
||||
"LX": "#3C0467",
|
||||
"LY": "#450368",
|
||||
"LZ": "#4F0369",
|
||||
"MA": "#0F5041",
|
||||
"MB": "#0F5147",
|
||||
"MC": "#0E524E",
|
||||
"MD": "#0E5053",
|
||||
"ME": "#0D4B54",
|
||||
"MF": "#0D4655",
|
||||
"MG": "#0D4056",
|
||||
"MH": "#0C3A57",
|
||||
"MI": "#0C3458",
|
||||
"MJ": "#0B2D59",
|
||||
"MK": "#0B265A",
|
||||
"ML": "#0A1F5B",
|
||||
"MM": "#0A185C",
|
||||
"MN": "#09105D",
|
||||
"MO": "#09095F",
|
||||
"MP": "#100860",
|
||||
"MQ": "#180861",
|
||||
"MR": "#1F0762",
|
||||
"MS": "#280763",
|
||||
"MT": "#300664",
|
||||
"MU": "#390665",
|
||||
"MV": "#410566",
|
||||
"MW": "#4B0468",
|
||||
"MX": "#540469",
|
||||
"MY": "#5E036A",
|
||||
"MZ": "#68036B",
|
||||
"NA": "#0F5151",
|
||||
"NB": "#0F4C52",
|
||||
"NC": "#0F4753",
|
||||
"ND": "#0E4254",
|
||||
"NE": "#0E3C55",
|
||||
"NF": "#0D3656",
|
||||
"NG": "#0D3057",
|
||||
"NH": "#0C2958",
|
||||
"NI": "#0C235A",
|
||||
"NJ": "#0B1C5B",
|
||||
"NK": "#0B145C",
|
||||
"NL": "#0A0D5D",
|
||||
"NM": "#0F0A5E",
|
||||
"NN": "#16095F",
|
||||
"NO": "#1D0960",
|
||||
"NP": "#250861",
|
||||
"NQ": "#2D0862",
|
||||
"NR": "#350764",
|
||||
"NS": "#3E0765",
|
||||
"NT": "#470666",
|
||||
"NU": "#500667",
|
||||
"NV": "#590568",
|
||||
"NW": "#630569",
|
||||
"NX": "#6A0468",
|
||||
"NY": "#6C0360",
|
||||
"NZ": "#6D0357",
|
||||
"OA": "#104353",
|
||||
"OB": "#0F3E54",
|
||||
"OC": "#0F3855",
|
||||
"OD": "#0E3256",
|
||||
"OE": "#0E2C57",
|
||||
"OF": "#0D2658",
|
||||
"OG": "#0D1F59",
|
||||
"OH": "#0D185A",
|
||||
"OI": "#0C115B",
|
||||
"OJ": "#0D0C5C",
|
||||
"OK": "#140B5D",
|
||||
"OL": "#1B0B5E",
|
||||
"OM": "#230A5F",
|
||||
"ON": "#2A0A61",
|
||||
"OO": "#320962",
|
||||
"OP": "#3A0863",
|
||||
"OQ": "#430864",
|
||||
"OR": "#4C0765",
|
||||
"OS": "#550766",
|
||||
"OT": "#5E0667",
|
||||
"OU": "#680669",
|
||||
"OV": "#6A0562",
|
||||
"OW": "#6B055A",
|
||||
"OX": "#6C0451",
|
||||
"OY": "#6D0349",
|
||||
"OZ": "#6F0340",
|
||||
"PA": "#103554",
|
||||
"PB": "#102F55",
|
||||
"PC": "#0F2956",
|
||||
"PD": "#0F2357",
|
||||
"PE": "#0E1C58",
|
||||
"PF": "#0E1559",
|
||||
"PG": "#0D0E5A",
|
||||
"PH": "#130D5B",
|
||||
"PI": "#1A0C5D",
|
||||
"PJ": "#210C5E",
|
||||
"PK": "#280B5F",
|
||||
"PL": "#2F0B60",
|
||||
"PM": "#370A61",
|
||||
"PN": "#3F0A62",
|
||||
"PO": "#480963",
|
||||
"PP": "#510964",
|
||||
"PQ": "#5A0866",
|
||||
"PR": "#630867",
|
||||
"PS": "#680763",
|
||||
"PT": "#69065C",
|
||||
"PU": "#6A0654",
|
||||
"PV": "#6B054C",
|
||||
"PW": "#6D0543",
|
||||
"PX": "#6E043A",
|
||||
"PY": "#6F0331",
|
||||
"PZ": "#700327",
|
||||
"QA": "#102655",
|
||||
"QB": "#101F56",
|
||||
"QC": "#0F1958",
|
||||
"QD": "#0F1259",
|
||||
"QE": "#120E5A",
|
||||
"QF": "#180E5B",
|
||||
"QG": "#1F0D5C",
|
||||
"QH": "#260D5D",
|
||||
"QI": "#2D0C5E",
|
||||
"QJ": "#340C5F",
|
||||
"QK": "#3C0B60",
|
||||
"QL": "#440B61",
|
||||
"QM": "#4D0A63",
|
||||
"QN": "#550A64",
|
||||
"QO": "#5E0965",
|
||||
"QP": "#660965",
|
||||
"QQ": "#67085D",
|
||||
"QR": "#680856",
|
||||
"QS": "#6A074E",
|
||||
"QT": "#6B0646",
|
||||
"QU": "#6C063D",
|
||||
"QV": "#6D0535",
|
||||
"QW": "#6E052C",
|
||||
"QX": "#700422",
|
||||
"QY": "#710419",
|
||||
"QZ": "#72030E",
|
||||
"RA": "#111657",
|
||||
"RB": "#111058",
|
||||
"RC": "#171059",
|
||||
"RD": "#1D0F5A",
|
||||
"RE": "#240F5B",
|
||||
"RF": "#2B0E5C",
|
||||
"RG": "#320E5D",
|
||||
"RH": "#390D5F",
|
||||
"RI": "#410D60",
|
||||
"RJ": "#490C61",
|
||||
"RK": "#510C62",
|
||||
"RL": "#5A0B63",
|
||||
"RM": "#620B64",
|
||||
"RN": "#650A5F",
|
||||
"RO": "#660958",
|
||||
"RP": "#680950",
|
||||
"RQ": "#690849",
|
||||
"RR": "#6A0841",
|
||||
"RS": "#6B0738",
|
||||
"RT": "#6C0730",
|
||||
"RU": "#6E0627",
|
||||
"RV": "#6F051D",
|
||||
"RW": "#700514",
|
||||
"RX": "#71040A",
|
||||
"RY": "#720804",
|
||||
"RZ": "#741103",
|
||||
"SA": "#1C1158",
|
||||
"SB": "#221059",
|
||||
"SC": "#29105B",
|
||||
"SD": "#2F0F5C",
|
||||
"SE": "#360F5D",
|
||||
"SF": "#3E0E5E",
|
||||
"SG": "#450E5F",
|
||||
"SH": "#4D0D60",
|
||||
"SI": "#550D61",
|
||||
"SJ": "#5E0C62",
|
||||
"SK": "#630C60",
|
||||
"SL": "#650B5A",
|
||||
"SM": "#660B52",
|
||||
"SN": "#670A4B",
|
||||
"SO": "#680A43",
|
||||
"SP": "#69093B",
|
||||
"SQ": "#6A0833",
|
||||
"SR": "#6C082B",
|
||||
"SS": "#6D0722",
|
||||
"ST": "#6E0718",
|
||||
"SU": "#6F060F",
|
||||
"SV": "#700605",
|
||||
"SW": "#720F05",
|
||||
"SX": "#470F03",
|
||||
"SY": "#481502",
|
||||
"SZ": "#491B02",
|
||||
"TA": "#2D115A",
|
||||
"TB": "#34115B",
|
||||
"TC": "#3B105C",
|
||||
"TD": "#42105D",
|
||||
"TE": "#490F5E",
|
||||
"TF": "#510F5F",
|
||||
"TG": "#590E60",
|
||||
"TH": "#620E61",
|
||||
"TI": "#630D5B",
|
||||
"TJ": "#640D54",
|
||||
"TK": "#650C4D",
|
||||
"TL": "#660B46",
|
||||
"TM": "#670B3E",
|
||||
"TN": "#680A37",
|
||||
"TO": "#6A0A2E",
|
||||
"TP": "#6B0926",
|
||||
"TQ": "#6C091D",
|
||||
"TR": "#6D0814",
|
||||
"TS": "#6E070A",
|
||||
"TT": "#700D07",
|
||||
"TU": "#460E04",
|
||||
"TV": "#471303",
|
||||
"TW": "#481903",
|
||||
"TX": "#492003",
|
||||
"TY": "#4A2602",
|
||||
"TZ": "#4B2D02",
|
||||
"UA": "#3F115B",
|
||||
"UB": "#46115C",
|
||||
"UC": "#4D105D",
|
||||
"UD": "#55105F",
|
||||
"UE": "#5D0F60",
|
||||
"UF": "#610F5C",
|
||||
"UG": "#620E56",
|
||||
"UH": "#630E4F",
|
||||
"UI": "#640D48",
|
||||
"UJ": "#650D41",
|
||||
"UK": "#670C3A",
|
||||
"UL": "#680C32",
|
||||
"UM": "#690B2A",
|
||||
"UN": "#6A0A21",
|
||||
"UO": "#6B0A19",
|
||||
"UP": "#6C090F",
|
||||
"UQ": "#6E0B09",
|
||||
"UR": "#6F1408",
|
||||
"US": "#461205",
|
||||
"UT": "#471804",
|
||||
"UU": "#481E04",
|
||||
"UV": "#492404",
|
||||
"UW": "#4A2A03",
|
||||
"UX": "#4B3103",
|
||||
"UY": "#4C3802",
|
||||
"UZ": "#4D3F02",
|
||||
"VA": "#51125D",
|
||||
"VB": "#59115E",
|
||||
"VC": "#5F115D",
|
||||
"VD": "#601057",
|
||||
"VE": "#611051",
|
||||
"VF": "#620F4A",
|
||||
"VG": "#630F44",
|
||||
"VH": "#650E3C",
|
||||
"VI": "#660D35",
|
||||
"VJ": "#670D2D",
|
||||
"VK": "#680C25",
|
||||
"VL": "#690C1D",
|
||||
"VM": "#6A0B14",
|
||||
"VN": "#6C0B0B",
|
||||
"VO": "#6D120A",
|
||||
"VP": "#451006",
|
||||
"VQ": "#451606",
|
||||
"VR": "#461C05",
|
||||
"VS": "#472205",
|
||||
"VT": "#482804",
|
||||
"VU": "#492E04",
|
||||
"VV": "#4A3504",
|
||||
"VW": "#4B3C03",
|
||||
"VX": "#4C4303",
|
||||
"VY": "#4D4B02",
|
||||
"VZ": "#4B4E02",
|
||||
"WA": "#5E1258",
|
||||
"WB": "#5F1152",
|
||||
"WC": "#60114C",
|
||||
"WD": "#621046",
|
||||
"WE": "#63103F",
|
||||
"WF": "#640F38",
|
||||
"WG": "#650F31",
|
||||
"WH": "#660E29",
|
||||
"WI": "#670E21",
|
||||
"WJ": "#680D19",
|
||||
"WK": "#6A0D10",
|
||||
"WL": "#6B100C",
|
||||
"WM": "#430F07",
|
||||
"WN": "#441407",
|
||||
"WO": "#451A06",
|
||||
"WP": "#462006",
|
||||
"WQ": "#472606",
|
||||
"WR": "#482C05",
|
||||
"WS": "#493205",
|
||||
"WT": "#4A3905",
|
||||
"WU": "#4B4004",
|
||||
"WV": "#4C4704",
|
||||
"WW": "#4C4D03",
|
||||
"WX": "#464E03",
|
||||
"WY": "#404F02",
|
||||
"WZ": "#3A5002",
|
||||
"XA": "#601248",
|
||||
"XB": "#611241",
|
||||
"XC": "#62113B",
|
||||
"XD": "#631134",
|
||||
"XE": "#64102D",
|
||||
"XF": "#651025",
|
||||
"XG": "#660F1D",
|
||||
"XH": "#680E15",
|
||||
"XI": "#690F0E",
|
||||
"XJ": "#420E08",
|
||||
"XK": "#431308",
|
||||
"XL": "#441808",
|
||||
"XM": "#451E07",
|
||||
"XN": "#462307",
|
||||
"XO": "#472907",
|
||||
"XP": "#482F06",
|
||||
"XQ": "#493606",
|
||||
"XR": "#4A3C05",
|
||||
"XS": "#4B4305",
|
||||
"XT": "#4C4A05",
|
||||
"XU": "#484D04",
|
||||
"XV": "#424E04",
|
||||
"XW": "#3C4F03",
|
||||
"XX": "#365003",
|
||||
"XY": "#305103",
|
||||
"XZ": "#295202",
|
||||
"YA": "#611237",
|
||||
"YB": "#621230",
|
||||
"YC": "#631129",
|
||||
"YD": "#651121",
|
||||
"YE": "#66101A",
|
||||
"YF": "#671012",
|
||||
"YG": "#410D09",
|
||||
"YH": "#421209",
|
||||
"YI": "#431709",
|
||||
"YJ": "#431C08",
|
||||
"YK": "#442108",
|
||||
"YL": "#452708",
|
||||
"YM": "#462D07",
|
||||
"YN": "#473307",
|
||||
"YO": "#483907",
|
||||
"YP": "#494006",
|
||||
"YQ": "#4A4706",
|
||||
"YR": "#494B06",
|
||||
"YS": "#434C05",
|
||||
"YT": "#3E4D05",
|
||||
"YU": "#384E04",
|
||||
"YV": "#324F04",
|
||||
"YW": "#2C5003",
|
||||
"YX": "#255103",
|
||||
"YY": "#1E5203",
|
||||
"YZ": "#175402",
|
||||
"ZA": "#631325",
|
||||
"ZB": "#64121E",
|
||||
"ZC": "#651216",
|
||||
"ZD": "#3F0C0B",
|
||||
"ZE": "#40110A",
|
||||
"ZF": "#41160A",
|
||||
"ZG": "#421B0A",
|
||||
"ZH": "#432009",
|
||||
"ZI": "#442509",
|
||||
"ZJ": "#452B09",
|
||||
"ZK": "#463008",
|
||||
"ZL": "#473608",
|
||||
"ZM": "#483D08",
|
||||
"ZN": "#494307",
|
||||
"ZO": "#4A4A07",
|
||||
"ZP": "#454B06",
|
||||
"ZQ": "#3F4C06",
|
||||
"ZR": "#3A4D06",
|
||||
"ZS": "#344E05",
|
||||
"ZT": "#2E4F05",
|
||||
"ZU": "#285004",
|
||||
"ZV": "#215104",
|
||||
"ZW": "#1A5204",
|
||||
"ZX": "#135303",
|
||||
"ZY": "#0C5403",
|
||||
"ZZ": "#045502"
|
||||
}
|
||||
BIN
frontend/src/data/blue.png
Normal file
BIN
frontend/src/data/blue.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 212 KiB |
BIN
frontend/src/data/green.png
Normal file
BIN
frontend/src/data/green.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 220 KiB |
BIN
frontend/src/data/pink.png
Normal file
BIN
frontend/src/data/pink.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 218 KiB |
BIN
frontend/src/data/yellow.png
Normal file
BIN
frontend/src/data/yellow.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 205 KiB |
10
frontend/src/main.jsx
Normal file
10
frontend/src/main.jsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
453
frontend/src/styles.css
Normal file
453
frontend/src/styles.css
Normal file
|
|
@ -0,0 +1,453 @@
|
|||
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Manrope:wght@400;500;600;700&display=swap");
|
||||
|
||||
:root { font-family:"DM Sans",sans-serif; color:#080821; background:#fffdfb; font-synthesis:none; --ink:#080821; --muted:#67677a; --coral:#ff806c; --blush:#fff3ef; --line:#eee9e5; }
|
||||
* { box-sizing:border-box; }
|
||||
html { scroll-behavior:smooth; }
|
||||
body { margin:0; min-width:320px; }
|
||||
button,a { font:inherit; }
|
||||
button { cursor:pointer; }
|
||||
a { color:inherit; text-decoration:none; }
|
||||
.shell { width:min(1180px,calc(100% - 64px)); margin-inline:auto; }
|
||||
.mobile-view { display:none; }
|
||||
.brand { display:inline-flex; align-items:center; gap:10px; font:700 20px "Manrope"; letter-spacing:-.7px; }
|
||||
.brand-mark { width:31px; height:29px; position:relative; display:block; background:var(--ink); border-radius:9px 9px 11px 11px; transform:rotate(-4deg); box-shadow:inset 0 -2px 0 #ffffff12; }
|
||||
.robot-eyes { position:absolute; top:8px; left:8px; display:flex; gap:6px; transform:rotate(4deg); }
|
||||
.robot-eyes i { width:4px; height:5px; display:block; border-radius:50%; background:white; }
|
||||
.robot-smile { width:14px; height:7px; position:absolute; left:9px; bottom:6px; border:2px solid white; border-top:0; border-radius:0 0 10px 10px; transform:rotate(4deg); }
|
||||
.brand-compact { line-height:0; }
|
||||
.nav { height:88px; display:flex; align-items:center; justify-content:space-between; }
|
||||
.nav-links,.nav-actions,.hero-actions,.trust-line { display:flex; align-items:center; }
|
||||
.nav-links { gap:38px; margin-left:80px; color:#4f4f62; font-size:14px; }
|
||||
.nav-actions,.hero-actions { gap:10px; }
|
||||
.button { min-height:43px; padding:0 19px; display:inline-flex; align-items:center; justify-content:center; gap:9px; border:0; border-radius:11px; font-size:14px; font-weight:600; transition:.2s; }
|
||||
.button:hover { transform:translateY(-2px); }
|
||||
.button-quiet { background:transparent; }
|
||||
.button-dark { color:white; background:var(--ink); box-shadow:0 8px 24px #08082122; }
|
||||
.button-coral { min-height:51px; padding-inline:24px; color:var(--ink); background:var(--coral); box-shadow:0 12px 25px #ff806c47; }
|
||||
.button-outline { min-height:51px; color:var(--ink); border:1px solid #dcd7d3; background:#ffffffbf; }
|
||||
.hero { min-height:660px; padding:36px 0 70px; display:grid; grid-template-columns:.88fr 1.12fr; align-items:center; gap:58px; }
|
||||
.eyebrow { width:max-content; padding:7px 11px; display:flex; align-items:center; gap:7px; border:1px solid #f0ded7; border-radius:99px; background:#fff9f6; color:#6e5d58; font-size:12px; font-weight:600; }
|
||||
h1 { margin:23px 0 20px; font:600 clamp(52px,5.2vw,76px)/.99 "Manrope"; letter-spacing:-4.6px; }
|
||||
h1 em { color:var(--coral); font-style:normal; font-weight:500; }
|
||||
.hero-copy>p { max-width:500px; color:var(--muted); font-size:17px; line-height:1.65; }
|
||||
.hero-actions { margin-top:31px; }
|
||||
.trust-line { gap:20px; margin-top:22px; color:#888795; font-size:12px; }
|
||||
.trust-line span,.mobile-benefits span { display:flex; align-items:center; gap:5px; }
|
||||
.trust-line svg,.mobile-benefits svg { color:#49aa87; }
|
||||
.product-stage { min-height:540px; position:relative; display:grid; place-items:center; }
|
||||
.product-stage:before { content:""; position:absolute; inset:1% -3% 0; border-radius:48% 52% 38% 45%; background:linear-gradient(145deg,#ffe1cd,#f6efc8 54%,#d6f3f4); transform:rotate(-2deg); }
|
||||
.orb { position:absolute; border-radius:50%; opacity:.5; }
|
||||
.orb-one { width:90px; height:90px; top:0; right:20px; background:#ffcfc6; }
|
||||
.orb-two { width:70px; height:70px; bottom:0; left:0; background:#b8eef2; }
|
||||
.inbox-window { width:94%; height:445px; z-index:2; display:grid; grid-template-columns:62px 1fr; overflow:hidden; border:7px solid #ffffffbf; border-radius:22px; background:white; box-shadow:0 34px 70px #312a3c33; transform:rotate(1.2deg); }
|
||||
.inbox-sidebar { padding:18px 0 14px; display:flex; flex-direction:column; align-items:center; color:white; background:var(--ink); }
|
||||
.mini-brand .brand-mark { width:25px; height:22px; background:white; }
|
||||
.mini-brand .robot-eyes { top:6px; left:6px; gap:5px; }.mini-brand .robot-eyes i { width:3px; height:4px; background:var(--ink); }
|
||||
.mini-brand .robot-smile { width:11px; height:5px; left:7px; bottom:4px; border-color:var(--ink); }
|
||||
.compose { width:34px; height:34px; margin-top:34px; border:0; border-radius:10px; background:var(--coral); font-size:23px; }
|
||||
.side-icons { margin-top:25px; display:grid; gap:12px; }
|
||||
.side-icons span { width:36px; height:36px; display:grid; place-items:center; border-radius:10px; color:#9594a8; }
|
||||
.side-icons .active { color:white; background:#ffffff1f; }
|
||||
.avatar { width:30px; height:30px; margin-top:auto; display:grid; place-items:center; border-radius:50%; background:#ffe1d5; color:#37201c; font-size:10px; font-weight:700; }
|
||||
.mail-panel { min-width:0; padding:22px 25px 17px; background:#fffefd; }
|
||||
.mail-toolbar,.inbox-heading { display:flex; justify-content:space-between; align-items:center; }
|
||||
.mail-toolbar div:first-child { display:grid; }
|
||||
.mail-toolbar small { color:#9995a0; font-size:9px; }
|
||||
.mail-toolbar strong { font-size:13px; }
|
||||
.toolbar-actions { display:flex; gap:17px; color:#737181; }
|
||||
.inbox-heading { margin-top:21px; }
|
||||
.inbox-heading>div { display:flex; align-items:center; gap:8px; }
|
||||
.inbox-heading h3 { margin:0; font:600 22px "Manrope"; }
|
||||
.inbox-heading span { width:20px; height:20px; display:grid; place-items:center; border-radius:7px; background:var(--blush); color:var(--coral); font-size:9px; }
|
||||
.inbox-heading button { display:flex; align-items:center; gap:4px; border:0; background:transparent; color:#777380; font-size:10px; }
|
||||
.agent-summary { margin-top:14px; padding:12px; display:grid; grid-template-columns:36px 1fr auto; align-items:center; gap:11px; border:1px solid #ffddd5; border-radius:12px; background:#fff1ed; }
|
||||
.agent-icon { width:34px; height:34px; display:grid; place-items:center; border-radius:10px; background:var(--ink); color:white; }
|
||||
.agent-summary strong { font-size:11px; }
|
||||
.agent-summary p { margin:3px 0 0; color:#81777a; font-size:8px; }
|
||||
.mail-list { margin-top:9px; }
|
||||
.mail-item,.mobile-mail { display:grid; grid-template-columns:35px 1fr auto; align-items:center; gap:10px; border-bottom:1px solid #f2eeeb; }
|
||||
.mail-item { padding:10px 8px; }
|
||||
.mail-item.selected { margin-inline:-5px; padding-inline:13px; border-radius:10px; background:#fcf9f7; }
|
||||
.sender-avatar { width:31px; height:31px; display:grid; place-items:center; border-radius:50%; font-size:10px; font-weight:700; }
|
||||
.coral { background:#ffe0d8; color:#bd4e3b; }.blue { background:#daf2f4; color:#247f87; }.gold { background:#f9e9b9; color:#9a741b; }
|
||||
.mail-item strong { display:block; font-size:10px; }.mail-item p { margin:3px 0 0; color:#84808b; font-size:8px; }.mail-item time { color:#aaa6ac; font-size:8px; }
|
||||
.quick-reply { height:38px; margin-top:9px; padding:0 8px 0 12px; display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:8px; border:1px solid #eee8e4; border-radius:11px; color:#aaa3aa; font-size:9px; }
|
||||
.quick-reply button { width:25px; height:25px; display:grid; place-items:center; border:0; border-radius:8px; background:var(--ink); color:white; }
|
||||
.floating-note { position:absolute; z-index:4; right:-12px; bottom:35px; padding:11px 15px 11px 11px; display:flex; align-items:center; gap:9px; border-radius:12px; background:white; box-shadow:0 16px 32px #1e192829; transform:rotate(-2deg); }
|
||||
.floating-note>span { width:27px; height:27px; display:grid; place-items:center; border-radius:50%; background:#dff5ed; color:#3a9877; }
|
||||
.floating-note div { display:grid; }.floating-note strong { font-size:10px; }.floating-note small { color:#98939b; font-size:8px; }
|
||||
.feature-strip { padding:74px 0 90px; display:grid; grid-template-columns:.75fr 1.25fr; gap:70px; border-top:1px solid var(--line); }
|
||||
.feature-intro>span { color:var(--coral); font-size:12px; font-weight:700; text-transform:uppercase; letter-spacing:1.2px; }
|
||||
.feature-intro h2 { margin:13px 0 0; font:600 34px/1.17 "Manrope"; letter-spacing:-1.7px; }
|
||||
.feature-grid { display:grid; grid-template-columns:repeat(3,1fr); gap:28px; }
|
||||
.feature-icon { width:43px; height:43px; display:grid; place-items:center; border-radius:13px; }
|
||||
.peach { background:#ffe6dd; color:#c85e49; }.cyan { background:#ddf3f4; color:#317d83; }.violet { background:#e9e5f8; color:#595081; }
|
||||
.feature-grid h3 { margin:17px 0 7px; font-size:15px; }.feature-grid p { margin:0; color:#777482; font-size:12px; line-height:1.6; }
|
||||
|
||||
.auth-page { min-height:100vh; position:relative; overflow:hidden; background:linear-gradient(135deg,#fffdfb 0%,#fff9f5 48%,#f5f4df 100%); }
|
||||
.auth-nav { width:min(1180px,calc(100% - 64px)); height:86px; margin:auto; display:flex; align-items:center; justify-content:space-between; position:relative; z-index:2; }
|
||||
.back-link { display:flex; align-items:center; gap:7px; color:#74717c; font-size:13px; font-weight:600; }
|
||||
.auth-shell { width:min(1050px,calc(100% - 64px)); min-height:calc(100vh - 86px); margin:auto; padding:42px 0 70px; display:grid; grid-template-columns:.85fr 1.15fr; align-items:center; gap:95px; position:relative; z-index:1; }
|
||||
.auth-story h1 { margin:21px 0; font-size:clamp(48px,5vw,67px); }
|
||||
.auth-story>p { max-width:390px; color:var(--muted); font-size:16px; line-height:1.65; }
|
||||
.auth-points { margin-top:31px; display:grid; gap:12px; color:#5f5d69; font-size:12px; }
|
||||
.auth-points span { display:flex; align-items:center; gap:9px; }.auth-points svg { color:var(--coral); }
|
||||
.auth-card { padding:37px; border:1px solid #eee7e1; border-radius:24px; background:#ffffffeb; box-shadow:0 30px 75px #352b3b1c; backdrop-filter:blur(12px); }
|
||||
.card-heading>span { color:var(--coral); font-size:10px; font-weight:700; letter-spacing:1.1px; text-transform:uppercase; }
|
||||
.card-heading h2,.auth-success h2 { margin:8px 0 7px; font:600 25px "Manrope"; letter-spacing:-1px; }
|
||||
.card-heading p,.auth-success p { margin:0; color:#85818b; font-size:12px; line-height:1.5; }
|
||||
.auth-form { margin-top:26px; display:grid; gap:17px; }
|
||||
.auth-form label,.auth-form legend { color:#393745; font-size:11px; font-weight:700; }
|
||||
.auth-form fieldset { margin:0; padding:0; border:0; }
|
||||
.auth-form legend { margin-bottom:9px; }
|
||||
.input-wrap { height:48px; margin-top:7px; padding:0 14px; display:flex; align-items:center; gap:10px; border:1px solid #e4dfdc; border-radius:11px; background:white; color:#a19ba2; transition:.2s; }
|
||||
.input-wrap:focus-within { border-color:var(--coral); box-shadow:0 0 0 3px #ff806c18; }
|
||||
.input-wrap input { min-width:0; flex:1; border:0; outline:0; background:transparent; color:var(--ink); font-size:13px; }
|
||||
.input-wrap input::placeholder { color:#aaa5ab; }
|
||||
.account-types { display:grid; grid-template-columns:1fr 1fr; gap:10px; }
|
||||
.type-card { min-height:74px; padding:13px; display:grid; grid-template-columns:20px 1fr; align-items:center; gap:10px; border:1px solid #e5e0dc; border-radius:12px; background:white; cursor:pointer; }
|
||||
.type-card.selected { border-color:var(--coral); background:#fff6f2; box-shadow:0 0 0 2px #ff806c12; }
|
||||
.type-card input { position:absolute; opacity:0; pointer-events:none; }
|
||||
.type-card>span { display:grid; gap:3px; }.type-card strong { font-size:11px; }.type-card small { color:#97919a; font-size:8px; font-weight:500; line-height:1.3; }
|
||||
.type-card svg { color:#77727c; }.type-card.selected svg { color:var(--coral); }
|
||||
.field-row { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
|
||||
.email-builder>span { padding-left:10px; border-left:1px solid #eae5e1; color:#7e7982; font-size:11px; white-space:nowrap; }
|
||||
.field-hint { display:block; margin-top:6px; color:#99939b; font-size:9px; font-weight:500; }
|
||||
.auth-submit { width:100%; margin-top:3px; }
|
||||
.auth-submit:disabled { opacity:.65; cursor:wait; transform:none; }
|
||||
.auth-switch { margin:20px 0 0; text-align:center; color:#85808a; font-size:11px; }.auth-switch a { color:var(--ink); font-weight:700; }
|
||||
.form-error { margin:0; padding:10px 12px; border:1px solid #ffd3ca; border-radius:9px; background:#fff2ef; color:#b54f3d; font-size:10px; line-height:1.4; }
|
||||
.auth-success { padding:35px 10px; display:flex; flex-direction:column; align-items:center; text-align:center; }
|
||||
.auth-success>span { width:50px; height:50px; display:grid; place-items:center; border-radius:16px; background:#dff5ed; color:#359374; }
|
||||
.auth-success .auth-submit { margin-top:25px; }
|
||||
.auth-orb { position:absolute; border-radius:50%; filter:blur(2px); opacity:.65; }.auth-orb-one { width:240px; height:240px; top:-80px; right:10%; background:#ffe0d3; }.auth-orb-two { width:180px; height:180px; bottom:-70px; left:7%; background:#d9f1f0; }
|
||||
|
||||
.dashboard { min-height:100vh; display:grid; grid-template-columns:270px 1fr; background:#f5f4f2; }
|
||||
.dashboard-sidebar { height:100vh; padding:29px 20px 19px; position:sticky; top:0; display:flex; flex-direction:column; border-right:1px solid #e6e2de; background:#fffefd; box-shadow:4px 0 28px #0808210c; z-index:12; }
|
||||
.dashboard-brand-row { padding:0 9px; display:flex; align-items:center; justify-content:space-between; }
|
||||
.dashboard-brand-row>button { display:none; }
|
||||
.dashboard-compose { min-height:45px; margin:35px 3px 29px; display:flex; align-items:center; justify-content:center; gap:9px; border:0; border-radius:11px; background:var(--ink); color:white; font-weight:700; box-shadow:0 6px 18px #08082128; transition:transform .18s ease,box-shadow .18s ease,background .18s ease; }
|
||||
.dashboard-compose:hover { transform:scale(1.02); box-shadow:0 10px 26px #08082136; background:#12102a; }
|
||||
.dashboard-compose:active { transform:scale(0.99); box-shadow:0 4px 12px #08082122; }
|
||||
.dashboard-nav-group { margin-bottom:24px; }
|
||||
.dashboard-nav-group h2 { margin:0 10px 8px; display:flex; align-items:center; gap:8px; color:#97929a; font-size:10px; letter-spacing:1.2px; text-transform:uppercase; }
|
||||
.dashboard-nav-group nav { display:grid; gap:3px; }
|
||||
.dashboard-nav-group button { min-height:39px; padding:0 11px; display:flex; align-items:center; gap:11px; border:0; border-radius:9px; background:transparent; color:#6f6b76; font-size:13px; text-align:left; transition:background .15s ease,color .15s ease; }
|
||||
.dashboard-nav-group button:hover { background:#faf5f2; color:var(--ink); }
|
||||
.dashboard-nav-group button.active { background:#fff1ed; color:#c95945; font-weight:700; box-shadow:inset 2px 0 0 var(--coral); }
|
||||
.dashboard-profile { margin-top:auto; padding:13px 5px 0; display:grid; grid-template-columns:34px minmax(0,1fr) 31px; align-items:center; gap:9px; border-top:1px solid var(--line); }
|
||||
.dashboard-profile>span { width:34px; height:34px; display:grid; place-items:center; border-radius:11px; background:var(--ink); color:white; font-size:10px; font-weight:700; text-transform:uppercase; }
|
||||
.dashboard-profile>div { min-width:0; display:grid; gap:2px; }
|
||||
.dashboard-profile strong,.dashboard-profile small { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.dashboard-profile strong { font-size:11px; }.dashboard-profile small { color:#99949d; font-size:8px; }
|
||||
.dashboard-profile button,.dashboard-brand-row>button,.dashboard-mobile-header>button { width:31px; height:31px; display:grid; place-items:center; border:0; border-radius:9px; background:transparent; color:#8c8790; }
|
||||
.dashboard-profile button:hover { color:var(--ink); background:#f5f1ee; }
|
||||
.dashboard-content { min-width:0; padding:28px clamp(28px,4vw,60px); background:#f5f4f2; }
|
||||
.dashboard-content-heading { display:flex; align-items:end; justify-content:space-between; gap:20px; }
|
||||
.dashboard-content-heading p { margin:0 0 7px; color:#7a757f; font-size:11px; font-weight:700; letter-spacing:1px; text-transform:uppercase; }
|
||||
.dashboard-content-heading h1 { margin:0; color:var(--ink); font-size:clamp(35px,4vw,48px); letter-spacing:-2.5px; }
|
||||
.dashboard-account-pill { padding:8px 12px; border:1px solid #f1dcd5; border-radius:99px; background:#fff5f1; color:#a95343; font-size:10px; font-weight:700; }
|
||||
.dashboard-empty { min-height:390px; margin-top:35px; display:flex; flex-direction:column; align-items:center; justify-content:center; border:1px solid var(--line); border-radius:22px; background:white; text-align:center; box-shadow:0 18px 50px #392e3e0a; }
|
||||
.dashboard-empty>span,.company-soon-icon { width:53px; height:53px; display:grid; place-items:center; border-radius:16px; background:#fff0eb; color:var(--coral); }
|
||||
.dashboard-empty h2 { margin:17px 0 7px; font:600 20px "Manrope"; letter-spacing:-.7px; }
|
||||
.dashboard-empty p { margin:0; color:#8c8790; font-size:12px; }
|
||||
.dashboard-mobile-header { display:none; }
|
||||
.dashboard-loading { min-height:100vh; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:18px; background:#fffdfb; color:#8d8891; font-size:12px; }
|
||||
.company-soon { min-height:100vh; padding:0 clamp(22px,5vw,64px); display:flex; flex-direction:column; overflow:hidden; background:radial-gradient(circle at 78% 25%,#f3efd2 0,transparent 26%),linear-gradient(135deg,#fffdfb,#fff4ef); }
|
||||
.company-soon header { height:88px; display:flex; align-items:center; justify-content:space-between; }
|
||||
.company-soon header button { display:flex; align-items:center; gap:7px; border:0; background:transparent; color:#77727c; font-size:12px; }
|
||||
.company-soon section { margin:auto; padding:40px 0 120px; display:flex; flex-direction:column; align-items:center; text-align:center; }
|
||||
.company-soon section>.eyebrow { margin-top:22px; }
|
||||
.company-soon h1 { margin:20px 0; font-size:clamp(45px,7vw,78px); }
|
||||
.company-soon section>p:last-child { margin:0; color:#77727c; font-size:14px; }
|
||||
.company-dns-setup { min-height:100vh; padding:0 clamp(22px,5vw,64px) 80px; display:flex; flex-direction:column; background:radial-gradient(circle at 78% 25%,#f3efd2 0,transparent 26%),linear-gradient(135deg,#fffdfb,#fff4ef); }
|
||||
.company-dns-header { height:88px; display:flex; align-items:center; justify-content:space-between; }
|
||||
.company-dns-header button { border:0; background:transparent; color:#77727c; font-size:12px; }
|
||||
.company-dns-card { width:min(920px,100%); margin:0 auto; padding:34px 0; display:flex; flex-direction:column; gap:18px; }
|
||||
.company-dns-card h1 { margin:0; font-size:clamp(34px,5vw,52px); letter-spacing:-1.2px; }
|
||||
.company-dns-card h1 em { font-style:normal; color:var(--coral); }
|
||||
.company-dns-lead { margin:0; color:#77727c; font-size:14px; line-height:1.6; max-width:720px; }
|
||||
.dns-notes { margin:4px 0 0; padding-left:18px; color:#7d7882; font-size:12px; line-height:1.55; }
|
||||
.dns-notes li+li { margin-top:6px; }
|
||||
.company-dns-checks { display:grid; gap:8px; }
|
||||
.company-dns-check { padding:10px 12px; display:flex; align-items:flex-start; gap:10px; border-radius:10px; font-size:12px; }
|
||||
.company-dns-check strong { display:block; font-size:10px; letter-spacing:.4px; }
|
||||
.company-dns-check span { color:#7d7882; }
|
||||
.company-dns-check.ok { background:#eef8f1; color:#2f6b43; }
|
||||
.company-dns-check.fail { background:#fff0eb; color:#b04a38; }
|
||||
.company-dns-next { align-self:flex-start; margin-top:8px; display:inline-flex; align-items:center; gap:8px; }
|
||||
.dns-copy { flex-shrink:0; border:0; border-radius:7px; padding:4px; background:#f7f3f1; color:#8c8790; cursor:pointer; }
|
||||
.dns-copy:hover { background:#fff0eb; color:var(--coral); }
|
||||
.dashboard-scrim { display:none; }
|
||||
.dashboard-notice { padding:12px 16px; position:fixed; right:24px; bottom:24px; z-index:30; border-radius:11px; background:var(--ink); color:white; box-shadow:0 14px 35px #08082138; font-size:12px; font-weight:600; }
|
||||
|
||||
.mailbox-workspace { min-height:calc(100vh - 56px); display:grid; grid-template-columns:minmax(0,70fr) minmax(0,30fr); align-items:stretch; gap:22px; }
|
||||
.mailbox-main-column { min-width:0; display:flex; flex-direction:column; }
|
||||
.mailbox-shell { min-height:calc(95vh - 158px); margin-top:24px; overflow:hidden; border:1px solid #e6e2de; border-radius:18px; background:white; box-shadow:0 1px 2px #08082106,0 12px 40px #0808210a; }
|
||||
.mailbox-list-pane { min-width:0; min-height:calc(95vh - 158px); position:relative; display:flex; flex-direction:column; }
|
||||
.mailbox-notes-pane { min-width:0; min-height:calc(100vh - 56px); max-height:calc(100vh - 56px); overflow:hidden; display:flex; flex-direction:column; border:1px solid #e6e2de; border-radius:18px; background:#fffefd; box-shadow:0 1px 2px #08082106,0 14px 42px #08082110; }
|
||||
.mailbox-toolbar { min-height:62px; padding:11px 15px; display:flex; align-items:center; gap:8px; border-bottom:1px solid #ebe7e3; background:#fffefd; }
|
||||
.mailbox-toolbar>label { height:38px; min-width:160px; flex:1; padding:0 12px; display:flex; align-items:center; gap:8px; border:1px solid #e8e3df; border-radius:10px; color:#aaa4ac; background:#fcfbfa; }
|
||||
.mailbox-toolbar input { min-width:0; width:100%; border:0; outline:0; background:transparent; color:var(--ink); font-size:12px; }
|
||||
.mailbox-toolbar>button,.mail-detail-toolbar button { height:36px; padding:0 10px; display:flex; align-items:center; justify-content:center; gap:6px; border:0; border-radius:9px; background:transparent; color:#77717a; font-size:10px; font-weight:700; }
|
||||
.mailbox-toolbar>button:hover,.mailbox-toolbar>button.active,.mail-detail-toolbar button:hover { background:#fff0eb; color:#bd5947; }
|
||||
.mailbox-filters { padding:13px 15px; display:grid; grid-template-columns:1fr 1.5fr 1fr 1fr; gap:10px; border-bottom:1px solid var(--line); background:#fcfaf9; }
|
||||
.mailbox-filters label { display:grid; gap:5px; color:#76717a; font-size:9px; font-weight:700; text-transform:uppercase; letter-spacing:.5px; }
|
||||
.mailbox-filters input,.mailbox-filters select { min-width:0; height:34px; padding:0 9px; border:1px solid #e4dfdc; border-radius:8px; outline:0; background:white; color:#393745; font-size:10px; text-transform:none; }
|
||||
.mailbox-filters input:focus,.mailbox-filters select:focus { border-color:#f0ad9f; box-shadow:0 0 0 2px #ff806c12; }
|
||||
.mailbox-rows { min-height:120px; flex:0 0 auto; }
|
||||
.mailbox-row { width:100%; min-height:62px; padding:10px 15px 10px 13px; display:grid; grid-template-columns:20px 36px minmax(85px,140px) minmax(0,1fr) auto 28px; align-items:center; gap:10px 12px; border:0; border-bottom:1px solid #ebe7e3; border-left:2px solid transparent; background:white; color:#4a4652; text-align:left; cursor:pointer; transition:background .15s ease,border-color .15s ease; }
|
||||
.mailbox-row:focus-visible { outline:2px solid #f0ad9f; outline-offset:-2px; }
|
||||
.sender-avatar { width:36px; height:36px; border-radius:50%; display:grid; place-items:center; color:white; font-size:11px; font-weight:700; letter-spacing:.02em; flex-shrink:0; user-select:none; line-height:1; }
|
||||
.sender-avatar img { width:100%; height:100%; border-radius:50%; object-fit:cover; display:block; }
|
||||
.mailbox-avatar { align-self:center; }
|
||||
.mailbox-row:hover { background:#faf8f6; border-left-color:var(--coral); }
|
||||
.mailbox-row.selected { background:#fff8f5; border-left-color:var(--coral); }
|
||||
.mailbox-read-dot { display:grid; place-items:center; color:#9a959f; }
|
||||
.mailbox-read-dot i { width:7px; height:7px; display:block; border-radius:50%; background:var(--coral); box-shadow:0 0 0 3px #fff0eb; }
|
||||
.mailbox-sender,.mailbox-summary strong,.mailbox-summary span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.mailbox-sender { font-size:12px; font-weight:500; color:#3a3642; }
|
||||
.mailbox-summary { min-width:0; display:flex; gap:6px; align-items:baseline; font-size:12px; }
|
||||
.mailbox-summary strong { min-width:0; max-width:100%; flex:0 1 auto; font-weight:500; color:#2a2733; }
|
||||
.mailbox-summary span { min-width:0; flex:1 1 0; color:#7a7580; font-weight:400; }
|
||||
.mailbox-summary b { color:#9a959f; font-weight:400; }
|
||||
.mailbox-row.unread .mailbox-sender,.mailbox-row.unread .mailbox-summary strong { color:var(--ink); font-weight:700; }
|
||||
.mailbox-row.unread .mailbox-summary span { color:#5c5763; font-weight:400; }
|
||||
.mailbox-row time { color:#8f8a94; font-size:10px; white-space:nowrap; }
|
||||
.mailbox-star { width:28px; height:28px; padding:0; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#c5c0c8; cursor:pointer; transition:color .15s ease,background .15s ease; }
|
||||
.mailbox-star:hover,.mailbox-star.active { color:#e8a317; background:#fff8e8; }
|
||||
.mailbox-row.starred .mailbox-star { color:#e8a317; }
|
||||
.mailbox-list-footer { margin-top:auto; padding:18px 20px 20px; display:flex; align-items:center; justify-content:space-between; gap:16px; border-top:1px solid #ebe7e3; background:linear-gradient(180deg,#fcfbfa 0%,#f8f7f5 100%); color:#8f8a94; font-size:11px; }
|
||||
.mailbox-list-footer p { margin:0; color:#5c5763; font-weight:600; }
|
||||
.mailbox-list-footer span { color:#a09aa4; font-size:10px; }
|
||||
.mailbox-state { min-height:360px; padding:30px; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#8d8891; text-align:center; font-size:11px; }
|
||||
.mailbox-state.empty span { width:46px; height:46px; margin-bottom:13px; display:grid; place-items:center; border-radius:14px; color:var(--coral); background:#fff0eb; }
|
||||
.mailbox-state.empty strong { color:#393541; font:600 15px "Manrope"; }
|
||||
.mailbox-state p { margin:7px 0; }
|
||||
.mailbox-state.error { color:#a94f40; }
|
||||
.mailbox-state button { margin-top:8px; padding:7px 12px; border:0; border-radius:8px; background:var(--ink); color:white; font-size:10px; }
|
||||
.mailbox-pagination { min-height:52px; padding:8px 14px; display:flex; align-items:center; justify-content:space-between; border-top:1px solid var(--line); color:#99949d; font-size:9px; }
|
||||
.mailbox-pagination button { height:32px; padding:0 9px; display:flex; align-items:center; gap:4px; border:1px solid #e6e1dd; border-radius:8px; background:white; color:#5f5a64; font-size:9px; font-weight:700; }
|
||||
.mailbox-pagination button:disabled { opacity:.4; cursor:not-allowed; }
|
||||
|
||||
.sticky-notes-header { min-height:64px; padding:11px 18px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid #ebe7e3; background:#fffefd; }
|
||||
.sticky-notes-title { display:flex; align-items:center; gap:8px; color:#5c5763; }
|
||||
.sticky-notes-title>svg { transform:rotate(-24deg); transform-origin:center; }
|
||||
.sticky-notes-title h2 { margin:0; font:700 11px "DM Sans"; letter-spacing:.9px; text-transform:uppercase; color:#4a4652; }
|
||||
.sticky-notes-actions button { width:34px; height:34px; padding:0; display:grid; place-items:center; border:0; border-radius:9px; background:transparent; color:#77717a; cursor:pointer; }
|
||||
.sticky-notes-actions button:hover { background:#fff0eb; color:#bd5947; }
|
||||
.sticky-notes-body { flex:1; padding:20px 16px 18px; display:flex; flex-direction:column; gap:13px; overflow:auto; }
|
||||
.sticky-notes-state { padding:24px 12px; color:#8d8891; font-size:11px; text-align:center; }
|
||||
.sticky-notes-state.error { color:#a94f40; }
|
||||
.sticky-note { height:132px; min-height:132px; position:relative; isolation:isolate; padding:19px 16px 14px; display:flex; flex-direction:column; border:1px solid #ffffffa8; border-radius:5px 7px 8px 6px; transform:rotate(-.45deg); box-shadow:0 2px 3px #31282120,0 8px 15px #31282116,0 18px 34px #3128210b; transition:transform .16s ease,box-shadow .16s ease; }
|
||||
.sticky-note:nth-of-type(even) { transform:rotate(.5deg); }
|
||||
.sticky-note:hover { transform:rotate(0) translateY(-2px); box-shadow:0 3px 4px #31282124,0 11px 20px #31282119,0 22px 38px #3128210e; }
|
||||
.sticky-note::before { content:""; position:absolute; inset:0; z-index:-1; border-radius:inherit; background:linear-gradient(115deg,#ffffff38 0%,transparent 28%,transparent 72%,#00000008 100%); pointer-events:none; }
|
||||
.sticky-note::after { content:""; position:absolute; z-index:-2; right:7px; bottom:-5px; width:45%; height:12px; border-radius:0 0 9px 45%; background:#443a3224; filter:blur(4px); transform:skewX(-16deg) rotate(-1deg); transform-origin:right; }
|
||||
.sticky-note-pin { position:absolute; z-index:2; top:-13px; left:50%; width:34px; height:31px; overflow:hidden; transform:translateX(-50%) rotate(-7deg); filter:drop-shadow(1px 3px 2px #3028204a); pointer-events:none; }
|
||||
.sticky-note-pin img { width:34px; height:34px; display:block; object-fit:cover; object-position:center top; clip-path:inset(0 0 10% 0); }
|
||||
.sticky-note-header { flex:0 0 auto; display:flex; align-items:center; justify-content:space-between; gap:8px; margin-bottom:9px; }
|
||||
.sticky-note-header h3 { margin:0; font:700 14px "Manrope"; color:#252230; letter-spacing:-.25px; line-height:1.25; }
|
||||
.sticky-note-menu-wrap { position:relative; flex-shrink:0; }
|
||||
.sticky-note-menu-btn { width:26px; height:26px; padding:0; display:grid; place-items:center; border:0; border-radius:7px; background:transparent; color:#8f8a94; cursor:pointer; }
|
||||
.sticky-note-menu-btn:hover { background:#ffffff80; color:#5c5763; }
|
||||
.sticky-note-menu { position:absolute; top:100%; right:0; z-index:2; min-width:120px; padding:4px; border:1px solid #e6e2de; border-radius:9px; background:white; box-shadow:0 8px 24px #08082118; }
|
||||
.sticky-note-menu button { width:100%; padding:8px 10px; display:flex; align-items:center; gap:7px; border:0; border-radius:7px; background:transparent; color:#a94f40; font-size:11px; font-weight:600; cursor:pointer; text-align:left; }
|
||||
.sticky-note-menu button:hover { background:#fff5f3; }
|
||||
.sticky-note p { min-height:0; margin:0 0 10px; padding-right:4px; flex:1 1 auto; overflow:auto; color:#6b6872; font-size:11px; font-weight:400; line-height:1.55; white-space:pre-wrap; overflow-wrap:anywhere; scrollbar-width:thin; scrollbar-color:#8f8a9442 transparent; }
|
||||
.sticky-note p::-webkit-scrollbar { width:4px; }
|
||||
.sticky-note p::-webkit-scrollbar-thumb { border-radius:999px; background:#8f8a9442; }
|
||||
.sticky-note-footer { margin-top:auto; flex:0 0 auto; display:flex; align-items:end; justify-content:space-between; gap:10px; }
|
||||
.sticky-note-tags { display:flex; flex-wrap:wrap; gap:5px; }
|
||||
.sticky-note-tags span { padding:3px 8px; border-radius:999px; font-size:9px; font-weight:700; letter-spacing:.2px; }
|
||||
.sticky-note-footer time { color:#8f8a94; font-size:9px; white-space:nowrap; }
|
||||
.sticky-note-yellow { background:linear-gradient(145deg,#fff9df,#fff4c9); }
|
||||
.sticky-note-yellow .sticky-note-tags span { background:#fff3c4; color:#b8860b; }
|
||||
.sticky-note-green { background:linear-gradient(145deg,#f0fae9,#e2f3d7); }
|
||||
.sticky-note-green .sticky-note-tags span { background:#d8f0dc; color:#2d7a38; }
|
||||
.sticky-note-blue { background:linear-gradient(145deg,#edf7ff,#ddebfa); }
|
||||
.sticky-note-blue .sticky-note-tags span { background:#d5e8ff; color:#2a6db0; }
|
||||
.sticky-note-pink { background:linear-gradient(145deg,#fff1f4,#f9dfe5); }
|
||||
.sticky-note-pink .sticky-note-tags span { background:#fce0ea; color:#c44d75; }
|
||||
.sticky-note-add { width:100%; min-height:68px; padding:10px 14px; display:grid; grid-template-columns:24px auto; grid-template-rows:auto auto; column-gap:8px; align-content:center; justify-content:center; align-items:center; border:1.5px dashed #d1cbc5; border-radius:10px; background:#fffcfa80; color:#8f8a94; cursor:pointer; transition:border-color .15s ease,background .15s ease; }
|
||||
.sticky-note-add>svg { grid-row:1/3; }
|
||||
.sticky-note-add:hover { border-color:#c5bdb4; background:white; color:#5c5763; }
|
||||
.sticky-note-add span { align-self:end; font-size:11px; font-weight:700; color:#5c5763; text-align:left; }
|
||||
.sticky-note-add small { align-self:start; font-size:9px; color:#aaa4ac; text-align:left; }
|
||||
.sticky-note-form { padding:14px; display:grid; gap:10px; border:1px solid #e6e2de; border-radius:12px; background:white; box-shadow:0 4px 16px #0808210c; }
|
||||
.sticky-note-form input,.sticky-note-form textarea { width:100%; padding:9px 10px; border:1px solid #e4dfdc; border-radius:8px; outline:0; background:#fcfbfa; color:#393745; font:500 11px "DM Sans"; resize:vertical; }
|
||||
.sticky-note-form input:focus,.sticky-note-form textarea:focus { border-color:#f0ad9f; box-shadow:0 0 0 2px #ff806c12; }
|
||||
.sticky-note-colors { display:flex; gap:8px; }
|
||||
.sticky-note-color { width:22px; height:22px; padding:0; border:2px solid transparent; border-radius:50%; cursor:pointer; }
|
||||
.sticky-note-color[data-color="yellow"] { background:#fff3c4; }
|
||||
.sticky-note-color[data-color="green"] { background:#d8f0dc; }
|
||||
.sticky-note-color[data-color="blue"] { background:#d5e8ff; }
|
||||
.sticky-note-color[data-color="pink"] { background:#fce0ea; }
|
||||
.sticky-note-color.active { border-color:#393745; box-shadow:0 0 0 2px white; }
|
||||
.sticky-note-form-actions { display:flex; justify-content:flex-end; gap:8px; }
|
||||
.sticky-note-form-actions button { height:32px; padding:0 12px; border:0; border-radius:8px; font-size:10px; font-weight:700; cursor:pointer; }
|
||||
.sticky-note-form-actions button[type="button"] { background:#f3f0ed; color:#5c5763; }
|
||||
.sticky-note-form-actions button[type="submit"] { background:var(--ink); color:white; }
|
||||
.sticky-note-form-actions button:disabled { opacity:.5; cursor:not-allowed; }
|
||||
|
||||
.mail-detail { min-width:0; display:none; position:absolute; inset:0; z-index:4; overflow:auto; border-left:0; background:#fffefd; }
|
||||
.mail-detail.open { display:block; }
|
||||
.mail-detail-toolbar { min-height:62px; padding:10px 13px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); }
|
||||
.mail-detail-toolbar>div { display:flex; gap:3px; }
|
||||
.mail-detail-toolbar button { width:36px; padding:0; }
|
||||
.mail-detail-toolbar button.danger { color:#bb4e43; }
|
||||
.mail-detail-close { display:none!important; }
|
||||
.mail-detail-state { min-height:320px; padding:30px; display:grid; place-items:center; color:#8d8891; font-size:11px; text-align:center; }
|
||||
.mail-detail-state.error { color:#a94f40; }
|
||||
.mail-detail-content { padding:26px clamp(19px,3vw,34px) 40px; }
|
||||
.mail-detail-content h2 { margin:0 0 22px; overflow-wrap:anywhere; font:600 21px/1.3 "Manrope"; letter-spacing:-.7px; }
|
||||
.mail-detail-meta { padding-bottom:19px; display:flex; align-items:start; justify-content:space-between; gap:14px; border-bottom:1px solid var(--line); }
|
||||
.mail-detail-meta>div { min-width:0; display:grid; gap:3px; }
|
||||
.mail-detail-sender { display:flex; align-items:center; gap:10px; min-width:0; }
|
||||
.mail-detail-sender .sender-avatar { width:40px; height:40px; font-size:12px; flex-shrink:0; }
|
||||
.mail-detail-sender strong { overflow:hidden; text-overflow:ellipsis; font-size:11px; }
|
||||
.mail-detail-meta strong { overflow:hidden; text-overflow:ellipsis; font-size:11px; }
|
||||
.mail-detail-meta small,.mail-detail-meta time { color:#928d96; font-size:8px; line-height:1.5; }
|
||||
.mail-detail-meta time { white-space:nowrap; }
|
||||
.mail-detail-body { padding-top:23px; color:#383440; font-size:12px; line-height:1.75; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
|
||||
.compose-overlay { position:fixed; inset:0; z-index:40; padding:24px; display:flex; align-items:center; justify-content:center; background:#08082175; backdrop-filter:blur(5px); }
|
||||
.compose-modal { width:min(760px,100%); max-height:calc(100vh - 48px); overflow:auto; border:1px solid #eee7e2; border-radius:22px; background:#fffefd; box-shadow:0 32px 90px #08082150; }
|
||||
.compose-header { min-height:76px; padding:0 24px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); background:linear-gradient(100deg,#fff6f2,#fffefd); }
|
||||
.compose-header>div { display:flex; align-items:center; gap:11px; }
|
||||
.compose-header>div>span { width:34px; height:34px; display:grid; place-items:center; border-radius:11px; background:var(--ink); color:var(--coral); }
|
||||
.compose-header h2 { margin:0; font:600 17px "Manrope"; letter-spacing:-.5px; }
|
||||
.compose-header small { display:block; margin-top:2px; color:#97919a; font-size:9px; }
|
||||
.compose-header>button { width:35px; height:35px; display:grid; place-items:center; border:0; border-radius:10px; background:transparent; color:#77717a; }
|
||||
.compose-header>button:hover { background:#f4eeea; color:var(--ink); }
|
||||
.compose-form { padding:0 24px 22px; }
|
||||
.compose-recipient-row { min-height:55px; display:grid; grid-template-columns:28px 1fr auto; align-items:center; gap:9px; border-bottom:1px solid var(--line); }
|
||||
.compose-recipient-row label,.compose-copy-fields label { color:#77717a; font-size:11px; font-weight:700; }
|
||||
.compose-recipient-row input,.compose-copy-fields input,.compose-subject,.compose-ai input { min-width:0; border:0; outline:0; background:transparent; color:var(--ink); font:500 12px "DM Sans"; }
|
||||
.compose-recipient-row>button { padding:6px 8px; border:0; border-radius:7px; background:transparent; color:#847e87; font-size:10px; font-weight:700; }
|
||||
.compose-recipient-row>button:hover { background:var(--blush); color:#bc5948; }
|
||||
.compose-copy-fields { display:grid; grid-template-columns:1fr 1fr; border-bottom:1px solid var(--line); }
|
||||
.compose-copy-fields label { min-height:48px; padding-right:18px; display:grid; grid-template-columns:32px 1fr; align-items:center; }
|
||||
.compose-copy-fields label+label { padding-left:18px; border-left:1px solid var(--line); }
|
||||
.compose-subject { width:100%; height:52px; border-bottom:1px solid var(--line); font-size:13px; }
|
||||
.compose-format-row { padding:12px 0; display:flex; align-items:center; justify-content:space-between; }
|
||||
.compose-format-row>span { color:#97919a; font-size:9px; font-weight:700; letter-spacing:.8px; text-transform:uppercase; }
|
||||
.compose-format-row>div { padding:3px; display:flex; border-radius:9px; background:#f3efec; }
|
||||
.compose-format-row button { min-width:56px; height:27px; border:0; border-radius:7px; background:transparent; color:#85808a; font-size:9px; font-weight:700; }
|
||||
.compose-format-row button.active { background:white; color:var(--ink); box-shadow:0 3px 9px #241a2b18; }
|
||||
.compose-body { width:100%; min-height:210px; padding:17px; resize:vertical; border:1px solid #ebe5e1; outline:0; border-radius:13px; background:white; color:var(--ink); font:400 13px/1.65 "DM Sans"; }
|
||||
.compose-body:focus { border-color:#f0ad9f; box-shadow:0 0 0 3px #ff806c14; }
|
||||
.compose-tools { padding:9px 3px; display:flex; gap:3px; border-bottom:1px solid var(--line); }
|
||||
.compose-tools button { width:32px; height:30px; display:grid; place-items:center; border:0; border-radius:8px; background:transparent; color:#77717a; }
|
||||
.compose-tools button:hover { background:#fff0eb; color:#c25c49; }
|
||||
.compose-tools button:disabled { opacity:.3; cursor:not-allowed; }
|
||||
.compose-tools button:disabled:hover { background:transparent; color:#77717a; }
|
||||
.compose-tools>span { margin-left:7px; align-self:center; color:#aaa4ac; font-size:8px; }
|
||||
.compose-ai { min-height:58px; margin:14px 0 10px; padding:8px 9px 8px 14px; display:grid; grid-template-columns:auto 1fr auto; align-items:center; gap:10px; border:1px solid #e9e4ef; border-radius:14px; background:linear-gradient(100deg,#f5f4fb,#fff6f2); }
|
||||
.compose-ai>svg { color:#7463a8; }
|
||||
.compose-ai button { min-height:35px; padding:0 13px; border:0; border-radius:9px; background:var(--ink); color:white; font-size:10px; font-weight:700; }
|
||||
.compose-ai button:disabled { opacity:.45; cursor:not-allowed; }
|
||||
.compose-error { margin:8px 0; color:#b54f3d; font-size:10px; }
|
||||
.compose-footer { display:flex; align-items:center; justify-content:space-between; gap:16px; }
|
||||
.compose-footer>span { color:#99939b; font-size:9px; }
|
||||
.compose-send { min-width:105px; min-height:41px; display:flex; align-items:center; justify-content:center; gap:8px; border:0; border-radius:10px; background:var(--coral); color:var(--ink); box-shadow:0 9px 20px #ff806c3b; font-weight:700; }
|
||||
.compose-send:hover { transform:translateY(-1px); box-shadow:0 13px 25px #ff806c57; }
|
||||
.compose-send:disabled { opacity:.6; cursor:wait; transform:none; }
|
||||
|
||||
@media(max-width:900px) {
|
||||
.web-view { display:none; }.mobile-view { display:block; }
|
||||
.mobile-landing { min-height:100vh; overflow:hidden; padding-bottom:28px; background:radial-gradient(circle at 90% 36%,#f3efd0,transparent 27%),#fffdfb; }
|
||||
.mobile-nav { height:72px; padding:0 22px; display:flex; align-items:center; justify-content:space-between; }
|
||||
.icon-button { width:39px; height:39px; display:grid; place-items:center; border:1px solid #e8e3df; border-radius:11px; background:white; }
|
||||
.mobile-hero { padding:42px 24px 27px; display:flex; flex-direction:column; align-items:center; text-align:center; }
|
||||
.mobile-hero h1 { margin:21px 0 16px; font-size:clamp(43px,13vw,61px); letter-spacing:-3.5px; }
|
||||
.mobile-hero>p { max-width:420px; margin:0 0 26px; color:var(--muted); font-size:15px; line-height:1.6; }
|
||||
.mobile-hero .button { width:min(100%,330px); }
|
||||
.mobile-login { margin-top:15px; border:0; background:none; color:#7e7a84; font-size:12px; }.mobile-login strong { color:var(--ink); }
|
||||
.phone-preview { width:min(86%,390px); margin:34px auto 0; padding:11px 16px 14px; position:relative; border:5px solid white; border-radius:31px; background:#fffefd; box-shadow:0 28px 70px #32283733; transform:rotate(-1deg); }
|
||||
.phone-preview:before { content:""; position:absolute; z-index:-1; inset:-22px -45px 20px; border-radius:42% 45% 40% 44%; background:linear-gradient(145deg,#ffe0cf,#f6efc9 55%,#cfedf0); }
|
||||
.phone-top { height:30px; padding-inline:6px; display:flex; align-items:center; justify-content:space-between; font-size:8px; font-weight:700; }
|
||||
.phone-pill { width:61px; height:18px; border-radius:99px; background:var(--ink); }
|
||||
.phone-preview>header { padding:9px 6px; display:flex; align-items:center; justify-content:space-between; }.phone-preview>header div { display:grid; }.phone-preview>header small { color:#99949c; font-size:8px; }.phone-preview>header strong { font-size:13px; }.phone-preview>header .avatar { margin:0; }
|
||||
.mobile-summary { margin:10px 3px 18px; padding:11px; display:grid; grid-template-columns:35px 1fr auto; align-items:center; gap:10px; border:1px solid #ffded5; border-radius:13px; background:#fff1ed; }
|
||||
.mobile-summary div { display:grid; }.mobile-summary strong { font-size:10px; }.mobile-summary small { color:#8d8183; font-size:8px; }
|
||||
.mobile-inbox-title { padding-inline:4px; display:flex; align-items:center; justify-content:space-between; }.mobile-inbox-title h3 { margin:0; font:600 16px "Manrope"; }.mobile-inbox-title span { color:var(--coral); font-size:8px; }
|
||||
.mobile-mail { padding:12px 5px; }.mobile-mail strong { display:block; font-size:9px; }.mobile-mail p { margin:3px 0 0; color:#85818a; font-size:8px; }.mobile-mail time { color:#aaa5aa; font-size:7px; }
|
||||
.mobile-tabbar { height:49px; margin-top:10px; display:flex; align-items:center; justify-content:space-around; border-radius:14px; background:var(--ink); color:#9291a4; }.mobile-tabbar .active { color:var(--coral); }.mobile-tabbar button { width:37px; height:37px; display:grid; place-items:center; border:0; border-radius:12px; background:var(--coral); font-size:23px; }
|
||||
.mobile-benefits { margin:38px auto 0; display:flex; justify-content:center; gap:18px; color:#77727d; font-size:10px; }
|
||||
.auth-nav { width:calc(100% - 40px); height:72px; }
|
||||
.auth-shell { width:min(100% - 32px,560px); min-height:auto; padding:28px 0 50px; display:block; }
|
||||
.auth-story { margin-bottom:28px; text-align:center; display:flex; flex-direction:column; align-items:center; }
|
||||
.auth-story h1 { margin:18px 0 13px; font-size:clamp(40px,11vw,54px); }
|
||||
.auth-story>p { margin:0; font-size:14px; }
|
||||
.auth-points { display:none; }
|
||||
.auth-card { padding:25px 21px; border-radius:20px; }
|
||||
.back-link { font-size:0; }.back-link svg { width:20px; height:20px; }
|
||||
.dashboard { display:block; }
|
||||
.dashboard-mobile-header { height:70px; padding:0 20px; display:flex; align-items:center; justify-content:space-between; border-bottom:1px solid var(--line); background:#fffefd; }
|
||||
.dashboard-sidebar { width:min(290px,85vw); position:fixed; left:0; transform:translateX(-105%); transition:transform .25s ease; box-shadow:18px 0 45px #18111f24; }
|
||||
.dashboard-sidebar.open { transform:translateX(0); }
|
||||
.dashboard-brand-row>button { display:grid; }
|
||||
.dashboard-scrim { display:block; position:fixed; inset:0; z-index:11; border:0; background:#08082155; }
|
||||
.dashboard-content { padding:32px 20px; }
|
||||
.dashboard-empty { min-height:340px; }
|
||||
.mailbox-workspace { min-height:auto; display:block; }
|
||||
.mailbox-shell { display:block; }
|
||||
.mailbox-notes-pane { display:none; }
|
||||
.mailbox-list-pane { border-right:0; }
|
||||
.mailbox-shell.detail-visible .mailbox-toolbar,
|
||||
.mailbox-shell.detail-visible .mailbox-filters,
|
||||
.mailbox-shell.detail-visible .mailbox-rows,
|
||||
.mailbox-shell.detail-visible .mailbox-list-footer,
|
||||
.mailbox-shell.detail-visible .mailbox-pagination { display:none; }
|
||||
.mailbox-shell.detail-visible .mailbox-list-pane { display:flex; min-height:520px; }
|
||||
.mail-detail { position:static; flex:1; }
|
||||
.mail-detail-close { display:flex!important; }
|
||||
.compose-overlay { padding:12px; align-items:flex-end; }
|
||||
.compose-modal { max-height:calc(100vh - 24px); border-radius:19px 19px 12px 12px; }
|
||||
}
|
||||
@media(max-width:480px) {
|
||||
.account-types,.field-row { grid-template-columns:1fr; }.auth-story h1 br { display:none; }
|
||||
.compose-overlay { padding:0; }
|
||||
.compose-modal { width:100%; max-height:100vh; border:0; border-radius:0; }
|
||||
.compose-header { min-height:64px; padding:0 16px; }
|
||||
.compose-form { padding:0 15px 16px; }
|
||||
.compose-copy-fields { grid-template-columns:1fr; }
|
||||
.compose-copy-fields label+label { padding-left:0; border-left:0; border-top:1px solid var(--line); }
|
||||
.compose-body { min-height:32vh; }
|
||||
.compose-ai { grid-template-columns:auto 1fr; }
|
||||
.compose-ai button { grid-column:1/-1; }
|
||||
.compose-footer>span { display:none; }
|
||||
.compose-send { width:100%; }
|
||||
.dashboard-content-heading { align-items:center; }
|
||||
.dashboard-content-heading h1 { font-size:34px; }
|
||||
.dashboard-account-pill { display:none; }
|
||||
.mailbox-shell { margin-top:20px; border-radius:14px; }
|
||||
.mailbox-toolbar { padding:9px; }
|
||||
.mailbox-toolbar>button span { display:none; }
|
||||
.mailbox-filters { grid-template-columns:1fr 1fr; }
|
||||
.mailbox-row { min-height:70px; padding:10px 11px; grid-template-columns:17px 32px minmax(0,1fr) auto 26px; gap:8px 9px; }
|
||||
.sender-avatar.mailbox-avatar { width:32px; height:32px; font-size:10px; }
|
||||
.mailbox-sender { grid-column:3; align-self:end; }
|
||||
.mailbox-summary { grid-column:3/5; display:flex; align-self:start; }
|
||||
.mailbox-summary strong { max-width:100%; }
|
||||
.mailbox-row time { grid-column:4; grid-row:1; align-self:end; }
|
||||
.mailbox-star { grid-column:5; grid-row:1; align-self:center; width:26px; height:26px; }
|
||||
.mailbox-read-dot { grid-row:1/3; }
|
||||
.mailbox-avatar { grid-row:1/3; align-self:center; }
|
||||
}
|
||||
@media(max-width:380px) { .mobile-hero { padding-top:25px; }.mobile-hero h1 { font-size:41px; }.phone-preview { width:90%; } }
|
||||
.dns-results { margin-top:22px; display:grid; gap:18px; }
|
||||
.dns-summary { margin:0; color:#4f4f62; font-size:12px; line-height:1.5; }
|
||||
.dns-notes { margin:0; padding-left:18px; color:#74717c; font-size:11px; line-height:1.55; }
|
||||
.dns-section h3 { margin:0 0 10px; font:600 15px "Manrope"; letter-spacing:-.3px; }
|
||||
.dns-table-wrap { overflow:auto; border:1px solid var(--line); border-radius:12px; background:#fff; }
|
||||
.dns-table { width:100%; border-collapse:collapse; font-size:11px; }
|
||||
.dns-table th,.dns-table td { padding:10px 12px; border-bottom:1px solid #f2eeeb; text-align:left; vertical-align:top; }
|
||||
.dns-table th { color:#74717c; font-size:10px; font-weight:600; text-transform:uppercase; letter-spacing:.4px; background:#fcf9f7; }
|
||||
.dns-table tr:last-child td { border-bottom:0; }
|
||||
.dns-table code { font:500 11px ui-monospace,SFMono-Regular,Menlo,monospace; word-break:break-all; }
|
||||
.dns-value-cell { display:flex; align-items:flex-start; gap:8px; }
|
||||
.dns-copy { width:28px; height:28px; flex:0 0 auto; display:grid; place-items:center; border:1px solid #ece7e3; border-radius:8px; background:white; color:#67677a; }
|
||||
.link-button { border:0; background:transparent; color:var(--coral); font:inherit; font-weight:600; cursor:pointer; padding:0; }
|
||||
@media(prefers-reduced-motion:reduce) { * { scroll-behavior:auto!important; transition:none!important; } }
|
||||
63
frontend/src/utils/senderAvatar.js
Normal file
63
frontend/src/utils/senderAvatar.js
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import colorMap from "../data/az_2_letter_color_map.json";
|
||||
|
||||
const FALLBACK_COLOR = "#5c5763";
|
||||
|
||||
export function addressText(sender) {
|
||||
if (typeof sender === "string") return sender;
|
||||
return sender?.email || sender?.address || "";
|
||||
}
|
||||
|
||||
export function senderDisplayName(email) {
|
||||
const displayName =
|
||||
email.sender_display_name ||
|
||||
email.sender_name ||
|
||||
email.from_name ||
|
||||
(typeof email.sender === "object" ? email.sender?.name : "");
|
||||
const address = addressText(email.sender || email.from);
|
||||
return displayName?.trim() || address.split("@")[0] || "Unknown sender";
|
||||
}
|
||||
|
||||
/** Two-letter key for color lookup: first + last word, or first two chars. */
|
||||
export function initialsFromName(name) {
|
||||
const cleaned = String(name || "")
|
||||
.trim()
|
||||
.replace(/[^a-zA-Z\s'-]/g, " ");
|
||||
const parts = cleaned.split(/\s+/).filter(Boolean);
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const first = parts[0][0] || "";
|
||||
const last = parts[parts.length - 1][0] || "";
|
||||
return `${first}${last}`.toUpperCase();
|
||||
}
|
||||
|
||||
const word = parts[0] || "?";
|
||||
const letters = word.replace(/[^a-zA-Z]/g, "");
|
||||
if (letters.length >= 2) {
|
||||
return letters.slice(0, 2).toUpperCase();
|
||||
}
|
||||
if (letters.length === 1) {
|
||||
return `${letters[0]}${letters[0]}`.toUpperCase();
|
||||
}
|
||||
return "??";
|
||||
}
|
||||
|
||||
export function colorForInitials(initials) {
|
||||
const key = String(initials || "").toUpperCase().slice(0, 2);
|
||||
return colorMap[key] || FALLBACK_COLOR;
|
||||
}
|
||||
|
||||
/** Registered-user avatar URL hook — wire when profile photos exist. */
|
||||
export function senderAvatarUrl(email) {
|
||||
return email.sender_avatar_url || email.sender?.avatar_url || null;
|
||||
}
|
||||
|
||||
export function senderAvatar(email) {
|
||||
const name = senderDisplayName(email);
|
||||
const initials = initialsFromName(name);
|
||||
return {
|
||||
name,
|
||||
initials,
|
||||
color: colorForInitials(initials),
|
||||
imageUrl: senderAvatarUrl(email),
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue