Merge branch 'feature/new-homepage'
@@ -6,7 +6,7 @@ from contextlib import asynccontextmanager
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
from routes import account, messaging, profile, push, webrtc, devices, moderation
|
from routes import account, messaging, profile, push, webrtc, devices, moderation, download
|
||||||
import logging
|
import logging
|
||||||
from models import User
|
from models import User
|
||||||
from constants import OWNER_USERNAME
|
from constants import OWNER_USERNAME
|
||||||
@@ -176,3 +176,4 @@ app.include_router(push.router, prefix="/push")
|
|||||||
app.include_router(webrtc.router, prefix="/webrtc")
|
app.include_router(webrtc.router, prefix="/webrtc")
|
||||||
app.include_router(devices.router, prefix="/devices")
|
app.include_router(devices.router, prefix="/devices")
|
||||||
app.include_router(moderation.router)
|
app.include_router(moderation.router)
|
||||||
|
app.include_router(download.router)
|
||||||
@@ -0,0 +1,381 @@
|
|||||||
|
"""
|
||||||
|
Download routes for FromChat desktop and mobile builds.
|
||||||
|
Fetches from GitHub Actions (PC) and GitHub Releases (mobile), with disk caching.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from fastapi.responses import FileResponse, Response, StreamingResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/download", tags=["download"])
|
||||||
|
|
||||||
|
GITHUB_API = "https://api.github.com"
|
||||||
|
WEB_OWNER, WEB_REPO = "fromchat-messenger", "web"
|
||||||
|
APP_OWNER, APP_REPO = "fromchat-messenger", "app"
|
||||||
|
WORKFLOW_FILE = "build.yml"
|
||||||
|
TIMEOUT = 10.0
|
||||||
|
|
||||||
|
ARTIFACT_NAMES = {
|
||||||
|
"windows": "FromChat-windows",
|
||||||
|
"linux": "FromChat-linux",
|
||||||
|
"macos": "FromChat-macOS",
|
||||||
|
}
|
||||||
|
|
||||||
|
CACHE_DIR = Path(__file__).resolve().parent.parent / "data" / "downloads"
|
||||||
|
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _headers() -> dict[str, str]:
|
||||||
|
token = os.environ.get("RELEASES_TOKEN")
|
||||||
|
if not token:
|
||||||
|
raise HTTPException(status_code=503, detail="RELEASES_TOKEN not configured")
|
||||||
|
return {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/vnd.github+json",
|
||||||
|
"X-GitHub-Api-Version": "2022-11-28",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _etag_path(os_name: str) -> Path:
|
||||||
|
return CACHE_DIR / f"{os_name}.etag"
|
||||||
|
|
||||||
|
|
||||||
|
def _cached_file_path(os_name: str) -> Path:
|
||||||
|
ext = ".zip" if os_name in ARTIFACT_NAMES else (".apk" if os_name == "android" else ".ipa")
|
||||||
|
return CACHE_DIR / f"{os_name}{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_pc_artifact_url(os_name: str) -> tuple[str, int]:
|
||||||
|
"""Fetch workflow runs, get latest run, find artifact. Returns (download_url, artifact_id)."""
|
||||||
|
artifact_name = ARTIFACT_NAMES[os_name]
|
||||||
|
logger.info("[download] Fetching PC artifact for %s: workflow=%s/%s/%s", os_name, WEB_OWNER, WEB_REPO, WORKFLOW_FILE)
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
|
||||||
|
runs_url = f"{GITHUB_API}/repos/{WEB_OWNER}/{WEB_REPO}/actions/workflows/{WORKFLOW_FILE}/runs"
|
||||||
|
logger.info("[download] GitHub API: GET %s (per_page=1, status=success)", runs_url)
|
||||||
|
runs_resp = await client.get(
|
||||||
|
runs_url,
|
||||||
|
headers=_headers(),
|
||||||
|
params={"per_page": 1, "status": "success"},
|
||||||
|
)
|
||||||
|
logger.info("[download] GitHub workflow runs response: status=%s", runs_resp.status_code)
|
||||||
|
runs_resp.raise_for_status()
|
||||||
|
runs = runs_resp.json()
|
||||||
|
workflow_runs = runs.get("workflow_runs", [])
|
||||||
|
if not workflow_runs:
|
||||||
|
logger.warning("[download] No successful workflow runs for %s", artifact_name)
|
||||||
|
raise HTTPException(status_code=404, detail=f"No successful workflow run for {artifact_name}")
|
||||||
|
|
||||||
|
run_id = workflow_runs[0]["id"]
|
||||||
|
logger.info("[download] Latest run_id=%s, fetching artifacts", run_id)
|
||||||
|
artifacts_url = f"{GITHUB_API}/repos/{WEB_OWNER}/{WEB_REPO}/actions/runs/{run_id}/artifacts"
|
||||||
|
artifacts_resp = await client.get(artifacts_url, headers=_headers())
|
||||||
|
logger.info("[download] GitHub artifacts response: status=%s", artifacts_resp.status_code)
|
||||||
|
artifacts_resp.raise_for_status()
|
||||||
|
data = artifacts_resp.json()
|
||||||
|
for artifact in data.get("artifacts", []):
|
||||||
|
if artifact["name"] == artifact_name:
|
||||||
|
url = artifact["archive_download_url"]
|
||||||
|
aid = artifact["id"]
|
||||||
|
logger.info("[download] Found artifact %s id=%s, download_url=%s", artifact_name, aid, url[:80] + "..." if len(url) > 80 else url)
|
||||||
|
return url, aid
|
||||||
|
logger.warning("[download] Artifact %s not found in run %s", artifact_name, run_id)
|
||||||
|
raise HTTPException(status_code=404, detail=f"Artifact {artifact_name} not found")
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_mobile_asset_url(os_name: str) -> str:
|
||||||
|
"""Fetch latest release, find asset by name. Returns browser_download_url."""
|
||||||
|
keyword = "android" if os_name == "android" else "ios"
|
||||||
|
logger.info("[download] Fetching mobile asset for %s: releases %s/%s", os_name, APP_OWNER, APP_REPO)
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
|
||||||
|
releases_url = f"{GITHUB_API}/repos/{APP_OWNER}/{APP_REPO}/releases"
|
||||||
|
logger.info("[download] GitHub API: GET %s (per_page=10)", releases_url)
|
||||||
|
resp = await client.get(
|
||||||
|
releases_url,
|
||||||
|
headers=_headers(),
|
||||||
|
params={"per_page": 10},
|
||||||
|
)
|
||||||
|
logger.info("[download] GitHub releases response: status=%s", resp.status_code)
|
||||||
|
resp.raise_for_status()
|
||||||
|
releases = resp.json()
|
||||||
|
for release in releases:
|
||||||
|
if release.get("draft"):
|
||||||
|
continue
|
||||||
|
for asset in release.get("assets", []):
|
||||||
|
if keyword.lower() in asset.get("name", "").lower():
|
||||||
|
url = asset["browser_download_url"]
|
||||||
|
logger.info("[download] Found %s asset: %s (release: %s)", os_name, asset.get("name"), release.get("tag_name"))
|
||||||
|
return url
|
||||||
|
logger.warning("[download] No %s asset in releases", os_name)
|
||||||
|
raise HTTPException(status_code=404, detail=f"No {os_name} asset found in releases")
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_and_stream(
|
||||||
|
url: str,
|
||||||
|
os_name: str,
|
||||||
|
stored_etag: str | None,
|
||||||
|
) -> StreamingResponse | FileResponse:
|
||||||
|
"""Stream from GitHub to client and save to disk. If 304, serve from disk."""
|
||||||
|
etag_path = _etag_path(os_name)
|
||||||
|
cache_path = _cached_file_path(os_name)
|
||||||
|
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
headers = {**_headers(), "Accept": "*/*"}
|
||||||
|
if stored_etag:
|
||||||
|
headers["If-None-Match"] = stored_etag
|
||||||
|
|
||||||
|
logger.info("[download] Mobile %s: GET %s (etag=%s)", os_name, url[:100] + "..." if len(url) > 100 else url, stored_etag or "none")
|
||||||
|
|
||||||
|
async def stream_and_save():
|
||||||
|
total = 0
|
||||||
|
tmp_path = cache_path.with_name(cache_path.name + ".tmp")
|
||||||
|
new_etag: str | None = None
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||||
|
async with client.stream("GET", url, headers=headers) as resp:
|
||||||
|
if resp.status_code == 304 and cache_path.exists():
|
||||||
|
yield None
|
||||||
|
return
|
||||||
|
if resp.status_code != 200:
|
||||||
|
if resp.status_code in (404, 410):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail="Release asset not found on GitHub",
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="GitHub returned an error while downloading asset",
|
||||||
|
)
|
||||||
|
new_etag = resp.headers.get("etag")
|
||||||
|
logger.info("[download] Mobile %s: streaming (content-length=%s)", os_name, resp.headers.get("content-length") or "unknown")
|
||||||
|
with open(tmp_path, "wb") as f:
|
||||||
|
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
||||||
|
f.write(chunk)
|
||||||
|
total += len(chunk)
|
||||||
|
yield chunk
|
||||||
|
tmp_path.rename(cache_path)
|
||||||
|
if new_etag:
|
||||||
|
etag_path.write_text(new_etag)
|
||||||
|
logger.info("[download] Mobile %s: completed, saved %d bytes", os_name, total)
|
||||||
|
except httpx.StreamClosed:
|
||||||
|
logger.info("[download] Mobile %s: client disconnected after %d bytes", os_name, total)
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except httpx.TimeoutException:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
if cache_path.exists():
|
||||||
|
raise _CacheFallback()
|
||||||
|
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
|
||||||
|
except HTTPException:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
class _CacheFallback(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
gen = stream_and_save()
|
||||||
|
try:
|
||||||
|
first = await gen.__anext__()
|
||||||
|
except StopAsyncIteration:
|
||||||
|
first = None
|
||||||
|
except _CacheFallback:
|
||||||
|
await gen.aclose()
|
||||||
|
return FileResponse(str(cache_path), media_type="application/octet-stream", filename=cache_path.name)
|
||||||
|
if first is None:
|
||||||
|
await gen.aclose()
|
||||||
|
logger.info("[download] Mobile %s: serving from cache (304)", os_name)
|
||||||
|
return FileResponse(str(cache_path), media_type="application/octet-stream", filename=cache_path.name)
|
||||||
|
|
||||||
|
async def body():
|
||||||
|
yield first
|
||||||
|
async for chunk in gen:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
body(),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{cache_path.name}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_artifact_download_url(url: str) -> str:
|
||||||
|
"""Resolve artifact URL: GitHub 302 redirects to Azure; Azure rejects Authorization. Get Location without following."""
|
||||||
|
headers = {**_headers(), "Accept": "application/vnd.github+json"}
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT, follow_redirects=False) as client:
|
||||||
|
resp = await client.get(url, headers=headers)
|
||||||
|
if resp.status_code in (404, 410):
|
||||||
|
raise HTTPException(status_code=404, detail="Artifact not found on GitHub")
|
||||||
|
if resp.status_code != 302:
|
||||||
|
raise HTTPException(status_code=503, detail="GitHub returned an error while resolving artifact URL")
|
||||||
|
location = resp.headers.get("location")
|
||||||
|
if not location:
|
||||||
|
raise HTTPException(status_code=502, detail="No redirect location from GitHub")
|
||||||
|
return location
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_artifact_and_stream(
|
||||||
|
url: str,
|
||||||
|
os_name: str,
|
||||||
|
artifact_id: int,
|
||||||
|
) -> StreamingResponse | FileResponse:
|
||||||
|
"""Download artifact (zip). GitHub redirects to Azure; Azure must be called WITHOUT Authorization."""
|
||||||
|
etag_path = _etag_path(os_name)
|
||||||
|
cache_path = _cached_file_path(os_name)
|
||||||
|
stored_id = etag_path.read_text().strip() if etag_path.exists() else None
|
||||||
|
if stored_id == str(artifact_id) and cache_path.exists():
|
||||||
|
logger.info("[download] PC %s: serving from cache (artifact_id=%s)", os_name, artifact_id)
|
||||||
|
return FileResponse(
|
||||||
|
str(cache_path),
|
||||||
|
media_type="application/zip",
|
||||||
|
filename=cache_path.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
download_url = await _resolve_artifact_download_url(url)
|
||||||
|
except HTTPException:
|
||||||
|
if cache_path.exists():
|
||||||
|
logger.info("[download] PC %s: GitHub error, serving from cache", os_name)
|
||||||
|
return FileResponse(str(cache_path), media_type="application/zip", filename=cache_path.name)
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info("[download] PC %s: streaming from Azure URL (no auth)", os_name)
|
||||||
|
|
||||||
|
async def stream_and_save():
|
||||||
|
total = 0
|
||||||
|
tmp_path = cache_path.with_name(cache_path.name + ".tmp")
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=30.0, follow_redirects=True) as client:
|
||||||
|
async with client.stream("GET", download_url) as resp:
|
||||||
|
if resp.status_code != 200:
|
||||||
|
if resp.status_code in (404, 410):
|
||||||
|
raise HTTPException(status_code=404, detail="Artifact file not found on GitHub")
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail="GitHub returned an error while downloading artifact file",
|
||||||
|
)
|
||||||
|
logger.info("[download] PC %s: streaming (content-length=%s)", os_name, resp.headers.get("content-length") or "unknown")
|
||||||
|
with open(tmp_path, "wb") as f:
|
||||||
|
async for chunk in resp.aiter_bytes(chunk_size=65536):
|
||||||
|
f.write(chunk)
|
||||||
|
total += len(chunk)
|
||||||
|
yield chunk
|
||||||
|
tmp_path.rename(cache_path)
|
||||||
|
etag_path.write_text(str(artifact_id))
|
||||||
|
logger.info("[download] PC %s: completed, saved %d bytes", os_name, total)
|
||||||
|
except httpx.StreamClosed:
|
||||||
|
logger.info("[download] PC %s: client disconnected after %d bytes", os_name, total)
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
except HTTPException:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
gen = stream_and_save()
|
||||||
|
try:
|
||||||
|
first = await gen.__anext__()
|
||||||
|
except StopAsyncIteration:
|
||||||
|
first = None
|
||||||
|
except HTTPException:
|
||||||
|
if cache_path.exists():
|
||||||
|
return FileResponse(str(cache_path), media_type="application/zip", filename=cache_path.name)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if first is None:
|
||||||
|
await gen.aclose()
|
||||||
|
raise HTTPException(status_code=502, detail="Empty response from download")
|
||||||
|
|
||||||
|
async def body():
|
||||||
|
yield first
|
||||||
|
async for chunk in gen:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
body(),
|
||||||
|
media_type="application/zip",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="{cache_path.name}"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _head_response(filename: str, content_length: int | None = None) -> Response:
|
||||||
|
headers = {"Content-Disposition": f'attachment; filename="{filename}"'}
|
||||||
|
if content_length is not None:
|
||||||
|
headers["Content-Length"] = str(content_length)
|
||||||
|
return Response(status_code=200, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
@router.api_route("/{os_name}", methods=["GET", "HEAD"])
|
||||||
|
async def download(request: Request, os_name: str):
|
||||||
|
"""Download app for the given OS: windows, linux, macos, android, ios."""
|
||||||
|
is_head = request.method == "HEAD"
|
||||||
|
os_name = os_name.lower()
|
||||||
|
logger.info("[download] %s /download/%s", request.method, os_name)
|
||||||
|
|
||||||
|
if os_name not in ("windows", "linux", "macos", "android", "ios"):
|
||||||
|
raise HTTPException(status_code=400, detail="Invalid os. Use: windows, linux, macos, android, ios")
|
||||||
|
|
||||||
|
try:
|
||||||
|
if os_name in ARTIFACT_NAMES:
|
||||||
|
try:
|
||||||
|
url, artifact_id = await asyncio.wait_for(
|
||||||
|
_fetch_pc_artifact_url(os_name),
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("[download] PC %s: GitHub API timeout", os_name)
|
||||||
|
cache_path = _cached_file_path(os_name)
|
||||||
|
if cache_path.exists():
|
||||||
|
if is_head:
|
||||||
|
return _head_response(cache_path.name, cache_path.stat().st_size)
|
||||||
|
return FileResponse(
|
||||||
|
str(cache_path),
|
||||||
|
media_type="application/zip",
|
||||||
|
filename=cache_path.name,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
|
||||||
|
cache_path = _cached_file_path(os_name)
|
||||||
|
result = await _download_artifact_and_stream(url, os_name, artifact_id)
|
||||||
|
if is_head:
|
||||||
|
fn = getattr(result, "filename", None) or cache_path.name
|
||||||
|
size = cache_path.stat().st_size if cache_path.exists() else None
|
||||||
|
return _head_response(fn, size)
|
||||||
|
return result
|
||||||
|
else:
|
||||||
|
stored_etag = None
|
||||||
|
etag_path = _etag_path(os_name)
|
||||||
|
cache_path = _cached_file_path(os_name)
|
||||||
|
if etag_path.exists():
|
||||||
|
stored_etag = etag_path.read_text().strip() or None
|
||||||
|
|
||||||
|
try:
|
||||||
|
url = await asyncio.wait_for(
|
||||||
|
_fetch_mobile_asset_url(os_name),
|
||||||
|
timeout=TIMEOUT,
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
logger.warning("[download] Mobile %s: GitHub API timeout", os_name)
|
||||||
|
if cache_path.exists():
|
||||||
|
if is_head:
|
||||||
|
return _head_response(cache_path.name, cache_path.stat().st_size)
|
||||||
|
return FileResponse(
|
||||||
|
str(cache_path),
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
filename=cache_path.name,
|
||||||
|
)
|
||||||
|
raise HTTPException(status_code=503, detail="GitHub unavailable and no cached file")
|
||||||
|
|
||||||
|
result = await _download_and_stream(url, os_name, stored_etag)
|
||||||
|
if is_head:
|
||||||
|
fn = getattr(result, "filename", None) or cache_path.name
|
||||||
|
size = cache_path.stat().st_size if cache_path.exists() else None
|
||||||
|
return _head_response(fn, size)
|
||||||
|
return result
|
||||||
|
except HTTPException as exc:
|
||||||
|
if exc.status_code in (404, 410):
|
||||||
|
raise HTTPException(status_code=404, detail=exc.detail)
|
||||||
|
if exc.status_code in (502, 503, 504):
|
||||||
|
raise HTTPException(status_code=503, detail=exc.detail)
|
||||||
|
raise
|
||||||
@@ -0,0 +1,260 @@
|
|||||||
|
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent, type ReactNode } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
|
import { MaterialIcon, MaterialRipple, useRippleHandlers } from "@/utils/material";
|
||||||
|
import useWindowSize from "@/core/hooks/useWindowSize";
|
||||||
|
import styles from "./css/split-button.module.scss";
|
||||||
|
|
||||||
|
export type SplitButtonVariant = "filled" | "tonal" | "outlined" | "elevated";
|
||||||
|
|
||||||
|
interface SplitButtonProps {
|
||||||
|
text: ReactNode;
|
||||||
|
icon?: ReactNode | string;
|
||||||
|
menu: ReactNode;
|
||||||
|
menuOpen: boolean;
|
||||||
|
onMenuOpen: (open: boolean) => void;
|
||||||
|
onPrimaryClick?: () => void;
|
||||||
|
variant?: SplitButtonVariant;
|
||||||
|
disabled?: boolean;
|
||||||
|
className?: string;
|
||||||
|
menuAriaLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SplitButton({
|
||||||
|
text,
|
||||||
|
icon,
|
||||||
|
menu,
|
||||||
|
menuOpen: open,
|
||||||
|
onMenuOpen,
|
||||||
|
onPrimaryClick,
|
||||||
|
variant = "filled",
|
||||||
|
disabled = false,
|
||||||
|
className = "",
|
||||||
|
menuAriaLabel,
|
||||||
|
}: SplitButtonProps) {
|
||||||
|
const [isExiting, setIsExiting] = useState(false);
|
||||||
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const menuSegmentRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [menuPosition, setMenuPosition] = useState<{
|
||||||
|
top?: number;
|
||||||
|
bottom?: number;
|
||||||
|
left: number;
|
||||||
|
maxHeight: number;
|
||||||
|
} | null>(null);
|
||||||
|
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||||
|
const primaryRipple = useRippleHandlers(disabled);
|
||||||
|
const menuRipple = useRippleHandlers(disabled);
|
||||||
|
|
||||||
|
const MENU_GAP = 16;
|
||||||
|
const EDGE_PAD = 16;
|
||||||
|
|
||||||
|
const updateMenuPosition = useCallback(() => {
|
||||||
|
const anchor = menuSegmentRef.current;
|
||||||
|
if (!anchor) return;
|
||||||
|
const rect = anchor.getBoundingClientRect();
|
||||||
|
const menuEl = menuRef.current;
|
||||||
|
const menuWidth = menuEl?.offsetWidth ?? 220;
|
||||||
|
const menuHeight = menuEl?.offsetHeight ?? 320;
|
||||||
|
|
||||||
|
const anchorCenterX = rect.left + rect.width / 2;
|
||||||
|
let left: number;
|
||||||
|
let top: number | undefined;
|
||||||
|
let bottom: number | undefined;
|
||||||
|
let maxHeight: number;
|
||||||
|
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
|
||||||
|
const availableBelow = vh - rect.bottom - MENU_GAP - EDGE_PAD;
|
||||||
|
const availableAbove = rect.top - MENU_GAP - EDGE_PAD;
|
||||||
|
const fitsBelow = menuHeight <= availableBelow;
|
||||||
|
const fitsAbove = menuHeight <= availableAbove;
|
||||||
|
const placeAbove = !fitsBelow && (fitsAbove || availableAbove > availableBelow);
|
||||||
|
|
||||||
|
if (placeAbove) {
|
||||||
|
bottom = vh - (rect.top - MENU_GAP);
|
||||||
|
maxHeight = Math.max(100, availableAbove);
|
||||||
|
} else {
|
||||||
|
top = rect.bottom + MENU_GAP;
|
||||||
|
maxHeight = Math.max(100, availableBelow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (anchorCenterX - menuWidth / 2 < EDGE_PAD) {
|
||||||
|
left = EDGE_PAD;
|
||||||
|
} else if (anchorCenterX + menuWidth / 2 > vw - EDGE_PAD) {
|
||||||
|
left = vw - menuWidth - EDGE_PAD;
|
||||||
|
} else {
|
||||||
|
left = anchorCenterX - menuWidth / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMenuPosition({ top, bottom, left, maxHeight });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeMenu = useCallback(() => {
|
||||||
|
onMenuOpen(false);
|
||||||
|
setIsExiting(true);
|
||||||
|
}, [onMenuOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open && !isExiting) {
|
||||||
|
setMenuPosition(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
updateMenuPosition();
|
||||||
|
window.addEventListener("scroll", updateMenuPosition, true);
|
||||||
|
|
||||||
|
function handleDocumentClick(event: MouseEvent | globalThis.MouseEvent) {
|
||||||
|
const target = event.target as Node | null;
|
||||||
|
if (!target) return;
|
||||||
|
if (rootRef.current?.contains(target)) return;
|
||||||
|
if (menuRef.current?.contains(target)) return;
|
||||||
|
closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === "Escape") closeMenu();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("mousedown", handleDocumentClick as unknown as EventListener);
|
||||||
|
document.addEventListener("touchstart", handleDocumentClick as unknown as EventListener);
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("scroll", updateMenuPosition, true);
|
||||||
|
document.removeEventListener("mousedown", handleDocumentClick as unknown as EventListener);
|
||||||
|
document.removeEventListener("touchstart", handleDocumentClick as unknown as EventListener);
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [open, isExiting, closeMenu, updateMenuPosition, windowWidth, windowHeight]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setIsExiting(true);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (open && menuRef.current) {
|
||||||
|
updateMenuPosition();
|
||||||
|
}
|
||||||
|
}, [open, updateMenuPosition]);
|
||||||
|
|
||||||
|
const handlePrimaryClick = () => {
|
||||||
|
if (disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPrimaryClick?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMenuToggle = () => {
|
||||||
|
if (disabled) return;
|
||||||
|
if (open) closeMenu();
|
||||||
|
else onMenuOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantClass =
|
||||||
|
variant === "tonal"
|
||||||
|
? styles.variantTonal
|
||||||
|
: variant === "outlined"
|
||||||
|
? styles.variantOutlined
|
||||||
|
: variant === "elevated"
|
||||||
|
? styles.variantElevated
|
||||||
|
: styles.variantFilled;
|
||||||
|
|
||||||
|
const renderIcon = () => {
|
||||||
|
if (!icon) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof icon === "string") {
|
||||||
|
return <MaterialIcon name={icon} className={styles.leadingIconIcon} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span className={styles.leadingIconIcon}>{icon}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const rootClasses = [
|
||||||
|
styles.splitButton,
|
||||||
|
variantClass,
|
||||||
|
disabled ? styles.disabled : "",
|
||||||
|
className,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={rootRef}
|
||||||
|
className={rootClasses}
|
||||||
|
data-open={open ? "true" : "false"}
|
||||||
|
aria-disabled={disabled ? "true" : "false"}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.primarySegment}
|
||||||
|
onClick={handlePrimaryClick}
|
||||||
|
onPointerDown={primaryRipple.onPointerDown}
|
||||||
|
onPointerEnter={primaryRipple.onPointerEnter}
|
||||||
|
onPointerLeave={primaryRipple.onPointerLeave}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<MaterialRipple ref={primaryRipple.rippleRef} />
|
||||||
|
<span className={styles.primaryContent}>
|
||||||
|
{icon && <span className={styles.leadingIcon}>{renderIcon()}</span>}
|
||||||
|
<span className={styles.label}>{text}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
ref={menuSegmentRef}
|
||||||
|
type="button"
|
||||||
|
className={styles.menuSegment}
|
||||||
|
onClick={handleMenuToggle}
|
||||||
|
onPointerDown={menuRipple.onPointerDown}
|
||||||
|
onPointerEnter={menuRipple.onPointerEnter}
|
||||||
|
onPointerLeave={menuRipple.onPointerLeave}
|
||||||
|
disabled={disabled}
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-label={menuAriaLabel}
|
||||||
|
>
|
||||||
|
<MaterialRipple ref={menuRipple.rippleRef} />
|
||||||
|
<span className={styles.menuIcon}>
|
||||||
|
<MaterialIcon name="expand_more" />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{(open || isExiting) &&
|
||||||
|
menuPosition &&
|
||||||
|
createPortal(
|
||||||
|
<AnimatePresence onExitComplete={() => setIsExiting(false)}>
|
||||||
|
{open && (
|
||||||
|
<motion.div
|
||||||
|
key="menu"
|
||||||
|
ref={menuRef}
|
||||||
|
className={styles.menu}
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
...(menuPosition.bottom != null
|
||||||
|
? { bottom: menuPosition.bottom }
|
||||||
|
: { top: menuPosition.top }),
|
||||||
|
left: menuPosition.left,
|
||||||
|
maxHeight: menuPosition.maxHeight,
|
||||||
|
overflowY: "auto",
|
||||||
|
}}
|
||||||
|
initial={{ opacity: 0, y: -4 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
exit={{ opacity: 0, y: -4 }}
|
||||||
|
transition={{ duration: 0.16, ease: "easeOut" }}
|
||||||
|
>
|
||||||
|
{menu}
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
@use "../../../css/material" as *;
|
||||||
|
|
||||||
|
$height: 40px;
|
||||||
|
$trailing-width: 48px; // 12 + 22 + 14 per spec
|
||||||
|
$between-space: 2px;
|
||||||
|
$outer-radius: calc(#{$height} / 2); // 20px
|
||||||
|
$inner-radius: 4px;
|
||||||
|
$inner-radius-hovered: 12px;
|
||||||
|
|
||||||
|
.splitButton {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: stretch;
|
||||||
|
position: relative;
|
||||||
|
border-radius: $outer-radius;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
letter-spacing: 0.1px;
|
||||||
|
background-color: transparent;
|
||||||
|
color: $color-dark-on-primary;
|
||||||
|
isolation: isolate;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.38;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primarySegment,
|
||||||
|
.menuSegment {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
outline: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0;
|
||||||
|
min-height: $height;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
box-sizing: border-box;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid $color-dark-primary;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
mdui-ripple {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.primarySegment {
|
||||||
|
border-top-left-radius: $outer-radius;
|
||||||
|
border-bottom-left-radius: $outer-radius;
|
||||||
|
border-top-right-radius: $inner-radius;
|
||||||
|
border-bottom-right-radius: $inner-radius;
|
||||||
|
padding-inline: 16px 12px;
|
||||||
|
transition: border-top-right-radius 0.18s ease-out, border-bottom-right-radius 0.18s ease-out;
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
border-top-right-radius: $inner-radius-hovered;
|
||||||
|
border-bottom-right-radius: $inner-radius-hovered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
border-top-right-radius: $inner-radius-hovered;
|
||||||
|
border-bottom-right-radius: $inner-radius-hovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primaryContent {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
.leadingIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.leadingIconIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuSegment {
|
||||||
|
width: $trailing-width;
|
||||||
|
border-top-right-radius: $outer-radius;
|
||||||
|
border-bottom-right-radius: $outer-radius;
|
||||||
|
border-top-left-radius: $inner-radius;
|
||||||
|
border-bottom-left-radius: $inner-radius;
|
||||||
|
margin-left: $between-space;
|
||||||
|
padding-inline: 12px 14px;
|
||||||
|
transition:
|
||||||
|
border-top-left-radius 0.18s ease-out,
|
||||||
|
border-bottom-left-radius 0.18s ease-out,
|
||||||
|
padding-inline 0.18s ease-out;
|
||||||
|
|
||||||
|
@media (hover: hover) {
|
||||||
|
&:hover {
|
||||||
|
border-top-left-radius: $inner-radius-hovered;
|
||||||
|
border-bottom-left-radius: $inner-radius-hovered;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
border-top-left-radius: $inner-radius-hovered;
|
||||||
|
border-bottom-left-radius: $inner-radius-hovered;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menuIcon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
font-size: 22px;
|
||||||
|
transition: transform 0.18s ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
mdui-icon {
|
||||||
|
width: inherit;
|
||||||
|
height: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&[data-open="true"] .menuSegment {
|
||||||
|
$size: calc($trailing-width / 2);
|
||||||
|
border-radius: $size;
|
||||||
|
padding-inline: 13px 13px;
|
||||||
|
|
||||||
|
.menuIcon {
|
||||||
|
transform: rotate(-180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.variantFilled {
|
||||||
|
.primarySegment, .menuSegment {
|
||||||
|
background-color: $color-dark-primary;
|
||||||
|
color: $color-dark-on-primary;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.variantTonal {
|
||||||
|
.primarySegment, .menuSegment {
|
||||||
|
background-color: $color-dark-primary-container;
|
||||||
|
color: $color-dark-on-primary-container;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.variantOutlined {
|
||||||
|
.primarySegment, .menuSegment {
|
||||||
|
border: 1px solid rgba($color-dark-outline, 0.8);
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.variantElevated {
|
||||||
|
.primarySegment, .menuSegment {
|
||||||
|
box-shadow:
|
||||||
|
0 1px 3px rgba(0, 0, 0, 0.3),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.15);
|
||||||
|
background-color: $color-dark-surface-container-low;
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
$menu-padding: 8px;
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
padding: $menu-padding;
|
||||||
|
min-width: 220px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background-color: rgba($color-dark-surface-container-high, 0.4);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
-webkit-backdrop-filter: blur(12px);
|
||||||
|
z-index: 100000000;
|
||||||
|
|
||||||
|
// Custom slim semi-transparent scrollbar
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba($color-dark-on-surface, 0.25);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba($color-dark-on-surface, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: rgba($color-dark-on-surface, 0.25) transparent;
|
||||||
|
|
||||||
|
mdui-list {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" version="1.1" width="256" height="256" viewBox="0 0 256 256">
|
||||||
|
<line x1="1.407" y1="1.353" x2="1.407" y2="1.46" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0, 0, 0); fill-rule: nonzero; opacity: 1;"/>
|
||||||
|
<g transform="matrix(1.329817, 0, 0, 1.329817, -42.024433, -38.371166)" style="">
|
||||||
|
<g transform="matrix(1, 0, 0, 1, -0.000001, 0.000008)">
|
||||||
|
<path d="M 187.533 167.601 C 184.614 169.278 180.394 175.015 176.028 176.522 L 173.703 176.879 C 173.692 176.878 173.682 176.878 173.671 176.877 C 170.911 176.954 167.048 175.793 164.888 173.621 C 164.62 173.351 164.378 173.064 164.167 172.763 C 163.081 170.76 162.591 168.959 162.078 167.519 C 163.681 138.029 142.006 97.682 141.345 89.031 L 140.535 89.081 L 143.379 87.758 C 146.633 86.246 146.577 81.601 143.289 80.165 L 126.072 72.64 C 124.802 72.084 123.338 72.196 122.169 72.941 L 109.131 81.222 C 106.644 82.801 106.537 86.395 108.928 88.118 L 120.16 96.21 C 121.382 97.093 122.987 97.244 124.353 96.607 L 131.993 93.054 L 124.35 96.609 C 122.984 97.244 121.38 97.093 120.157 96.213 L 112.629 90.787 L 112.182 90.815 C 112.182 96.766 92.754 126.354 85.61 153.184 C 82.2 144.438 85.83 134.593 92.372 122.051 C 83.667 131.338 75.779 146.462 83.605 158.699 C 87.499 164.679 93.454 168.284 99.138 171.235 C 103.255 173.904 113.854 176.441 110.918 182.885 C 110.925 182.876 110.931 182.867 110.938 182.859 C 110.931 182.869 110.925 182.88 110.918 182.89 C 108.679 187.681 104.866 190.521 100.012 192.077 C 98.585 189.825 96.894 187.456 94.937 184.967 L 90.627 179.198 C 89.739 177.993 88.66 176.155 87.266 173.682 C 85.934 171.212 84.729 169.31 83.779 167.978 C 82.956 166.519 81.624 165.061 79.912 163.603 C 79.401 163.15 78.861 162.743 78.299 162.386 C 75.967 160.899 72.963 160.587 70.538 161.919 C 69.819 162.313 69.262 162.771 68.883 163.288 C 67.998 164.493 67.489 165.825 67.363 167.219 C 67.172 168.551 66.792 169.439 66.157 169.883 C 66.152 169.886 66.141 169.891 66.135 169.894 C 65.64 163.51 67.408 157.038 70.867 151.489 C 73.595 147.111 75.664 142.699 76.428 138.228 C 76.779 130.993 78.71 123.934 82.113 117.038 C 86.159 107.861 95.837 101.976 98.644 92.659 C 102.474 80.66 83.964 28.579 126.067 28.984 C 150.53 28.984 159.32 50.152 159.32 84.644 C 159.32 107.391 201.316 122.18 187.533 167.601 Z M 151.486 211.429 C 151.801 212.025 152.146 212.581 152.52 213.101 C 136.094 207.361 120.468 206.548 104.498 212.435 C 105.326 211.043 105.778 209.435 105.778 207.781 C 105.778 207.779 105.778 207.778 105.778 207.776 C 105.778 207.761 105.778 207.746 105.778 207.731 C 105.778 207.729 105.778 207.727 105.778 207.725 C 105.778 204.739 104.88 201.359 103.085 197.582 C 107.83 198.93 113.536 199.677 120.393 199.677 C 136.171 200.012 146.507 196.345 152.958 190.163 C 151.666 200.295 149.855 207.938 151.486 211.429 Z M 168.963 155.718 C 167.694 156.465 166.457 157.278 165.336 158.247 C 164.063 159.306 162.925 160.573 162.122 161.99 C 164.732 160.051 167.618 158.932 170.555 157.952 L 170.855 157.851 C 172.058 156.221 173.112 154.49 173.983 152.649 C 179.496 140.288 172.1 126.454 162.265 118.749 C 168.908 130.233 175.535 143.047 168.963 155.718 Z M 110.325 80.46 L 121.571 73.32 C 121.829 72.168 121.972 70.94 121.972 69.664 C 121.972 63.111 118.283 57.8 113.731 57.8 C 109.179 57.8 105.489 63.111 105.489 69.664 C 105.489 74.469 107.476 78.594 110.325 80.46 Z M 140.237 78.831 C 142.075 76.656 143.244 73.357 143.244 69.664 C 143.244 63.111 139.555 57.8 135.002 57.8 C 130.45 57.8 126.761 63.111 126.761 69.664 C 126.761 70.859 126.887 72.008 127.115 73.095 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 97.708 188.673 C 97.691 188.648 97.674 188.626 97.658 188.6 C 97.677 188.626 97.691 188.651 97.708 188.673 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 178.569 117.583 L 162.265 118.749 C 173.339 131.799 177.532 144.523 169.998 156.693 C 166.446 158.264 163.822 160.028 162.122 161.987 C 162.189 163.875 162.178 165.735 162.08 167.551 C 161.65 166.34 161.203 165.379 160.377 164.766 C 160.374 164.774 160.368 164.786 160.363 164.794 C 161.832 165.882 162.13 168.073 163.274 170.855 C 163.316 170.956 163.353 171.052 163.397 171.156 C 163.619 171.676 163.867 172.21 164.167 172.763 C 166.07 175.489 170.571 176.97 173.676 176.883 C 174.039 176.905 174.404 176.88 174.767 176.829 C 174.882 176.812 175 176.781 175.115 176.759 C 175.365 176.708 175.615 176.649 175.863 176.571 C 175.995 176.529 176.124 176.481 176.256 176.43 C 176.495 176.34 176.734 176.236 176.973 176.124 C 177.099 176.065 177.223 176.006 177.349 175.941 C 177.616 175.801 177.883 175.646 178.147 175.48 C 178.237 175.424 178.327 175.376 178.414 175.317 C 178.768 175.087 179.122 174.84 179.471 174.576 C 179.532 174.531 179.591 174.48 179.651 174.432 C 179.943 174.208 180.229 173.977 180.516 173.738 C 180.628 173.646 180.738 173.55 180.848 173.454 C 181.086 173.249 181.325 173.041 181.558 172.831 C 181.668 172.732 181.778 172.631 181.887 172.533 C 182.14 172.302 182.39 172.069 182.638 171.836 C 182.713 171.766 182.789 171.693 182.865 171.622 C 183.202 171.302 183.537 170.987 183.863 170.675 C 184.141 170.411 184.413 170.153 184.68 169.903 C 184.773 169.818 184.86 169.737 184.95 169.655 C 185.155 169.467 185.358 169.282 185.557 169.107 C 185.658 169.017 185.759 168.933 185.861 168.849 C 186.043 168.694 186.223 168.545 186.397 168.408 C 186.496 168.329 186.594 168.253 186.69 168.18 C 186.869 168.045 187.046 167.924 187.218 167.812 C 187.297 167.761 187.378 167.705 187.454 167.66 C 187.482 167.643 187.513 167.621 187.538 167.604 C 194.434 144.871 187.358 129.81 178.569 117.583 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 97.708 188.676 C 97.708 188.676 97.708 188.676 97.708 188.676 C 97.711 188.679 97.711 188.682 97.714 188.685 C 97.714 188.685 97.714 188.685 97.714 188.685 C 98.54 189.845 99.296 190.975 99.995 192.079 C 104.857 190.525 108.676 187.684 110.918 182.888 C 119.916 168.72 59.028 169.675 92.372 122.051 L 80.221 121.306 C 79.123 124.043 78.257 126.806 77.622 129.593 C 77.611 129.638 77.6 129.683 77.589 129.728 C 77.299 131.021 77.069 132.322 76.883 133.628 C 76.849 133.864 76.821 134.103 76.79 134.339 C 76.625 135.629 76.493 136.924 76.431 138.228 C 75.666 142.699 73.598 147.111 70.87 151.489 C 70.46 152.149 70.075 152.823 69.715 153.506 C 69.414 154.08 69.136 154.661 68.872 155.248 C 68.827 155.344 68.776 155.44 68.734 155.535 C 68.127 156.915 67.616 158.328 67.208 159.767 C 66.273 163.066 65.876 166.488 66.141 169.894 C 66.146 169.891 66.157 169.886 66.163 169.883 C 66.798 169.439 67.177 168.551 67.369 167.219 C 67.495 165.825 68.004 164.493 68.889 163.288 C 69.268 162.771 69.824 162.313 70.544 161.919 C 72.969 160.587 75.973 160.899 78.305 162.386 C 78.867 162.743 79.407 163.15 79.918 163.603 C 81.629 165.061 82.961 166.519 83.785 167.978 C 84.734 169.31 85.94 171.212 87.272 173.682 C 88.666 176.155 89.745 177.993 90.633 179.198 L 94.943 184.967 C 95.688 185.914 96.373 186.836 97.037 187.746 C 97.261 188.058 97.492 188.37 97.708 188.676 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 122.169 72.941 C 118.834 75.093 115.509 77.26 112.241 79.513 L 109.8 81.219 C 107.29 82.543 107.108 86.283 109.592 87.601 C 110.213 88.014 120.685 94.772 120.823 94.921 C 121.686 95.556 122.672 95.629 123.597 95.235 C 123.597 95.235 128.913 92.698 128.913 92.698 C 132.521 91.046 136.14 89.416 139.799 87.879 C 140.951 87.263 143.674 86.679 144.295 85.44 C 145.102 84.229 144.885 82.292 143.618 81.301 C 140.94 79.665 136.031 77.493 133.153 75.984 C 133.153 75.984 127.767 73.449 127.767 73.449 C 126.033 72.567 123.979 71.716 122.169 72.941 Z M 122.169 72.941 C 123.948 71.676 126.072 72.486 127.817 73.337 C 127.817 73.337 133.339 75.568 133.339 75.568 C 136.525 76.889 141.575 78.485 144.585 79.985 C 147.94 82.014 147.237 87.637 143.626 89.039 C 139.26 91.341 134.738 93.535 130.282 95.634 C 127.727 96.584 123.675 99.481 120.826 98.191 C 118.682 97.396 110.668 90.321 108.647 88.91 C 105.613 86.881 106.132 81.958 109.417 80.618 C 109.417 80.618 112 79.134 112 79.134 C 115.428 77.13 118.803 75.043 122.169 72.941 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 137.309 83.658 C 129.815 90.793 116.833 92.487 108.451 85.83 C 118.092 89.511 128.081 88.126 137.309 83.658 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 135.177 76.619 C 136.199 75.787 136.995 74.354 137.234 72.634 C 137.638 69.737 136.337 67.158 134.322 66.877 C 132.31 66.596 130.349 68.717 129.942 71.614 C 129.801 72.612 129.877 73.562 130.102 74.402 L 135.177 76.619 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 115.226 77.35 L 118.477 75.284 C 118.94 74.267 119.14 72.98 118.949 71.612 C 118.544 68.715 116.583 66.593 114.568 66.874 C 112.556 67.155 111.252 69.735 111.657 72.632 C 112.008 75.138 113.523 77.055 115.226 77.35 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 59.565 208.06 C 67.843 208.624 75.959 211.078 83.813 213.494 C 87.536 214.444 91.254 216.223 95.12 215.916 C 110.114 213.418 101.209 197.528 95.48 190.025 C 91.948 185.386 87.606 179.844 84.661 174.59 C 82.773 171.676 80.913 167.472 77.864 165.353 C 76.043 163.476 71.974 162.855 70.552 165.308 C 69.26 167.528 70.055 170.085 67.534 171.853 C 66.008 172.746 64.775 172.898 63.246 173.241 C 63.03 173.227 61.69 173.283 61.453 173.275 C 58.157 173.502 53.65 172.238 52.563 176.27 C 51.548 180.165 53.532 184.234 53.389 188.345 C 53.512 192.045 51.677 195.729 49.702 198.539 C 48.876 199.939 48.05 201.462 47.923 202.836 C 47.923 202.836 47.895 202.625 47.895 202.625 C 48.39 204.522 51 205.533 52.799 206.213 C 54.976 206.989 57.264 207.565 59.565 208.043 C 57.219 207.877 54.858 207.683 52.526 207.127 C 50.104 206.433 47.33 205.812 46.257 203.049 C 46.176 198.978 48.98 195.946 49.725 192.984 C 51.425 187.937 48.719 183.45 48.387 178.296 C 48.379 169.56 54.704 168.554 61.099 168.652 C 61.099 168.652 62.988 168.554 62.988 168.554 L 62.49 168.607 C 63.215 168.411 64.415 168.155 64.924 167.826 C 64.362 168.315 64.946 167.716 64.949 166.739 C 65.267 162.259 69.116 158.446 73.638 158.494 C 76.42 158.37 79.069 159.514 81.166 161.259 C 84.762 163.752 87.005 168.211 89.362 171.945 C 94.294 181.041 102.184 188.401 106.051 198.258 C 110.586 208.352 107.428 218.445 95.457 219.623 C 91.046 219.825 86.937 217.515 82.888 216.127 C 75.248 212.887 67.68 209.875 59.565 208.06 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
<path d="M 173.676 176.883 C 170.04 177.085 166.101 176.093 163.628 173.137 C 162.616 171.504 161.905 169.658 161.242 167.975 C 160.756 166.418 159.579 165.201 158.46 165.297 C 154.203 166.106 156.179 175.222 155.81 178.734 C 155.754 184.413 155.22 190.07 154.523 195.628 C 154.133 200.068 152.424 206.837 153.947 210.561 C 155.895 214.048 159.787 216.161 163.743 215.908 C 166.519 215.635 168.883 213.823 171.541 212.008 C 178.285 206.981 184.38 201.231 191.84 196.904 C 194.948 195.108 198.368 193.566 201.861 192.501 C 203.075 192.231 204.952 190.952 205.103 190.143 C 205.053 189.705 204.471 189.193 204.145 188.879 C 201.195 186.423 195.49 184.627 193.473 182.365 C 190.983 180.314 189.73 176.486 189.986 173.477 C 189.927 170.948 189.772 167.112 188.134 168.394 C 183.601 170.858 179.6 177.209 173.676 176.883 C 176.63 176.759 178.805 174.629 180.834 172.746 C 183.436 170.431 189.337 161.785 191.947 168.818 C 193.237 172.586 192.152 177.414 195.718 179.766 C 196.949 180.974 200.987 182.446 202.777 183.326 C 209.203 185.979 212.499 191.309 205.674 195.839 C 201.861 197.685 197.972 198.767 194.414 201.184 C 187.519 205.522 181.325 211.283 174.736 216.332 C 171.822 218.443 168.059 221.036 163.892 221.219 C 157.221 221.539 149.907 217.024 148.541 210.28 C 147.889 205.092 149.28 199.972 149.974 194.959 C 150.904 189.491 151.747 184.065 152.079 178.594 C 152.748 173.682 151.132 163.049 158.578 162.858 C 163.322 163.372 162.987 169.245 164.743 172.446 C 166.393 174.955 170.386 176.7 173.676 176.883 Z" style="stroke: none; stroke-width: 2.81; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill-rule: nonzero; opacity: 1; fill: rgb(255, 255, 255);" stroke-linecap="round"/>
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 879 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="200px" height="200px" viewBox="0 0 200 200">
|
||||||
|
<path d="M 176.226 68.183 C 175.066 69.083 154.581 80.625 154.581 106.291 C 154.581 135.977 180.646 146.48 181.427 146.739 C 181.307 147.38 177.286 161.122 167.684 175.125 C 159.122 187.448 150.18 199.75 136.577 199.75 C 122.974 199.75 119.474 191.849 103.771 191.849 C 88.467 191.849 83.027 200.011 70.583 200.011 C 58.141 200.011 49.46 188.608 39.478 174.605 C 27.915 158.162 18.573 132.617 18.573 108.372 C 18.573 69.484 43.858 48.859 68.744 48.859 C 81.966 48.859 92.988 57.541 101.29 57.541 C 109.191 57.541 121.514 48.339 136.557 48.339 C 142.259 48.339 162.743 48.859 176.226 68.183 Z M 129.416 31.876 C 135.637 24.494 140.038 14.252 140.038 4.01 C 140.038 2.59 139.918 1.149 139.658 -0.011 C 129.536 0.369 117.493 6.73 110.232 15.152 C 104.531 21.634 99.209 31.876 99.209 42.257 C 99.209 43.818 99.47 45.379 99.59 45.879 C 100.23 45.998 101.27 46.139 102.31 46.139 C 111.393 46.139 122.815 40.057 129.416 31.876 Z" style="fill: rgb(255, 255, 255);" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 42 42">
|
||||||
|
<path fill="#FFFFFF" fill-rule="evenodd" d="M21.47 41.88c-4.11 0-6.02-.6-9.34-3-2.1 2.7-8.75 4.81-9.04 1.2 0-2.71-.6-5-1.28-7.5C1 29.5.08 26.07.08 21.1.08 9.23 9.82.3 21.36.3c11.55 0 20.6 9.37 20.6 20.91a20.6 20.6 0 0 1-20.49 20.67Zm.17-31.32c-5.62-.29-10 3.6-10.97 9.7-.8 5.05.62 11.2 1.83 11.52.58.14 2.04-1.04 2.95-1.95a10.4 10.4 0 0 0 5.08 1.81 10.7 10.7 0 0 0 11.19-9.97 10.7 10.7 0 0 0-10.08-11.1Z" clip-rule="evenodd"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 497 B |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 231 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 200 200" width="200px" height="200px">
|
||||||
|
<path fill-rule="evenodd" clip-rule="evenodd" d="M 13.693 88.614 C 67.369 65.304 103.102 49.815 121.044 42.299 C 172.114 20.984 182.849 17.303 189.751 17.149 C 191.284 17.149 194.658 17.456 196.958 19.295 C 198.799 20.829 199.259 22.823 199.565 24.356 C 199.873 25.889 200.18 29.111 199.873 31.565 C 197.112 60.703 185.15 131.402 179.016 163.916 C 176.408 177.717 171.347 182.317 166.441 182.778 C 155.705 183.699 147.577 175.724 137.302 168.976 C 121.044 158.395 111.997 151.8 96.202 141.371 C 77.951 129.408 89.76 122.815 100.188 112.08 C 102.948 109.319 150.031 66.378 150.95 62.545 C 151.104 62.084 151.104 60.243 150.031 59.323 C 148.956 58.403 147.423 58.71 146.197 59.016 C 144.509 59.323 118.745 76.5 68.596 110.391 C 61.235 115.453 54.64 117.906 48.659 117.754 C 42.065 117.6 29.49 114.072 19.981 111.005 C 8.479 107.325 -0.723 105.331 0.045 98.89 C 0.503 95.516 5.105 92.142 13.693 88.614 Z" style="fill: rgb(255, 255, 255);"></path>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,6 @@
|
|||||||
|
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect x="0" y="0" width="95" height="95" fill="#ffffff"/>
|
||||||
|
<rect x="105" y="0" width="95" height="95" fill="#ffffff"/>
|
||||||
|
<rect x="0" y="105" width="95" height="95" fill="#ffffff"/>
|
||||||
|
<rect x="105" y="105" width="95" height="95" fill="#ffffff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 318 B |
@@ -1,28 +1,29 @@
|
|||||||
import { MaterialButton } from "@/utils/material";
|
import { MaterialIcon } from "@/utils/material";
|
||||||
import styles from "./download-app.module.scss";
|
import styles from "./download-app.module.scss";
|
||||||
|
|
||||||
export default function DownloadAppPage() {
|
export default function DownloadAppPage() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.downloadAppScreen}>
|
<div className={styles.downloadAppScreen}>
|
||||||
<div>
|
<div className={styles.downloadAppCard}>
|
||||||
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1>
|
<h1>Скачайте приложение</h1>
|
||||||
<p>
|
<p>
|
||||||
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому
|
Этот сайт не предназначен для работы на маленьких экранах.
|
||||||
вам нужно скачать приложение мессенджера.
|
Выберите вашу платформу:
|
||||||
</p>
|
</p>
|
||||||
|
<div className={styles.downloadAppButtons}>
|
||||||
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
|
<a href="/download?os=android" className={styles.downloadAppBtn}>
|
||||||
<MaterialButton>Скачать на GitHub</MaterialButton>
|
<MaterialIcon name="android" />
|
||||||
</a>
|
Android
|
||||||
|
</a>
|
||||||
|
<a href="/download?os=ios" className={styles.downloadAppBtn}>
|
||||||
|
<MaterialIcon name="phone_iphone" />
|
||||||
|
iOS
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<p>
|
<p>
|
||||||
Если возникнут сложности или есть вопросы, нажмите кнопку!
|
<a href="https://t.me/denis0001-dev">Написать в поддержку</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<a href="https://t.me/denis0001-dev">
|
|
||||||
<MaterialButton>Написать в поддержку</MaterialButton>
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,70 @@
|
|||||||
|
@use "../../css/material" as *;
|
||||||
|
|
||||||
.downloadAppScreen {
|
.downloadAppScreen {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 100vw;
|
min-width: 100vw;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 32px;
|
padding: 24px;
|
||||||
|
background-color: $color-dark-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadAppCard {
|
||||||
|
max-width: 400px;
|
||||||
|
padding: 40px 32px;
|
||||||
|
background: rgba($color-dark-surface-container, 0.6);
|
||||||
|
border: 1px solid rgba($color-dark-outline, 0.3);
|
||||||
|
border-radius: 24px;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0 0 16px;
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
margin: 0 0 24px;
|
||||||
|
font-size: 16px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: $color-dark-primary;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadAppButtons {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
justify-content: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadAppBtn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: rgba($color-dark-primary, 0.2);
|
||||||
|
border: 1px solid rgba($color-dark-primary, 0.5);
|
||||||
|
border-radius: 12px;
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($color-dark-primary, 0.3);
|
||||||
|
border-color: $color-dark-primary;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { StyledDialog } from "@/core/components/StyledDialog";
|
||||||
|
import { MaterialButton, MaterialIcon } from "@/utils/material";
|
||||||
|
import { OS_CONFIG, type DownloadOs } from "@/pages/home/os";
|
||||||
|
import styles from "@/pages/home/download-dialog.module.scss";
|
||||||
|
|
||||||
|
interface DownloadDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
os: DownloadOs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AndroidInstructions(): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>Установка на Android</h3>
|
||||||
|
<p className={styles.text}>
|
||||||
|
Вы скачали APK-файл FromChat. Чтобы установить приложение:
|
||||||
|
</p>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
<li>Откройте загруженный APK-файл из шторки уведомлений или файлового менеджера.</li>
|
||||||
|
<li>
|
||||||
|
Если появится запрос "Разрешить установку из неизвестных источников" — дайте
|
||||||
|
разрешение для браузера, из которого вы скачивали APK.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
Google Play Protect может предупредить о неизвестном приложении. Если вы доверяете FromChat,
|
||||||
|
нажмите "Подробнее" → "Всё равно установить" (или аналогичную кнопку).
|
||||||
|
</li>
|
||||||
|
<li>Дождитесь завершения установки и откройте FromChat из списка приложений.</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function IosInstructions(): ReactNode {
|
||||||
|
return (
|
||||||
|
<div className={styles.section}>
|
||||||
|
<h3 className={styles.sectionTitle}>Установка на iOS</h3>
|
||||||
|
<p className={styles.text}>
|
||||||
|
Эта сборка не распространяется через App Store или TestFlight. Чтобы установить FromChat на iPhone
|
||||||
|
или iPad, потребуется один из вариантов сторонней установки:
|
||||||
|
</p>
|
||||||
|
<ul className={styles.list}>
|
||||||
|
<li>
|
||||||
|
<strong>TrollStore</strong>: постоянная установка приложений из IPA-файлов. Требуется поддерживаемая
|
||||||
|
версия iOS и настройка TrollStore на устройстве.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Джейлбрейк</strong>: установка через менеджер пакетов (Sileo, Cydia и т.п.) или напрямую
|
||||||
|
из файлового менеджера, если у вас уже есть джейлбрейк.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>Другие сервисы сайдлоада</strong>: сторонние инструменты, которые подписывают IPA-файл
|
||||||
|
вашим сертификатом разработчика или временным сертификатом.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<p className={styles.text}>
|
||||||
|
К сожалению, простого и официально поддерживаемого пути установки для iOS здесь нет — именно поэтому я
|
||||||
|
бы сам iPhone не покупал 😄
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderInstructions(os: DownloadOs): ReactNode {
|
||||||
|
if (os === "android") {
|
||||||
|
return <AndroidInstructions />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (os === "ios") {
|
||||||
|
return <IosInstructions />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DownloadDialog({ open, onOpenChange, os }: DownloadDialogProps) {
|
||||||
|
const osInfo = OS_CONFIG[os];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledDialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
className={styles.downloadDialog}
|
||||||
|
contentClassName={styles.downloadDialogContent}
|
||||||
|
afterChildren={
|
||||||
|
<div className={styles.actions}>
|
||||||
|
<MaterialButton variant="filled" onClick={() => onOpenChange(false)}>
|
||||||
|
Закрыть
|
||||||
|
</MaterialButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className={styles.body}>
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.iconWrapper}>
|
||||||
|
<MaterialIcon name="download" className={styles.icon} />
|
||||||
|
</div>
|
||||||
|
<div className={styles.titleBlock}>
|
||||||
|
<h2 className={styles.title}>Спасибо за скачивание!</h2>
|
||||||
|
<p className={styles.subtitle}>
|
||||||
|
FromChat для
|
||||||
|
<span className={styles.osName}>{osInfo.label}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderInstructions(os)}
|
||||||
|
</div>
|
||||||
|
</StyledDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { MaterialIcon } from "@/utils/material";
|
||||||
|
import { GitHubLink, GITHUB_WEB, GITHUB_APP, GITHUB_LICENSE } from "@/pages/home/homeLinks";
|
||||||
|
import styles from "@/pages/home/home-footer.module.scss";
|
||||||
|
|
||||||
|
interface HomeFooterProps {
|
||||||
|
onScrollToDownload?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeFooter({ onScrollToDownload }: HomeFooterProps) {
|
||||||
|
return (
|
||||||
|
<footer className={styles.homepageFooter}>
|
||||||
|
<div className={styles.footerBrand}>
|
||||||
|
<div className={styles.footerLogoRow}>
|
||||||
|
<div className={styles.footerLogo} />
|
||||||
|
<span className={styles.footerBrandName}>FromChat</span>
|
||||||
|
</div>
|
||||||
|
<p className={styles.footerCopyright}>FromChat © 2026</p>
|
||||||
|
</div>
|
||||||
|
<div className={styles.footerLinks}>
|
||||||
|
<div className={styles.footerSection}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onScrollToDownload}
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<MaterialIcon name="download" className={styles.footerLinkIcon} />
|
||||||
|
Скачать приложение
|
||||||
|
</button>
|
||||||
|
<Link to="/login" className={styles.footerLink}>
|
||||||
|
<MaterialIcon name="language" className={styles.footerLinkIcon} />
|
||||||
|
Веб-версия
|
||||||
|
</Link>
|
||||||
|
<a
|
||||||
|
href={`${GITHUB_WEB}/actions/workflows/build.yml`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<MaterialIcon name="computer" className={styles.footerLinkIcon} />
|
||||||
|
ПК-клиент
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className={styles.footerSection}>
|
||||||
|
<a
|
||||||
|
href={`${GITHUB_APP}/tree/main`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<MaterialIcon name="android" className={styles.footerLinkIcon} />
|
||||||
|
Исходный код приложения
|
||||||
|
</a>
|
||||||
|
<GitHubLink className={styles.footerLink}>
|
||||||
|
<MaterialIcon name="code" className={styles.footerLinkIcon} />
|
||||||
|
Исходный код веб-версии
|
||||||
|
</GitHubLink>
|
||||||
|
<a
|
||||||
|
href={GITHUB_LICENSE}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<MaterialIcon name="description" className={styles.footerLinkIcon} />
|
||||||
|
Лицензия
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div className={styles.footerSection}>
|
||||||
|
<a
|
||||||
|
href="https://t.me/fromchat_ch"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${styles.footerLinkIcon} ${styles.footerLinkIconSvg} ${styles.footerLinkIconSvgTelegram}`}
|
||||||
|
/>
|
||||||
|
Telegram
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://max.ru/join/c5t6LfnCCPetQSAOshmouEvq9vsjHZT_Lt63kw8YCg0"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={styles.footerLink}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`${styles.footerLinkIcon} ${styles.footerLinkIconSvg} ${styles.footerLinkIconSvgMax}`}
|
||||||
|
/>
|
||||||
|
MAX
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useUserStore } from "@/state/user";
|
||||||
|
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||||
|
import { MaterialButton, MaterialIconButton } from "@/utils/material";
|
||||||
|
import { GitHubLink, SupportLink } from "@/pages/home/homeLinks";
|
||||||
|
import styles from "@/pages/home/home-header.module.scss";
|
||||||
|
|
||||||
|
interface HomeHeaderProps {
|
||||||
|
onScrollToDownload?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HomeHeader({ onScrollToDownload }: HomeHeaderProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { user } = useUserStore();
|
||||||
|
const { isMobile } = useDownloadAppScreen();
|
||||||
|
const isLoggedIn = user.authToken && user.currentUser;
|
||||||
|
|
||||||
|
const handleMobileDownload = () => {
|
||||||
|
onScrollToDownload?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
function handleGetStarted() {
|
||||||
|
if (isMobile) {
|
||||||
|
handleMobileDownload();
|
||||||
|
} else if (isLoggedIn) {
|
||||||
|
navigate("/chat");
|
||||||
|
} else {
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const openBtn = (
|
||||||
|
<MaterialButton
|
||||||
|
variant="filled"
|
||||||
|
onClick={handleGetStarted}
|
||||||
|
icon={
|
||||||
|
isMobile ? "download" : isLoggedIn ? "open_in_new" : "login"
|
||||||
|
}
|
||||||
|
className={styles.headerDownloadButton}
|
||||||
|
>
|
||||||
|
{isMobile ? "Скачать" : isLoggedIn ? "Открыть" : "Войти"}
|
||||||
|
</MaterialButton>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className={styles.homepageHeader} data-home-header>
|
||||||
|
<div className={styles.headerInner}>
|
||||||
|
<div className={styles.headerContent}>
|
||||||
|
<div className={styles.logo}>
|
||||||
|
<div className={styles.logoIcon} />
|
||||||
|
<h1>FromChat</h1>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerCenterLinks}>
|
||||||
|
<GitHubLink>
|
||||||
|
<MaterialButton variant="text" icon="code">GitHub</MaterialButton>
|
||||||
|
</GitHubLink>
|
||||||
|
<SupportLink>
|
||||||
|
<MaterialButton variant="text" icon="support">Поддержка</MaterialButton>
|
||||||
|
</SupportLink>
|
||||||
|
</div>
|
||||||
|
<div className={styles.headerButton}>
|
||||||
|
{openBtn}
|
||||||
|
{isMobile ? (
|
||||||
|
<MaterialIconButton
|
||||||
|
variant="filled"
|
||||||
|
onClick={handleMobileDownload}
|
||||||
|
icon="download"
|
||||||
|
className={styles.headerSmallButton}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,228 +1,236 @@
|
|||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useUserStore } from "@/state/user";
|
import { useRef, useState, type ReactNode } from "react";
|
||||||
import styles from "./home.module.scss";
|
import styles from "@/pages/home/home.module.scss";
|
||||||
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
|
||||||
import { MaterialButton, MaterialIcon } from "@/utils/material";
|
import { MaterialButton, MaterialIcon, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material";
|
||||||
|
import generalChatScreenshot from "@/images/screenshots/general-chat.png";
|
||||||
|
import dmScreenshot from "@/images/screenshots/dm.png";
|
||||||
|
import windowsIcon from "@/images/windows.svg";
|
||||||
|
import linuxIcon from "@/images/linux.svg";
|
||||||
|
import macIcon from "@/images/mac.svg";
|
||||||
|
import { HomeHeader } from "@/pages/home/HomeHeader";
|
||||||
|
import { HomeFooter } from "@/pages/home/HomeFooter";
|
||||||
|
import { SplitButton } from "@/core/components/SplitButton";
|
||||||
|
import { DownloadDialog } from "@/pages/home/DownloadDialog";
|
||||||
|
import { OS_CONFIG, ALL_OS, detectOs, type DownloadOs } from "@/pages/home/os";
|
||||||
|
|
||||||
function GitHubLink({ children }: { children: React.ReactNode }) {
|
interface FeatureSectionProps {
|
||||||
return (
|
title: ReactNode;
|
||||||
<a href="https://github.com/denis0001-dev/FromChat" target="_blank">{children}</a>
|
children: ReactNode;
|
||||||
);
|
screenshot: string;
|
||||||
|
right?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SupportLink({ children }: { children: React.ReactNode }) {
|
function FeatureSection({
|
||||||
return (
|
title,
|
||||||
<a href="https://t.me/denis0001-dev" target="_blank">{children}</a>
|
children,
|
||||||
|
screenshot,
|
||||||
|
right = false,
|
||||||
|
}: FeatureSectionProps) {
|
||||||
|
const featureText = (
|
||||||
|
<div className={styles.featureText}>
|
||||||
|
<div className={styles.featureTitle}>{title}</div>
|
||||||
|
<div className={styles.featureDesc}>{children}</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const featureScreenshot = (
|
||||||
|
<div className={styles.featureScreenshotOuter}>
|
||||||
|
<div className={styles.featureScreenshotGlow} />
|
||||||
|
<img src={screenshot} className={styles.featureScreenshot} draggable={false} />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${styles.featureContainer}`}>
|
||||||
|
{right ? <>{featureText}{featureScreenshot}</> : <>{featureScreenshot}{featureText}</>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { user } = useUserStore();
|
|
||||||
const { isMobile } = useDownloadAppScreen();
|
const { isMobile } = useDownloadAppScreen();
|
||||||
const isLoggedIn = user.authToken && user.currentUser;
|
|
||||||
|
|
||||||
function handleGetStarted() {
|
const [dialogOpen, setDialogOpen] = useState(false);
|
||||||
if (isMobile) {
|
const [dialogOs, setDialogOs] = useState<DownloadOs>(() => detectOs());
|
||||||
navigate("/download-app");
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
} else if (isLoggedIn) {
|
const downloadSectionRef = useRef<HTMLElement>(null);
|
||||||
navigate("/chat");
|
|
||||||
} else {
|
const scrollToDownload = () => {
|
||||||
navigate("/login");
|
const section = downloadSectionRef.current;
|
||||||
|
const header = document.querySelector<HTMLElement>("[data-home-header]");
|
||||||
|
if (!section) return;
|
||||||
|
const headerHeight = header?.getBoundingClientRect().height ?? 0;
|
||||||
|
const targetY = section.getBoundingClientRect().top + window.scrollY - headerHeight;
|
||||||
|
window.scrollTo({ top: targetY, behavior: "smooth" });
|
||||||
|
};
|
||||||
|
|
||||||
|
const triggerDownload = (os: DownloadOs): boolean => {
|
||||||
|
if (typeof document === "undefined") {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const openBtn = (
|
setDialogOs(os);
|
||||||
<MaterialButton variant="filled" onClick={handleGetStarted}>
|
setDialogOpen(true);
|
||||||
{isMobile ? "Скачать приложение" : isLoggedIn ? "Перейти в чат" : "Войти"}
|
|
||||||
</MaterialButton>
|
const link = document.createElement("a");
|
||||||
);
|
link.href = `/api/download/${os}`;
|
||||||
|
link.download = "";
|
||||||
|
link.style.display = "none";
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getButtonVariant = (os: DownloadOs): "filled" | "tonal" | "outlined" => {
|
||||||
|
const detectedOs = detectOs();
|
||||||
|
if (os === detectedOs) return "filled";
|
||||||
|
if (!isMobile && ["windows", "linux", "macos"].includes(os)) return "tonal";
|
||||||
|
if (isMobile && (os === "android" || os === "ios") && os !== detectedOs) return "tonal";
|
||||||
|
return "outlined";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.homepage}>
|
<div className={styles.homepage}>
|
||||||
<header className={styles.homepageHeader}>
|
<HomeHeader onScrollToDownload={scrollToDownload} />
|
||||||
<div className={styles.container}>
|
|
||||||
<div className={styles.headerContent}>
|
|
||||||
<div className={styles.logo}>
|
|
||||||
<h1>FromChat</h1>
|
|
||||||
<span className={styles.tagline}>100% открытый мессенджер</span>
|
|
||||||
</div>
|
|
||||||
<nav className={styles.headerNav}>
|
|
||||||
<GitHubLink>
|
|
||||||
<MaterialButton variant="text">GitHub</MaterialButton>
|
|
||||||
</GitHubLink>
|
|
||||||
<SupportLink>
|
|
||||||
<MaterialButton variant="text">Поддержка</MaterialButton>
|
|
||||||
</SupportLink>
|
|
||||||
|
|
||||||
{openBtn}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<main>
|
<main>
|
||||||
<section className={styles.hero}>
|
<section className={styles.title}>
|
||||||
<div className={styles.container}>
|
<div className={styles.titleLogoWrapper}>
|
||||||
<div className={styles.heroContent}>
|
<div className={styles.titleLogo} />
|
||||||
<h2 className={styles.heroTitle}>
|
</div>
|
||||||
Безопасный мессенджер с открытым исходным кодом
|
<div className={styles.titleContent}>FromChat</div>
|
||||||
</h2>
|
<div className={styles.titleDesc}>
|
||||||
<p className={styles.heroDescription}>
|
100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.
|
||||||
FromChat — это полностью открытый мессенджер с end-to-end шифрованием,
|
</div>
|
||||||
поддержкой файлов и уведомлений. Создан для тех, кто ценит приватность и свободу.
|
<div className={styles.titleButtons}>
|
||||||
</p>
|
{isMobile ? null : (
|
||||||
<div className={styles.heroActions}>
|
<MaterialButton
|
||||||
{openBtn}
|
variant="filled"
|
||||||
{!isMobile && (
|
onClick={() => navigate("/auth?mode=login")}
|
||||||
<MaterialButton
|
icon="devices"
|
||||||
variant="outlined"
|
>
|
||||||
onClick={() => navigate("/register")}>
|
Открыть веб-версию
|
||||||
Зарегистрироваться
|
</MaterialButton>
|
||||||
</MaterialButton>
|
)}
|
||||||
)}
|
<SplitButton
|
||||||
</div>
|
variant={isMobile ? "filled" : "tonal"}
|
||||||
</div>
|
text="Скачать приложение"
|
||||||
<div className={styles.heroVisual}>
|
icon="download"
|
||||||
<div className={styles.chatPreview}>
|
onPrimaryClick={() => triggerDownload(detectOs())}
|
||||||
<div className={styles.chatWindow}>
|
menuOpen={menuOpen}
|
||||||
<div className={styles.chatHeader}>
|
onMenuOpen={setMenuOpen}
|
||||||
<div className={styles.chatTitle}>Общий чат</div>
|
menu={(
|
||||||
<div className={styles.onlineIndicator}>●</div>
|
<MaterialList>
|
||||||
</div>
|
{ALL_OS.map((os) => (
|
||||||
<div className={styles.chatMessages}>
|
<MaterialListItem
|
||||||
<div className={`${styles.message} ${styles.received}`}>
|
key={os}
|
||||||
<div className={styles.messageAvatar}>А</div>
|
icon={["windows", "linux", "macos"].includes(os) ? undefined : OS_CONFIG[os].icon}
|
||||||
<div className={styles.messageContent}>
|
headline={OS_CONFIG[os].label}
|
||||||
<div className={styles.messageText}>Привет! Как дела?</div>
|
rounded
|
||||||
<div className={styles.messageTime}>14:30</div>
|
onClick={() => {
|
||||||
</div>
|
if (triggerDownload(os)) setMenuOpen(false);
|
||||||
</div>
|
}}
|
||||||
<div className={`${styles.message} ${styles.sent}`}>
|
>
|
||||||
<div className={styles.messageContent}>
|
{["windows", "linux", "macos"].includes(os) && (
|
||||||
<div className={styles.messageText}>Всё отлично! А у тебя как?</div>
|
<span
|
||||||
<div className={styles.messageTime}>14:32</div>
|
slot="icon"
|
||||||
</div>
|
className={styles.menuCustomIcon}
|
||||||
</div>
|
style={{
|
||||||
<div className={`${styles.message} ${styles.received}`}>
|
"--menu-custom-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
|
||||||
<div className={styles.messageAvatar}>Б</div>
|
} as React.CSSProperties}
|
||||||
<div className={styles.messageContent}>
|
/>
|
||||||
<div className={styles.messageText}>Отправляю файл 📎</div>
|
)}
|
||||||
<div className={styles.messageTime}>14:35</div>
|
</MaterialListItem>
|
||||||
</div>
|
))}
|
||||||
</div>
|
</MaterialList>
|
||||||
</div>
|
)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.features}>
|
<section className={styles.features}>
|
||||||
<div className={styles.container}>
|
<FeatureSection
|
||||||
<h3 className={styles.sectionTitle}>Возможности</h3>
|
title={<>Общий чат</>}
|
||||||
<div className={styles.featuresGrid}>
|
screenshot={generalChatScreenshot}
|
||||||
<div className={styles.featureCard}>
|
right
|
||||||
<div className={styles.featureIcon}>
|
>
|
||||||
<MaterialIcon name="security" />
|
Открытый форум для всех пользователей сервера. Пишите сообщения, делитесь файлами и общайтесь в реальном времени.
|
||||||
</div>
|
</FeatureSection>
|
||||||
<h4>End-to-End Шифрование</h4>
|
<FeatureSection
|
||||||
<p>
|
title={<>Личные сообщения</>}
|
||||||
Ваши личные сообщения защищены современным шифрованием X25519 + AES-GCM.
|
screenshot={dmScreenshot}>
|
||||||
Только вы и получатель можете прочитать сообщения.
|
Общайтесь с одним человеком в личной переписке.
|
||||||
</p>
|
</FeatureSection>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="code" />
|
|
||||||
</div>
|
|
||||||
<h4>100% открытый код</h4>
|
|
||||||
<p>
|
|
||||||
Весь исходный код доступен на <GitHubLink>GitHub</GitHubLink>. Вы можете проверить безопасность,
|
|
||||||
внести изменения или развернуть свой сервер.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="attach_file" />
|
|
||||||
</div>
|
|
||||||
<h4>Обмен Файлами</h4>
|
|
||||||
<p>
|
|
||||||
Отправляйте файлы до 4 ГБ. Файлы в личных сообщениях шифруются.
|
|
||||||
В общем чате шифрования нет, так как ваши сообщения могут читать все пользователи FromChat.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="notifications" />
|
|
||||||
</div>
|
|
||||||
<h4>Уведомления</h4>
|
|
||||||
<p>
|
|
||||||
Получайте push-уведомления в браузере и настольном приложении.
|
|
||||||
Никогда не пропустите важное сообщение.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="edit" />
|
|
||||||
</div>
|
|
||||||
<h4>Редактирование</h4>
|
|
||||||
<p>
|
|
||||||
Редактируйте и удаляйте свои сообщения. Отвечайте на сообщения
|
|
||||||
для лучшего контекста общения.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.featureCard}>
|
|
||||||
<div className={styles.featureIcon}>
|
|
||||||
<MaterialIcon name="computer" />
|
|
||||||
</div>
|
|
||||||
<h4>Кроссплатформенность</h4>
|
|
||||||
<p>
|
|
||||||
Работает в браузере и как настольное приложение для Windows,
|
|
||||||
macOS и Linux. Единый интерфейс везде.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className={styles.download}>
|
<section ref={downloadSectionRef} className={styles.download}>
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.downloadContent}>
|
<div className={styles.downloadContent}>
|
||||||
<h3>Скачайте приложение</h3>
|
<h3>Скачайте приложение</h3>
|
||||||
<p>
|
<p>
|
||||||
Для лучшего опыта используйте настольное приложение с поддержкой
|
Настольное приложение с уведомлениями и автономной работой
|
||||||
уведомлений и автономной работы.
|
или мобильное приложение для Android и iOS.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.downloadButtons}>
|
<table className={styles.downloadTable}>
|
||||||
{!isMobile ? (
|
<thead>
|
||||||
<>
|
<tr>
|
||||||
<a
|
<th>Платформа</th>
|
||||||
href="https://github.com/Toolbox-io/FromChat/actions/workflows/build.yml"
|
<th>Описание</th>
|
||||||
target="_blank"
|
<th />
|
||||||
rel="noopener noreferrer"
|
</tr>
|
||||||
>
|
</thead>
|
||||||
<MaterialButton variant="filled">
|
<tbody>
|
||||||
<MaterialIcon name="download" slot="icon" />
|
{ALL_OS.map((os) => (
|
||||||
Скачать для ПК
|
<tr key={os}>
|
||||||
</MaterialButton>
|
<td className={styles.downloadTableOs}>
|
||||||
</a>
|
<span className={styles.downloadTableOsContent}>
|
||||||
<MaterialButton variant="outlined" onClick={() => navigate("/login")}>
|
{["windows", "linux", "macos"].includes(os) ? (
|
||||||
<MaterialIcon name="language" slot="icon" />
|
<span
|
||||||
Веб-версия
|
className={styles.tableOsIcon}
|
||||||
</MaterialButton>
|
style={{
|
||||||
</>
|
"--table-os-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
|
||||||
) : (
|
} as React.CSSProperties}
|
||||||
<MaterialButton variant="filled" onClick={() => navigate("/download-app")}>
|
/>
|
||||||
Скачать приложение
|
) : (
|
||||||
</MaterialButton>
|
<MaterialIcon
|
||||||
)}
|
name={OS_CONFIG[os].icon}
|
||||||
</div>
|
className={styles.downloadTableIcon}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{OS_CONFIG[os].label}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={styles.downloadTableDesc}>
|
||||||
|
<span className={styles.downloadTableDescInner}>
|
||||||
|
{OS_CONFIG[os].description}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={styles.downloadTableAction}>
|
||||||
|
<span className={styles.downloadTableActionInner}>
|
||||||
|
<MaterialButton
|
||||||
|
variant={getButtonVariant(os)}
|
||||||
|
icon="download"
|
||||||
|
className={styles.downloadButton}
|
||||||
|
onClick={() => triggerDownload(os)}
|
||||||
|
>
|
||||||
|
Скачать
|
||||||
|
</MaterialButton>
|
||||||
|
<MaterialIconButton
|
||||||
|
variant={getButtonVariant(os)}
|
||||||
|
icon="download"
|
||||||
|
className={styles.downloadButtonIcon}
|
||||||
|
onClick={() => triggerDownload(os)}
|
||||||
|
title={`Скачать ${OS_CONFIG[os].label}`}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -232,7 +240,8 @@ export default function HomePage() {
|
|||||||
<div className={styles.ctaContent}>
|
<div className={styles.ctaContent}>
|
||||||
<h3>Готовы начать общение?</h3>
|
<h3>Готовы начать общение?</h3>
|
||||||
<p>
|
<p>
|
||||||
Присоединяйтесь к FromChat и общайтесь безопасно с друзьями и коллегами.
|
Создайте аккаунт за минуту. Общайтесь в общем чате, ведите личную переписку
|
||||||
|
или звоните — всё бесплатно и с открытым кодом.
|
||||||
</p>
|
</p>
|
||||||
<div className={styles.ctaActions}>
|
<div className={styles.ctaActions}>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
@@ -259,24 +268,8 @@ export default function HomePage() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className={styles.homepageFooter}>
|
<DownloadDialog open={dialogOpen} onOpenChange={setDialogOpen} os={dialogOs} />
|
||||||
<div className={styles.container}>
|
<HomeFooter onScrollToDownload={scrollToDownload} />
|
||||||
<div className={styles.footerContent}>
|
|
||||||
<div className={styles.footerSection}>
|
|
||||||
<h4>Ссылки</h4>
|
|
||||||
<GitHubLink>GitHub</GitHubLink>
|
|
||||||
<SupportLink>Поддержка</SupportLink>
|
|
||||||
</div>
|
|
||||||
<div className={styles.footerSection}>
|
|
||||||
<h4>Лицензия</h4>
|
|
||||||
<p>GPL-3.0</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className={styles.footerBottom}>
|
|
||||||
<p>© 2025 FromChat. Сделано программистом denis0001-dev с ❤️ для свободы общения.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</footer>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@use "@/css/material" as *;
|
||||||
|
|
||||||
|
$gradient-rainbow: linear-gradient(45deg, #9333EA, #6366F1, #3B82F6, #A855F7, #D946EF, #EC4899, #7E22CE);
|
||||||
|
$glow-purple: rgba(147, 51, 234, 0.5);
|
||||||
|
|
||||||
|
@mixin gradient-text {
|
||||||
|
background: $gradient-rainbow;
|
||||||
|
background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
text-shadow: 0 0 20px $glow-purple;
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
@use "@/css/material" as *;
|
||||||
|
@use "@/css/colors" as *;
|
||||||
|
@use "sass:color";
|
||||||
|
|
||||||
|
.downloadDialog {
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
|
||||||
|
.downloadDialogContent {
|
||||||
|
padding: 24px 24px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.iconWrapper {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background-color: $color-dark-primary-container;
|
||||||
|
color: $color-dark-on-primary-container;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titleBlock {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
.osName {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sectionTitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list {
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 20px;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
|
||||||
|
li + li {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px 20px;
|
||||||
|
border-top: 1px solid rgba($color-dark-outline-variant, 0.4);
|
||||||
|
background-color: color.mix($color-dark-surface-container, $color-dark-surface-container-low, 60%);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.downloadDialogContent {
|
||||||
|
padding: 20px 16px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
padding-inline: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
@use "@/css/material" as *;
|
||||||
|
@use "home-shared" as shared;
|
||||||
|
|
||||||
|
.homepageFooter {
|
||||||
|
padding: 0 16px;
|
||||||
|
background: $color-dark-surface;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 32px;
|
||||||
|
max-width: 1000px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding-bottom: 48px;
|
||||||
|
|
||||||
|
@media (max-width: 635px) {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerBrand {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLogoRow {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLogo {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background-image: url('@/images/logo_square.svg');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerBrandName {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
@include shared.gradient-text;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerCopyright {
|
||||||
|
font-size: 14px;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
opacity: 0.8;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinks {
|
||||||
|
display: flex;
|
||||||
|
gap: 32px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerSection {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLink {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: $color-dark-on-surface-variant;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 15px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
|
transition: color 0.2s ease, transform 0.15s ease;
|
||||||
|
-webkit-user-drag: none;
|
||||||
|
user-select: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $color-dark-on-surface;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
button.footerLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.footerLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.footerLink {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:is(button) {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
transform: scale(0.92);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinkIcon {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
min-width: 24px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinkIconSvg {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: currentColor;
|
||||||
|
mask-size: contain;
|
||||||
|
mask-repeat: no-repeat;
|
||||||
|
mask-position: center;
|
||||||
|
-webkit-mask-size: contain;
|
||||||
|
-webkit-mask-repeat: no-repeat;
|
||||||
|
-webkit-mask-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinkIconSvgTelegram {
|
||||||
|
mask-image: url('@/images/telegram.svg');
|
||||||
|
-webkit-mask-image: url('@/images/telegram.svg');
|
||||||
|
}
|
||||||
|
|
||||||
|
.footerLinkIconSvgMax {
|
||||||
|
mask-image: url('@/images/max.svg');
|
||||||
|
-webkit-mask-image: url('@/images/max.svg');
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
@use "@/css/material" as *;
|
||||||
|
@use "home-shared" as shared;
|
||||||
|
|
||||||
|
.homepageHeader {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 16px;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1000;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerInner {
|
||||||
|
max-width: 960px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 16px 24px;
|
||||||
|
background: rgba($color-dark-surface, 0.8);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-radius: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerContent {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logoIcon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
background-image: url('@/images/logo_square.svg');
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo h1 {
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 0;
|
||||||
|
@include shared.gradient-text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerCenterLinks {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
|
||||||
|
@media (max-width: 730px) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerButton {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerDownloadButton {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerSmallButton {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 450px) {
|
||||||
|
.headerSmallButton {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.headerDownloadButton {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
const GITHUB_WEB = "https://github.com/fromchat-messenger/web";
|
||||||
|
const GITHUB_APP = "https://github.com/fromchat-messenger/app";
|
||||||
|
export const GITHUB_LICENSE = `${GITHUB_WEB}/blob/main/LICENSE`;
|
||||||
|
|
||||||
|
export function GitHubLink({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={`${GITHUB_WEB}/tree/main`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupportLink({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href="https://t.me/denis0001-dev"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { GITHUB_WEB, GITHUB_APP };
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
export type DownloadOs = "windows" | "linux" | "macos" | "android" | "ios";
|
||||||
|
|
||||||
|
export const ALL_OS: DownloadOs[] = [
|
||||||
|
"windows",
|
||||||
|
"linux",
|
||||||
|
"macos",
|
||||||
|
"android",
|
||||||
|
"ios",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export interface OsInfo {
|
||||||
|
id: DownloadOs;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
icon: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OS_CONFIG: Record<DownloadOs, OsInfo> = {
|
||||||
|
windows: { id: "windows", label: "Windows", description: "ПК", icon: "computer" },
|
||||||
|
linux: { id: "linux", label: "Linux", description: "ПК", icon: "computer" },
|
||||||
|
macos: { id: "macos", label: "macOS", description: "Apple", icon: "computer" },
|
||||||
|
android: { id: "android", label: "Android", description: "APK", icon: "android" },
|
||||||
|
ios: { id: "ios", label: "iOS", description: "iPhone, iPad", icon: "phone_iphone" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function detectOs(): DownloadOs {
|
||||||
|
if (typeof navigator === "undefined") {
|
||||||
|
return "android";
|
||||||
|
}
|
||||||
|
|
||||||
|
const ua = (navigator.userAgent || navigator.platform || "").toLowerCase();
|
||||||
|
const platform = (navigator as any).userAgentData?.platform?.toLowerCase?.() ?? "";
|
||||||
|
const haystack = `${ua} ${platform}`;
|
||||||
|
|
||||||
|
if (haystack.includes("android")) {
|
||||||
|
return "android";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("iphone") || haystack.includes("ipad") || haystack.includes("ipod")) {
|
||||||
|
return "ios";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("win")) {
|
||||||
|
return "windows";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("mac")) {
|
||||||
|
return "macos";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (haystack.includes("linux")) {
|
||||||
|
return "linux";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "android";
|
||||||
|
}
|
||||||
@@ -17,18 +17,21 @@ import 'mdui/components/button';
|
|||||||
import 'mdui/components/text-field';
|
import 'mdui/components/text-field';
|
||||||
import 'mdui/components/button-icon';
|
import 'mdui/components/button-icon';
|
||||||
import 'mdui/components/switch';
|
import 'mdui/components/switch';
|
||||||
|
import 'mdui/components/ripple';
|
||||||
import 'mdui/components/chip';
|
import 'mdui/components/chip';
|
||||||
import 'mdui/components/badge';
|
import 'mdui/components/badge';
|
||||||
import "mdui/mdui.css";
|
import "mdui/mdui.css";
|
||||||
import 'mdui/components/circular-progress';
|
import 'mdui/components/circular-progress';
|
||||||
|
|
||||||
|
import { useCallback, useRef } from "react";
|
||||||
import { setColorScheme } from 'mdui/functions/setColorScheme';
|
import { setColorScheme } from 'mdui/functions/setColorScheme';
|
||||||
import type { ChangeEventHandler, ComponentProps, ComponentPropsWithoutRef, FormEventHandler, Ref } from 'react';
|
import type { ChangeEventHandler, ComponentProps, ComponentPropsWithoutRef, FormEventHandler, Ref, RefObject } from 'react';
|
||||||
import type { TextField } from 'mdui/components/text-field';
|
import type { TextField } from 'mdui/components/text-field';
|
||||||
import type { Switch } from 'mdui/components/switch';
|
import type { Switch } from 'mdui/components/switch';
|
||||||
import type { Override } from '@/core/types';
|
import type { Override } from '@/core/types';
|
||||||
import type { Button } from 'mdui/components/button';
|
import type { Button } from 'mdui/components/button';
|
||||||
import type { ButtonIcon } from 'mdui/components/button-icon';
|
import type { ButtonIcon } from 'mdui/components/button-icon';
|
||||||
|
import type { Ripple } from 'mdui/components/ripple';
|
||||||
import type { Icon } from 'mdui/components/icon';
|
import type { Icon } from 'mdui/components/icon';
|
||||||
import type { Fab } from 'mdui/components/fab';
|
import type { Fab } from 'mdui/components/fab';
|
||||||
import type { Tabs } from 'mdui/components/tabs';
|
import type { Tabs } from 'mdui/components/tabs';
|
||||||
@@ -58,6 +61,7 @@ export type MDUISwitch = Override<HTMLElement, Switch>;
|
|||||||
export type MDUIButton = Override<HTMLElement, Button>;
|
export type MDUIButton = Override<HTMLElement, Button>;
|
||||||
export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>;
|
export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>;
|
||||||
export type MDUIIcon = Override<HTMLElement, Icon>;
|
export type MDUIIcon = Override<HTMLElement, Icon>;
|
||||||
|
export type MDUIRipple = Override<HTMLElement, Ripple>;
|
||||||
export type MDUIFab = Override<HTMLElement, Fab>;
|
export type MDUIFab = Override<HTMLElement, Fab>;
|
||||||
export type MDUITabs = Override<HTMLElement, Tabs>;
|
export type MDUITabs = Override<HTMLElement, Tabs>;
|
||||||
export type MDUITab = Override<HTMLElement, Tab>;
|
export type MDUITab = Override<HTMLElement, Tab>;
|
||||||
@@ -141,3 +145,68 @@ export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-b
|
|||||||
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
|
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
|
||||||
return <mdui-bottom-app-bar {...props as ComponentProps<"mdui-bottom-app-bar">} />
|
return <mdui-bottom-app-bar {...props as ComponentProps<"mdui-bottom-app-bar">} />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MaterialRippleProps = NoChildren<BasePropCustomization<"mdui-ripple", MDUIRipple>>;
|
||||||
|
export function MaterialRipple(props: MaterialRippleProps) {
|
||||||
|
return <mdui-ripple {...props as ComponentProps<"mdui-ripple">} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns pointer handlers that forward press and hover events to an mdui-ripple element.
|
||||||
|
* Pass the returned ref to MaterialRipple and spread the handlers onto the container (e.g. button).
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* const { rippleRef, ...rippleHandlers } = useRippleHandlers(disabled);
|
||||||
|
* <button {...rippleHandlers}>
|
||||||
|
* <MaterialRipple ref={rippleRef} />
|
||||||
|
* ...
|
||||||
|
* </button>
|
||||||
|
*/
|
||||||
|
export function useRippleHandlers(
|
||||||
|
disabled = false
|
||||||
|
): {
|
||||||
|
rippleRef: RefObject<MDUIRipple | null>;
|
||||||
|
onPointerDown: (e: React.PointerEvent) => void;
|
||||||
|
onPointerEnter: (e: React.PointerEvent) => void;
|
||||||
|
onPointerLeave: (e: React.PointerEvent) => void;
|
||||||
|
} {
|
||||||
|
const rippleRef = useRef<MDUIRipple | null>(null);
|
||||||
|
|
||||||
|
const onPointerDown = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (disabled || e.button !== 0) return;
|
||||||
|
const ripple = rippleRef.current;
|
||||||
|
if (!ripple?.startPress) return;
|
||||||
|
ripple.startPress(e.nativeEvent);
|
||||||
|
const btn = e.currentTarget as HTMLElement;
|
||||||
|
const endPress = () => {
|
||||||
|
ripple.endPress?.();
|
||||||
|
btn.removeEventListener("pointerup", endPress);
|
||||||
|
btn.removeEventListener("pointercancel", endPress);
|
||||||
|
btn.removeEventListener("pointerleave", endPress);
|
||||||
|
};
|
||||||
|
btn.addEventListener("pointerup", endPress);
|
||||||
|
btn.addEventListener("pointercancel", endPress);
|
||||||
|
btn.addEventListener("pointerleave", endPress);
|
||||||
|
},
|
||||||
|
[disabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerEnter = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (disabled || e.pointerType !== "mouse") return;
|
||||||
|
rippleRef.current?.startHover?.();
|
||||||
|
},
|
||||||
|
[disabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
const onPointerLeave = useCallback(
|
||||||
|
(e: React.PointerEvent) => {
|
||||||
|
if (disabled || e.pointerType !== "mouse") return;
|
||||||
|
rippleRef.current?.endHover?.();
|
||||||
|
},
|
||||||
|
[disabled]
|
||||||
|
);
|
||||||
|
|
||||||
|
return { rippleRef, onPointerDown, onPointerEnter, onPointerLeave };
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@ declare global {
|
|||||||
interface SyntheticEvent<T = Element, E = Event> {
|
interface SyntheticEvent<T = Element, E = Event> {
|
||||||
target: EventTarget & T;
|
target: EventTarget & T;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
namespace JSX {
|
||||||
|
interface IntrinsicElements {
|
||||||
|
"mdui-ripple": DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Augment DOM event listeners to provide typed target for ALL elements
|
// Augment DOM event listeners to provide typed target for ALL elements
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ TURN_USERNAME=<set>
|
|||||||
TURN_SECRET=<set>
|
TURN_SECRET=<set>
|
||||||
DEPLOYMENT_SERVER=<set>
|
DEPLOYMENT_SERVER=<set>
|
||||||
FIREBASE_CERT=<set>
|
FIREBASE_CERT=<set>
|
||||||
|
RELEASES_TOKEN=<set>
|
||||||
EOF
|
EOF
|
||||||
|
|||||||