Add Spark Messenger marketing site for Singapore host.
Serve sparkmessenger/sparkmsg on shared Nginx SNI with deploy script and Let's Encrypt. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
7f683ce150
commit
5b53dd906c
@ -31,6 +31,7 @@ HTTP 和 TCP 调同一套 `internal/module/*/logic`。Gin handler 只做绑定
|
|||||||
|
|
||||||
| 用途 | 域名 | 说明 |
|
| 用途 | 域名 | 说明 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
|
| 官网 | `https://sparkmessenger.wyrs365.com` | App 落地页(别名 `sparkmsg.wyrs365.com`) |
|
||||||
| HTTP API | `https://sparkapi.wyrs365.com` | REST `/v1/...`,文档 `/docs` |
|
| HTTP API | `https://sparkapi.wyrs365.com` | REST `/v1/...`,文档 `/docs` |
|
||||||
| IM 长连接 | `sparkim.wyrs365.com:443` | TLS + 自定义 Frame,不是 WebSocket |
|
| IM 长连接 | `sparkim.wyrs365.com:443` | TLS + 自定义 Frame,不是 WebSocket |
|
||||||
| 对象存储 | `https://sparkr.wyrs365.com` | RustFS S3 path-style,Presigned PUT/GET |
|
| 对象存储 | `https://sparkr.wyrs365.com` | RustFS S3 path-style,Presigned PUT/GET |
|
||||||
|
|||||||
@ -8,6 +8,7 @@ Client --TLS--> :443 ssl_preread
|
|||||||
│ → proxy_pass http://127.0.0.1:18080 + X-Real-IP
|
│ → proxy_pass http://127.0.0.1:18080 + X-Real-IP
|
||||||
├─ SNI sparkr.wyrs365.com → 127.0.0.1:8443 (HTTP TLS)
|
├─ SNI sparkr.wyrs365.com → 127.0.0.1:8443 (HTTP TLS)
|
||||||
│ → proxy_pass http://127.0.0.1:9000 RustFS S3
|
│ → proxy_pass http://127.0.0.1:9000 RustFS S3
|
||||||
|
├─ SNI sparkmessenger / sparkmsg → 127.0.0.1:8443 (静态官网)
|
||||||
└─ SNI sparkim.wyrs365.com → 127.0.0.1:19001 (IM TLS)
|
└─ SNI sparkim.wyrs365.com → 127.0.0.1:19001 (IM TLS)
|
||||||
→ PROXY protocol → 127.0.0.1:9090 gnet
|
→ PROXY protocol → 127.0.0.1:9090 gnet
|
||||||
```
|
```
|
||||||
@ -16,8 +17,15 @@ On this ECS, `:443` is shared with other wyrs HTTPS sites. Do **not** replace `/
|
|||||||
|
|
||||||
- `host/sparkapi.conf` → `/etc/nginx/conf.d/`
|
- `host/sparkapi.conf` → `/etc/nginx/conf.d/`
|
||||||
- `host/sparkr.conf` → `/etc/nginx/conf.d/` (RustFS S3; DNS A record `sparkr.wyrs365.com` → this host)
|
- `host/sparkr.conf` → `/etc/nginx/conf.d/` (RustFS S3; DNS A record `sparkr.wyrs365.com` → this host)
|
||||||
|
- `host/sparkmessenger.conf` → `/etc/nginx/conf.d/`(官网;`sparkmessenger.wyrs365.com` / `sparkmsg.wyrs365.com`)
|
||||||
- `host/spark-sni.conf` → `/etc/nginx/stream.d/`
|
- `host/spark-sni.conf` → `/etc/nginx/stream.d/`
|
||||||
|
|
||||||
|
官网静态文件在 `/var/www/sparkmessenger.wyrs365.com`,部署:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash deploy/website/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
Existing `listen 443 ssl` vhosts move to `127.0.0.1:8443 ssl proxy_protocol` so SNI can take public 443. `00-spark-http.conf` sets `real_ip_header proxy_protocol` and `port_in_redirect off` so `$remote_addr` is the client and redirects stay on 443.
|
Existing `listen 443 ssl` vhosts move to `127.0.0.1:8443 ssl proxy_protocol` so SNI can take public 443. `00-spark-http.conf` sets `real_ip_header proxy_protocol` and `port_in_redirect off` so `$remote_addr` is the client and redirects stay on 443.
|
||||||
|
|
||||||
spark-server (Docker):
|
spark-server (Docker):
|
||||||
|
|||||||
53
deploy/nginx/host/sparkmessenger.conf
Normal file
53
deploy/nginx/host/sparkmessenger.conf
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# Spark Messenger website — sparkmessenger.wyrs365.com / sparkmsg.wyrs365.com
|
||||||
|
# Public :443 is stream ssl_preread (see stream.d/spark-sni.conf).
|
||||||
|
# TLS terminates on loopback :8443 (PROXY protocol from stream).
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name sparkmessenger.wyrs365.com sparkmsg.wyrs365.com;
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 127.0.0.1:8443 ssl proxy_protocol;
|
||||||
|
listen [::1]:8443 ssl proxy_protocol;
|
||||||
|
http2 on;
|
||||||
|
server_name sparkmessenger.wyrs365.com sparkmsg.wyrs365.com;
|
||||||
|
|
||||||
|
ssl_certificate /etc/letsencrypt/live/sparkmessenger.wyrs365.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/sparkmessenger.wyrs365.com/privkey.pem;
|
||||||
|
ssl_session_timeout 1d;
|
||||||
|
ssl_session_cache shared:SparkSSL:10m;
|
||||||
|
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||||
|
add_header X-Content-Type-Options nosniff always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
root /var/www/sparkmessenger.wyrs365.com;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location = /favicon.ico {
|
||||||
|
try_files /assets/favicon.svg =404;
|
||||||
|
default_type image/svg+xml;
|
||||||
|
access_log off;
|
||||||
|
expires 7d;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /assets/ {
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -58,8 +58,8 @@ rewrite_http_443() {
|
|||||||
local f
|
local f
|
||||||
for f in /etc/nginx/conf.d/*.conf; do
|
for f in /etc/nginx/conf.d/*.conf; do
|
||||||
case "$(basename "$f")" in
|
case "$(basename "$f")" in
|
||||||
sparkapi.conf|sparkr.conf|spark-acme.conf|00-spark-http.conf) continue ;;
|
sparkapi.conf|sparkr.conf|sparkmessenger.conf|spark-acme.conf|00-spark-http.conf) continue ;;
|
||||||
spark-acme-*) continue ;;
|
spark-acme-*|sparkmessenger-acme.conf) continue ;;
|
||||||
esac
|
esac
|
||||||
if grep -qE 'listen[[:space:]]+127\.0\.0\.1:8443' "$f"; then
|
if grep -qE 'listen[[:space:]]+127\.0\.0\.1:8443' "$f"; then
|
||||||
continue
|
continue
|
||||||
@ -77,9 +77,13 @@ install_sni() {
|
|||||||
rewrite_http_443
|
rewrite_http_443
|
||||||
rm -f /etc/nginx/conf.d/spark-acme.conf
|
rm -f /etc/nginx/conf.d/spark-acme.conf
|
||||||
rm -f /etc/nginx/conf.d/spark-acme-*.conf
|
rm -f /etc/nginx/conf.d/spark-acme-*.conf
|
||||||
|
rm -f /etc/nginx/conf.d/sparkmessenger-acme.conf
|
||||||
cp "$HOST_DIR/00-spark-http.conf" /etc/nginx/conf.d/00-spark-http.conf
|
cp "$HOST_DIR/00-spark-http.conf" /etc/nginx/conf.d/00-spark-http.conf
|
||||||
cp "$HOST_DIR/sparkapi.conf" /etc/nginx/conf.d/sparkapi.conf
|
cp "$HOST_DIR/sparkapi.conf" /etc/nginx/conf.d/sparkapi.conf
|
||||||
cp "$HOST_DIR/sparkr.conf" /etc/nginx/conf.d/sparkr.conf
|
cp "$HOST_DIR/sparkr.conf" /etc/nginx/conf.d/sparkr.conf
|
||||||
|
if [[ -f "$HOST_DIR/sparkmessenger.conf" ]] && cert_ok sparkmessenger.wyrs365.com; then
|
||||||
|
cp "$HOST_DIR/sparkmessenger.conf" /etc/nginx/conf.d/sparkmessenger.conf
|
||||||
|
fi
|
||||||
cp "$HOST_DIR/spark-sni.conf" /etc/nginx/stream.d/spark-sni.conf
|
cp "$HOST_DIR/spark-sni.conf" /etc/nginx/stream.d/spark-sni.conf
|
||||||
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
|
mkdir -p /etc/letsencrypt/renewal-hooks/deploy
|
||||||
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'HOOK'
|
cat > /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh <<'HOOK'
|
||||||
|
|||||||
120
deploy/website/deploy.sh
Normal file
120
deploy/website/deploy.sh
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Deploy Spark Messenger website to the Singapore ECS host.
|
||||||
|
# Layout on host:
|
||||||
|
# /var/www/sparkmessenger.wyrs365.com static site
|
||||||
|
# /etc/nginx/conf.d/sparkmessenger.conf TLS vhost (loopback :8443)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
|
||||||
|
HOST="${SPARK_SSH_HOST:-root@47.236.66.9}"
|
||||||
|
KEY="${SPARK_SSH_KEY:-$HOME/.ssh/wyrs_shanghai_deploy.pem}"
|
||||||
|
SITE_SRC="$ROOT/website"
|
||||||
|
SITE_DST="/var/www/sparkmessenger.wyrs365.com"
|
||||||
|
DOMAINS=(sparkmessenger.wyrs365.com sparkmsg.wyrs365.com)
|
||||||
|
PRIMARY="${DOMAINS[0]}"
|
||||||
|
SSH=(ssh -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new "$HOST")
|
||||||
|
SCP=(scp -i "$KEY" -o BatchMode=yes -o StrictHostKeyChecking=accept-new)
|
||||||
|
|
||||||
|
need() {
|
||||||
|
command -v "$1" >/dev/null || {
|
||||||
|
echo "need $1 on this machine" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
need ssh
|
||||||
|
need scp
|
||||||
|
need rsync
|
||||||
|
|
||||||
|
if [[ ! -f "$KEY" ]]; then
|
||||||
|
echo "missing SSH key: $KEY" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ ! -f "$SITE_SRC/index.html" ]]; then
|
||||||
|
echo "missing $SITE_SRC/index.html" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> upload site + nginx conf"
|
||||||
|
"${SSH[@]}" "mkdir -p '$SITE_DST' /var/www/certbot /tmp/spark-nginx-host"
|
||||||
|
rsync -az --delete -e "ssh -i $KEY -o BatchMode=yes -o StrictHostKeyChecking=accept-new" \
|
||||||
|
"$SITE_SRC/" "$HOST:$SITE_DST/"
|
||||||
|
"${SCP[@]}" "$ROOT/deploy/nginx/host/sparkmessenger.conf" "$HOST:/tmp/spark-nginx-host/sparkmessenger.conf"
|
||||||
|
|
||||||
|
echo "==> ensure TLS certificates"
|
||||||
|
"${SSH[@]}" bash -s -- "$PRIMARY" "${DOMAINS[@]}" <<'REMOTE'
|
||||||
|
set -euo pipefail
|
||||||
|
PRIMARY=$1
|
||||||
|
shift
|
||||||
|
DOMAINS=("$@")
|
||||||
|
|
||||||
|
cert_ok() {
|
||||||
|
[[ -f "/etc/letsencrypt/live/$1/fullchain.pem" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
need_issue=0
|
||||||
|
if ! cert_ok "$PRIMARY"; then
|
||||||
|
need_issue=1
|
||||||
|
else
|
||||||
|
# Ensure SAN covers all website domains; re-issue if missing.
|
||||||
|
for d in "${DOMAINS[@]}"; do
|
||||||
|
if ! openssl x509 -in "/etc/letsencrypt/live/$PRIMARY/fullchain.pem" -noout -text 2>/dev/null | grep -q "$d"; then
|
||||||
|
need_issue=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$need_issue" -eq 1 ]]; then
|
||||||
|
mkdir -p /var/www/certbot
|
||||||
|
cat > /etc/nginx/conf.d/sparkmessenger-acme.conf <<EOF
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name ${DOMAINS[*]};
|
||||||
|
|
||||||
|
location /.well-known/acme-challenge/ {
|
||||||
|
root /var/www/certbot;
|
||||||
|
}
|
||||||
|
|
||||||
|
location / {
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
EOF
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
|
||||||
|
certbot_args=(certonly --webroot -w /var/www/certbot --non-interactive --agree-tos --keep-until-expiring --cert-name "$PRIMARY")
|
||||||
|
# Use a placeholder email if register is needed; prefer existing account.
|
||||||
|
if [[ -n "${CERTBOT_EMAIL:-}" ]]; then
|
||||||
|
certbot_args+=(--email "$CERTBOT_EMAIL")
|
||||||
|
else
|
||||||
|
certbot_args+=(--register-unsafely-without-email)
|
||||||
|
fi
|
||||||
|
for d in "${DOMAINS[@]}"; do
|
||||||
|
certbot_args+=(-d "$d")
|
||||||
|
done
|
||||||
|
certbot "${certbot_args[@]}"
|
||||||
|
rm -f /etc/nginx/conf.d/sparkmessenger-acme.conf
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! cert_ok "$PRIMARY"; then
|
||||||
|
echo "certificate missing for $PRIMARY" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cp /tmp/spark-nginx-host/sparkmessenger.conf /etc/nginx/conf.d/sparkmessenger.conf
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
echo "website nginx ready"
|
||||||
|
REMOTE
|
||||||
|
|
||||||
|
echo "==> verify"
|
||||||
|
for d in "${DOMAINS[@]}"; do
|
||||||
|
code=$("${SSH[@]}" "curl -fsS -o /dev/null -w '%{http_code}' --resolve ${d}:443:127.0.0.1 https://${d}/" || true)
|
||||||
|
echo "$d -> HTTP $code"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "done"
|
||||||
18
website/README.md
Normal file
18
website/README.md
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
# Spark Messenger 官网
|
||||||
|
|
||||||
|
静态落地页,部署到新加坡 ECS(`47.236.66.9`)。
|
||||||
|
|
||||||
|
| 域名 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `https://sparkmessenger.wyrs365.com` | 主站 |
|
||||||
|
| `https://sparkmsg.wyrs365.com` | 短域名,同一站点 |
|
||||||
|
|
||||||
|
主机路径:`/var/www/sparkmessenger.wyrs365.com`
|
||||||
|
Nginx:`deploy/nginx/host/sparkmessenger.conf` → `/etc/nginx/conf.d/`
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 默认 KEY=$HOME/.ssh/wyrs_shanghai_deploy.pem HOST=root@47.236.66.9
|
||||||
|
bash deploy/website/deploy.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
会签发 / 续期 Let’s Encrypt 证书(SAN 含两个域名),rsync 静态文件并 reload nginx。
|
||||||
14
website/assets/app.js
Normal file
14
website/assets/app.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
(() => {
|
||||||
|
const form = document.querySelector(".notify");
|
||||||
|
const note = document.getElementById("notify-note");
|
||||||
|
if (!form || !note) return;
|
||||||
|
|
||||||
|
form.addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
const email = form.querySelector("input[type='email']");
|
||||||
|
if (!(email instanceof HTMLInputElement) || !email.value.trim()) return;
|
||||||
|
note.hidden = false;
|
||||||
|
email.value = "";
|
||||||
|
email.blur();
|
||||||
|
});
|
||||||
|
})();
|
||||||
4
website/assets/favicon.svg
Normal file
4
website/assets/favicon.svg
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#1a221c"/>
|
||||||
|
<path d="M16 4.5 19.2 12.6 28 14.5 20.8 19.8 23.2 28 16 23.2 8.8 28 11.2 19.8 4 14.5 12.8 12.6Z" fill="#e35a1c"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 239 B |
555
website/assets/styles.css
Normal file
555
website/assets/styles.css
Normal file
@ -0,0 +1,555 @@
|
|||||||
|
:root {
|
||||||
|
--ink: #1a221c;
|
||||||
|
--ink-soft: #3d4a40;
|
||||||
|
--mist: #f3efe6;
|
||||||
|
--paper: #faf7f0;
|
||||||
|
--ember: #e35a1c;
|
||||||
|
--ember-deep: #b84312;
|
||||||
|
--leaf: #2f6b4f;
|
||||||
|
--line: rgba(26, 34, 28, 0.12);
|
||||||
|
--shadow: 0 28px 60px rgba(26, 34, 28, 0.14);
|
||||||
|
--font-display: "Bricolage Grotesque", "Segoe UI", sans-serif;
|
||||||
|
--font-body: "Source Serif 4", Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
*,
|
||||||
|
*::before,
|
||||||
|
*::after {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
color: var(--ink);
|
||||||
|
font-family: var(--font-body);
|
||||||
|
font-size: 1.125rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
background:
|
||||||
|
radial-gradient(1200px 700px at 12% -10%, #ffe2c4 0%, transparent 55%),
|
||||||
|
radial-gradient(900px 600px at 100% 8%, #cfe8d7 0%, transparent 50%),
|
||||||
|
linear-gradient(180deg, #f7f2e8 0%, var(--mist) 45%, #e8efe8 100%);
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grain {
|
||||||
|
pointer-events: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
opacity: 0.035;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: inherit;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
text-underline-offset: 0.2em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
padding: 0;
|
||||||
|
margin: -1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top {
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1.25rem 1.5rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.55rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1rem;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mark-icon {
|
||||||
|
width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
fill: var(--ember);
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.25rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav a {
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.78;
|
||||||
|
}
|
||||||
|
|
||||||
|
.top-nav a:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.95fr);
|
||||||
|
align-items: center;
|
||||||
|
gap: 2rem 3rem;
|
||||||
|
max-width: 1120px;
|
||||||
|
min-height: calc(100vh - 4.5rem);
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2.5rem 1.5rem 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
max-width: 34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--leaf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: clamp(2.8rem, 7vw, 5.2rem);
|
||||||
|
line-height: 0.95;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lede {
|
||||||
|
margin: 1.25rem 0 0;
|
||||||
|
max-width: 28rem;
|
||||||
|
font-size: clamp(1.1rem, 2vw, 1.35rem);
|
||||||
|
color: var(--ink-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.cta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.85rem;
|
||||||
|
padding: 0.7rem 1.25rem;
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
text-decoration: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 160ms ease, background-color 160ms ease, border-color 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: var(--ember);
|
||||||
|
color: #fffaf4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: var(--ember-deep);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border-color: var(--line);
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-ghost:hover {
|
||||||
|
border-color: rgba(26, 34, 28, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stage {
|
||||||
|
position: relative;
|
||||||
|
min-height: 28rem;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb {
|
||||||
|
position: absolute;
|
||||||
|
border-radius: 50%;
|
||||||
|
filter: blur(2px);
|
||||||
|
animation: drift 9s ease-in-out infinite alternate;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-a {
|
||||||
|
width: 18rem;
|
||||||
|
height: 18rem;
|
||||||
|
background: radial-gradient(circle at 30% 30%, #ffb07a, #e35a1c66 55%, transparent 70%);
|
||||||
|
top: 8%;
|
||||||
|
right: 8%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb-b {
|
||||||
|
width: 14rem;
|
||||||
|
height: 14rem;
|
||||||
|
background: radial-gradient(circle at 40% 40%, #8fd0ad, #2f6b4f55 60%, transparent 72%);
|
||||||
|
bottom: 10%;
|
||||||
|
left: 4%;
|
||||||
|
animation-delay: -3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device {
|
||||||
|
position: relative;
|
||||||
|
width: min(100%, 18.5rem);
|
||||||
|
aspect-ratio: 9 / 17.5;
|
||||||
|
border-radius: 2rem;
|
||||||
|
padding: 0.7rem;
|
||||||
|
background: linear-gradient(160deg, #2a332c, #151a17);
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
transform: rotate(3deg);
|
||||||
|
animation: floaty 6.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-bezel {
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 1.45rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background:
|
||||||
|
linear-gradient(180deg, rgba(255, 255, 255, 0.08), transparent 30%),
|
||||||
|
linear-gradient(180deg, #203028, #101512);
|
||||||
|
padding: 1.4rem 0.95rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.7rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble {
|
||||||
|
max-width: 88%;
|
||||||
|
padding: 0.7rem 0.85rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.in {
|
||||||
|
align-self: flex-start;
|
||||||
|
background: #eef4ef;
|
||||||
|
color: var(--ink);
|
||||||
|
border-bottom-left-radius: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bubble.out {
|
||||||
|
align-self: flex-end;
|
||||||
|
background: var(--ember);
|
||||||
|
color: #fff8f1;
|
||||||
|
border-bottom-right-radius: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing {
|
||||||
|
align-self: flex-start;
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.28rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background: #eef4ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing span {
|
||||||
|
width: 0.38rem;
|
||||||
|
height: 0.38rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #6d7a70;
|
||||||
|
animation: blink 1.2s infinite ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing span:nth-child(2) {
|
||||||
|
animation-delay: 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.typing span:nth-child(3) {
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rise {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
animation: rise-in 700ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||||
|
animation-delay: var(--d, 0s);
|
||||||
|
}
|
||||||
|
|
||||||
|
.features {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1rem 1.5rem 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-intro {
|
||||||
|
max-width: 36rem;
|
||||||
|
margin-bottom: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features h2,
|
||||||
|
.download h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: clamp(1.9rem, 4vw, 2.8rem);
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.03em;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-intro p,
|
||||||
|
.download > p {
|
||||||
|
margin: 0.9rem 0 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
max-width: 34rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-rail {
|
||||||
|
list-style: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-rail li {
|
||||||
|
padding: 1.6rem 1.4rem 0.4rem 0;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-rail li + li {
|
||||||
|
padding-left: 1.4rem;
|
||||||
|
border-left: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-rail h3 {
|
||||||
|
margin: 0 0 0.55rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 1.15rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-rail p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 1.02rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.download {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto 2rem;
|
||||||
|
padding: 3rem 1.5rem 4rem;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.65rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
max-width: 32rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify input {
|
||||||
|
flex: 1 1 14rem;
|
||||||
|
min-height: 2.85rem;
|
||||||
|
padding: 0.7rem 0.95rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 0.55rem;
|
||||||
|
background: rgba(255, 255, 255, 0.55);
|
||||||
|
color: var(--ink);
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify input:focus {
|
||||||
|
outline: 2px solid rgba(227, 90, 28, 0.35);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify .btn {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-note {
|
||||||
|
margin: 0.85rem 0 0;
|
||||||
|
color: var(--leaf);
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.store-chip {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.88rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
max-width: 1120px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 1.5rem 2.5rem;
|
||||||
|
color: var(--ink-soft);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot p {
|
||||||
|
margin: 0.35rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot-links {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot a {
|
||||||
|
text-decoration: none;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.foot a:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes drift {
|
||||||
|
from {
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translate3d(-12px, 16px, 0) scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes floaty {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: rotate(3deg) translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: rotate(2deg) translateY(-10px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rise-in {
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes blink {
|
||||||
|
0%,
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.35;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.hero {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
min-height: auto;
|
||||||
|
padding-top: 1.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-stage {
|
||||||
|
order: -1;
|
||||||
|
min-height: 22rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device {
|
||||||
|
width: min(100%, 15.5rem);
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-rail {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-rail li + li {
|
||||||
|
padding-left: 0;
|
||||||
|
border-left: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
html {
|
||||||
|
scroll-behavior: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.orb,
|
||||||
|
.device,
|
||||||
|
.typing span,
|
||||||
|
.rise {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rise {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
107
website/index.html
Normal file
107
website/index.html
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Spark Messenger — 即时沟通,火花相连</title>
|
||||||
|
<meta name="description" content="Spark Messenger 是面向好友、群聊与朋友圈的即时通讯应用。安全、简洁、跨端同步。" />
|
||||||
|
<link rel="canonical" href="https://sparkmessenger.wyrs365.com/" />
|
||||||
|
<link rel="icon" href="/assets/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,500;12..96,700;12..96,800&family=Source+Serif+4:opsz,wght@8..60,400;8..60,600&display=swap" rel="stylesheet" />
|
||||||
|
<link rel="stylesheet" href="/assets/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="grain" aria-hidden="true"></div>
|
||||||
|
|
||||||
|
<header class="top">
|
||||||
|
<a class="mark" href="/">
|
||||||
|
<svg class="mark-icon" viewBox="0 0 32 32" aria-hidden="true">
|
||||||
|
<path d="M16 2.5 19.8 12.2 30 14.5 21.8 20.8 24.5 30 16 24.6 7.5 30 10.2 20.8 2 14.5 12.2 12.2Z" />
|
||||||
|
</svg>
|
||||||
|
<span>Spark Messenger</span>
|
||||||
|
</a>
|
||||||
|
<nav class="top-nav" aria-label="主导航">
|
||||||
|
<a href="#features">能力</a>
|
||||||
|
<a href="#download">下载</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<section class="hero" aria-labelledby="brand">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<p class="eyebrow">即时通讯</p>
|
||||||
|
<h1 id="brand">Spark Messenger</h1>
|
||||||
|
<p class="lede">把对话、群聊与朋友圈收进同一束火花——安静、可靠、随处可达。</p>
|
||||||
|
<div class="cta">
|
||||||
|
<a class="btn btn-primary" href="#download">获取应用</a>
|
||||||
|
<a class="btn btn-ghost" href="https://sparkapi.wyrs365.com/docs" rel="noopener">开发者文档</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hero-stage" aria-hidden="true">
|
||||||
|
<div class="orb orb-a"></div>
|
||||||
|
<div class="orb orb-b"></div>
|
||||||
|
<div class="device">
|
||||||
|
<div class="device-bezel">
|
||||||
|
<div class="chat">
|
||||||
|
<div class="bubble in rise" style="--d:0.1s">今晚一起走?</div>
|
||||||
|
<div class="bubble out rise" style="--d:0.35s">好,我这就出发。</div>
|
||||||
|
<div class="bubble in rise" style="--d:0.6s">路上见。</div>
|
||||||
|
<div class="typing rise" style="--d:0.9s"><span></span><span></span><span></span></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="features" class="features" aria-labelledby="features-title">
|
||||||
|
<div class="features-intro">
|
||||||
|
<h2 id="features-title">为真实对话而造</h2>
|
||||||
|
<p>少一层噪音,多一点连接。消息直达,媒体直传,跨设备保持同一条时间线。</p>
|
||||||
|
</div>
|
||||||
|
<ul class="feature-rail">
|
||||||
|
<li>
|
||||||
|
<h3>一对一与群聊</h3>
|
||||||
|
<p>长连接实时推送,撤回与删除按会话清晰落地。</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<h3>朋友圈</h3>
|
||||||
|
<p>图片与短文按可见范围发布,不挤占会话列表。</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<h3>媒体直传</h3>
|
||||||
|
<p>对象存储签名上传,聊天里只留引用,加载更快。</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="download" class="download" aria-labelledby="download-title">
|
||||||
|
<h2 id="download-title">准备好了就装上</h2>
|
||||||
|
<p>Android 与 iOS 客户端陆续开放。留下邮箱,我们会在上架时通知你。</p>
|
||||||
|
<form class="notify" action="#" method="get" onsubmit="return false;">
|
||||||
|
<label class="sr-only" for="email">邮箱</label>
|
||||||
|
<input id="email" name="email" type="email" placeholder="you@example.com" autocomplete="email" required />
|
||||||
|
<button type="submit" class="btn btn-primary">提醒我</button>
|
||||||
|
</form>
|
||||||
|
<p class="notify-note" id="notify-note" hidden>已记下。上架时会联系你。</p>
|
||||||
|
<div class="store-row" aria-label="应用商店">
|
||||||
|
<span class="store-chip">App Store · 即将推出</span>
|
||||||
|
<span class="store-chip">Google Play · 即将推出</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="foot">
|
||||||
|
<p><strong>Spark Messenger</strong> · wyrs365</p>
|
||||||
|
<p class="foot-links">
|
||||||
|
<a href="https://sparkmessenger.wyrs365.com/">sparkmessenger.wyrs365.com</a>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<a href="https://sparkmsg.wyrs365.com/">sparkmsg.wyrs365.com</a>
|
||||||
|
</p>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="/assets/app.js" defer></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue
Block a user