diff --git a/backend/app.py b/backend/app.py
index 12ce81e..f4bee09 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -6,7 +6,7 @@ from contextlib import asynccontextmanager
import subprocess
import sys
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
from models import User
from constants import OWNER_USERNAME
@@ -175,4 +175,5 @@ app.include_router(profile.router)
app.include_router(push.router, prefix="/push")
app.include_router(webrtc.router, prefix="/webrtc")
app.include_router(devices.router, prefix="/devices")
-app.include_router(moderation.router)
\ No newline at end of file
+app.include_router(moderation.router)
+app.include_router(download.router)
\ No newline at end of file
diff --git a/backend/routes/download.py b/backend/routes/download.py
new file mode 100644
index 0000000..a13f6d1
--- /dev/null
+++ b/backend/routes/download.py
@@ -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
diff --git a/frontend/src/pages/download-app/DownloadAppPage.tsx b/frontend/src/pages/download-app/DownloadAppPage.tsx
index 54944af..67fd693 100644
--- a/frontend/src/pages/download-app/DownloadAppPage.tsx
+++ b/frontend/src/pages/download-app/DownloadAppPage.tsx
@@ -1,28 +1,29 @@
-import { MaterialButton } from "@/utils/material";
+import { MaterialIcon } from "@/utils/material";
import styles from "./download-app.module.scss";
export default function DownloadAppPage() {
return (
-
-
Чтобы пользоваться мессенджером, скачайте приложение
+
+
Скачайте приложение
- Этот сайт не предназначен для работы на маленьких экранах, поэтому
- вам нужно скачать приложение мессенджера.
+ Этот сайт не предназначен для работы на маленьких экранах.
+ Выберите вашу платформу:
-
-
- Скачать на GitHub
-
-
+
- Если возникнут сложности или есть вопросы, нажмите кнопку!
+ Написать в поддержку
-
-
- Написать в поддержку
-
- )
+ );
}
diff --git a/frontend/src/pages/download-app/download-app.module.scss b/frontend/src/pages/download-app/download-app.module.scss
index e40a734..fc3f41d 100644
--- a/frontend/src/pages/download-app/download-app.module.scss
+++ b/frontend/src/pages/download-app/download-app.module.scss
@@ -1,9 +1,70 @@
+@use "../../css/material" as *;
+
.downloadAppScreen {
display: flex;
justify-content: center;
align-items: center;
min-width: 100vw;
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;
+ }
}
diff --git a/frontend/src/pages/home/HomeFooter.tsx b/frontend/src/pages/home/HomeFooter.tsx
new file mode 100644
index 0000000..fc6ea9d
--- /dev/null
+++ b/frontend/src/pages/home/HomeFooter.tsx
@@ -0,0 +1,87 @@
+import { Link } from "react-router-dom";
+import { MaterialIcon } from "@/utils/material";
+import { GitHubLink, GITHUB_WEB, GITHUB_APP, GITHUB_LICENSE } from "./homeLinks";
+import styles from "./home-footer.module.scss";
+
+export function HomeFooter() {
+ return (
+
+ );
+}
diff --git a/frontend/src/pages/home/HomeHeader.tsx b/frontend/src/pages/home/HomeHeader.tsx
new file mode 100644
index 0000000..59b62f9
--- /dev/null
+++ b/frontend/src/pages/home/HomeHeader.tsx
@@ -0,0 +1,68 @@
+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 "./homeLinks";
+import styles from "./home-header.module.scss";
+
+export function HomeHeader() {
+ const navigate = useNavigate();
+ const { user } = useUserStore();
+ const { isMobile } = useDownloadAppScreen();
+ const isLoggedIn = user.authToken && user.currentUser;
+
+ function handleGetStarted() {
+ if (isMobile) {
+ navigate("/download-app");
+ } else if (isLoggedIn) {
+ navigate("/chat");
+ } else {
+ navigate("/login");
+ }
+ }
+
+ const openBtn = (
+
+ {isMobile ? "Скачать" : isLoggedIn ? "Открыть" : "Войти"}
+
+ );
+
+ return (
+
+
+
+
+
+
+ GitHub
+
+
+ Поддержка
+
+
+
+ {openBtn}
+ {isMobile ? (
+ navigate("/download-app")}
+ icon="download"
+ className={styles.headerSmallButton}
+ />
+ ) : null}
+
+
+
+
+ );
+}
diff --git a/frontend/src/pages/home/HomePage.tsx b/frontend/src/pages/home/HomePage.tsx
index 085896a..ca3cdc7 100644
--- a/frontend/src/pages/home/HomePage.tsx
+++ b/frontend/src/pages/home/HomePage.tsx
@@ -1,53 +1,13 @@
-import { Link, useNavigate } from "react-router-dom";
-import { useUserStore } from "@/state/user";
+import { useNavigate } from "react-router-dom";
+import type { ReactNode } from "react";
import styles from "./home.module.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
-import { MaterialButton, MaterialIcon, MaterialIconButton } from "@/utils/material";
+import { MaterialButton, MaterialIcon } from "@/utils/material";
import generalChatScreenshot from "../../images/screenshots/general-chat.png";
import dmScreenshot from "../../images/screenshots/dm.png";
-import type { ReactNode } from "react";
-
-const GITHUB_WEB = "https://github.com/fromchat-messenger/web";
-const GITHUB_APP = "https://github.com/fromchat-messenger/app";
-const GITHUB_LICENSE = `${GITHUB_WEB}/blob/main/LICENSE`;
-
-function GitHubLink({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return (
-
- {children}
-
- );
-}
-
-function SupportLink({
- children,
- className,
-}: {
- children: React.ReactNode;
- className?: string;
-}) {
- return (
-
- {children}
-
- );
-}
+import { HomeHeader } from "./HomeHeader";
+import { HomeFooter } from "./HomeFooter";
+import { OS_CONFIG, ALL_OS } from "@/core/downloads/os";
interface FeatureSectionProps {
title: ReactNode;
@@ -85,64 +45,11 @@ function FeatureSection({
export default function HomePage() {
const navigate = useNavigate();
- const { user } = useUserStore();
const { isMobile } = useDownloadAppScreen();
- const isLoggedIn = user.authToken && user.currentUser;
-
- function handleGetStarted() {
- if (isMobile) {
- navigate("/download-app");
- } else if (isLoggedIn) {
- navigate("/chat");
- } else {
- navigate("/login");
- }
- }
-
- const openBtn = (
-
- {isMobile ? "Скачать" : isLoggedIn ? "Открыть" : "Войти"}
-
- );
return (
-
-
-
-
-
-
- GitHub
-
-
- Поддержка
-
-
-
- {openBtn}
- {isMobile ? (
- navigate("/download-app")}
- icon="download"
- className={styles.headerSmallButton}
- />
- ) : null}
-
-
-
-
+
@@ -155,9 +62,9 @@ export default function HomePage() {
{isMobile ? null : (
-
navigate("/auth?mode=login")}
+ navigate("/auth?mode=login")}
icon="devices"
>
Открыть веб-версию
@@ -193,77 +100,42 @@ export default function HomePage() {
Скачайте приложение
- Для лучшего опыта используйте настольное приложение с уведомлениями
- и автономной работой или мобильное приложение для Android.
+ Настольное приложение с уведомлениями и автономной работой
+ или мобильное приложение для Android и iOS.
-
@@ -302,84 +174,7 @@ export default function HomePage() {
-
+
);
}
diff --git a/frontend/src/pages/home/_home-shared.scss b/frontend/src/pages/home/_home-shared.scss
new file mode 100644
index 0000000..1174afa
--- /dev/null
+++ b/frontend/src/pages/home/_home-shared.scss
@@ -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;
+}
diff --git a/frontend/src/pages/home/home-footer.module.scss b/frontend/src/pages/home/home-footer.module.scss
new file mode 100644
index 0000000..29f49fd
--- /dev/null
+++ b/frontend/src/pages/home/home-footer.module.scss
@@ -0,0 +1,120 @@
+@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;
+}
+
+.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;
+ transition: color 0.2s ease, transform 0.15s ease;
+ -webkit-user-drag: none;
+ user-select: none;
+
+ &:hover {
+ color: $color-dark-on-surface;
+ }
+
+ &: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');
+}
diff --git a/frontend/src/pages/home/home-header.module.scss b/frontend/src/pages/home/home-header.module.scss
new file mode 100644
index 0000000..681ac58
--- /dev/null
+++ b/frontend/src/pages/home/home-header.module.scss
@@ -0,0 +1,89 @@
+@use "../../css/material" as *;
+@use "home-shared" as shared;
+
+.homepageHeader {
+ display: flex;
+ justify-content: center;
+ padding: 16px;
+ position: sticky;
+ top: 0;
+ z-index: 1000;
+}
+
+.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;
+ }
+}
diff --git a/frontend/src/pages/home/home.module.scss b/frontend/src/pages/home/home.module.scss
index 42be682..1a64347 100644
--- a/frontend/src/pages/home/home.module.scss
+++ b/frontend/src/pages/home/home.module.scss
@@ -1,7 +1,7 @@
@use "../../css/material" as *;
+@use "home-shared" as shared;
-// Shared variables
-$gradient-rainbow: linear-gradient(45deg, #9333EA, #6366F1, #3B82F6, #A855F7, #D946EF, #EC4899, #7E22CE);
+// Shared variables (home-specific)
$gradient-conic: conic-gradient(
from 0deg,
rgba(147, 51, 234, 0.5) 0%,
@@ -14,23 +14,15 @@ $gradient-conic: conic-gradient(
rgba(126, 34, 206, 0.6) 87.5%,
rgba(147, 51, 234, 0.5) 100%
);
-$glow-purple: rgba(147, 51, 234, 0.5);
$radius-card: 20px;
$radius-pill: 9999px;
// Mixins
-@mixin gradient-text {
- background: $gradient-rainbow;
- background-clip: text;
- -webkit-text-fill-color: transparent;
- text-shadow: 0 0 20px $glow-purple;
-}
-
@mixin section-heading {
font-size: 40px;
font-weight: 700;
margin-bottom: 24px;
- @include gradient-text;
+ @include shared.gradient-text;
}
@mixin section-content {
@@ -54,6 +46,8 @@ $radius-pill: 9999px;
.homepage {
min-height: 100vh;
+ display: flex;
+ flex-direction: column;
color: $color-dark-on-background;
font-family: 'Montserrat', sans-serif;
position: relative;
@@ -66,94 +60,9 @@ $radius-pill: 9999px;
user-select: none;
}
- header.homepageHeader {
- display: flex;
- justify-content: center;
- padding: 16px;
- position: sticky;
- top: 0;
- z-index: 1000;
-
- .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;
- }
-
- h1 {
- font-size: 32px;
- font-weight: 700;
- margin: 0;
- @include 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;
- }
- }
- }
- }
- }
-
main {
+ flex: 1;
+
.title {
padding: 60px 0;
display: flex;
@@ -201,7 +110,7 @@ $radius-pill: 9999px;
font-weight: 700;
margin-top: 10px;
padding: 0 80px;
- @include gradient-text;
+ @include shared.gradient-text;
}
.titleDesc {
@@ -247,7 +156,7 @@ $radius-pill: 9999px;
font-size: 30pt;
font-weight: 700;
line-height: normal;
- @include gradient-text;
+ @include shared.gradient-text;
}
.featureDesc {
@@ -302,55 +211,66 @@ $radius-pill: 9999px;
@include section-heading;
}
- .downloadPlatforms {
- display: flex;
- gap: 24px;
- justify-content: center;
- flex-wrap: wrap;
- }
-
- .downloadCard {
+ .downloadTable {
+ width: 100%;
+ max-width: 720px;
+ margin: 0 auto;
+ border-radius: 20px;
+ background: rgba($color-dark-surface-container, 0.8);
+ border: 1px solid rgba($color-dark-outline, 0.3);
+ padding: 8px 16px;
display: flex;
flex-direction: column;
+ gap: 4px;
+ }
+
+ .downloadTableHeader {
+ display: grid;
+ grid-template-columns: 2fr 2fr auto;
+ padding: 8px 4px;
+ font-size: 12px;
+ text-transform: uppercase;
+ letter-spacing: 0.08em;
+ color: $color-dark-on-surface-variant;
+ }
+
+ .downloadTableRow {
+ display: grid;
+ grid-template-columns: 2fr 2fr auto;
align-items: center;
- gap: 12px;
- padding: 32px 24px;
- min-width: 200px;
- max-width: 260px;
- background: rgba($color-dark-surface-container, 0.6);
- border: 1px solid rgba($color-dark-outline, 0.3);
- border-radius: $radius-card;
- text-decoration: none;
- color: inherit;
- transition: all 0.3s ease;
-
+ padding: 8px 4px;
+ border-radius: 12px;
&:hover {
- background: rgba($color-dark-surface-container-high, 0.8);
- border-color: rgba($color-dark-primary, 0.5);
- box-shadow: 0 0 24px rgba($color-dark-primary, 0.2);
- }
-
- .downloadCardIcon {
- width: 48px;
- height: 48px;
- color: $color-dark-primary;
- }
-
- .downloadCardTitle {
- font-size: 20px;
- font-weight: 600;
- @include gradient-text;
- }
-
- .downloadCardDesc {
- font-size: 14px;
- opacity: 0.8;
- }
-
- .downloadCardBtn {
- margin-top: 8px;
+ background-color: rgba($color-dark-surface-container-high, 0.8);
}
}
+
+ .downloadTableOs {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ }
+
+ .downloadTableIcon {
+ font-size: 20px;
+ color: $color-dark-primary;
+ opacity: 0.9;
+ }
+
+ .downloadTableDesc {
+ font-size: 14px;
+ color: $color-dark-on-surface-variant;
+ }
+
+ .downloadTableAction {
+ display: flex;
+ justify-content: flex-end;
+ }
+
+ .downloadTableActionLink {
+ text-decoration: none;
+ }
+
}
}
@@ -378,124 +298,6 @@ $radius-pill: 9999px;
}
}
- footer.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;
-
- .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 gradient-text;
- }
-
- .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;
- transition: color 0.2s ease, transform 0.15s ease;
- -webkit-user-drag: none;
- user-select: none;
-
- &:hover {
- color: $color-dark-on-surface;
- }
-
- &: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');
- }
- }
- }
- }
-
- @media (max-width: 635px) {
- flex-direction: column-reverse;
- align-items: flex-start;
- padding: 32px;
- }
- }
-
@media (max-width: 635px) {
main {
.title {
diff --git a/frontend/src/pages/home/homeLinks.tsx b/frontend/src/pages/home/homeLinks.tsx
new file mode 100644
index 0000000..f8c51fe
--- /dev/null
+++ b/frontend/src/pages/home/homeLinks.tsx
@@ -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 (
+
+ {children}
+
+ );
+}
+
+export function SupportLink({
+ children,
+ className,
+}: {
+ children: React.ReactNode;
+ className?: string;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+export { GITHUB_WEB, GITHUB_APP };
diff --git a/frontend/src/utils/material.tsx b/frontend/src/utils/material.tsx
index 31203d8..e8785ff 100644
--- a/frontend/src/utils/material.tsx
+++ b/frontend/src/utils/material.tsx
@@ -17,6 +17,7 @@ import 'mdui/components/button';
import 'mdui/components/text-field';
import 'mdui/components/button-icon';
import 'mdui/components/switch';
+import 'mdui/components/ripple';
import 'mdui/components/chip';
import 'mdui/components/badge';
import "mdui/mdui.css";
@@ -29,6 +30,7 @@ import type { Switch } from 'mdui/components/switch';
import type { Override } from '@/core/types';
import type { Button } from 'mdui/components/button';
import type { ButtonIcon } from 'mdui/components/button-icon';
+import type { Ripple } from 'mdui/components/ripple';
import type { Icon } from 'mdui/components/icon';
import type { Fab } from 'mdui/components/fab';
import type { Tabs } from 'mdui/components/tabs';
@@ -58,6 +60,7 @@ export type MDUISwitch = Override;
export type MDUIButton = Override;
export type MDUIButtonIcon = Override;
export type MDUIIcon = Override;
+export type MDUIRipple = Override;
export type MDUIFab = Override;
export type MDUITabs = Override;
export type MDUITab = Override;
@@ -140,4 +143,10 @@ export function MaterialCircularProgress(props: MaterialCircularProgressProps) {
export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-bar", MDUIBottomAppBar>;
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
return } />
+}
+
+export type MaterialRippleProps = BasePropCustomization<"div", MDUIRipple>;
+export function MaterialRipple(props: MaterialRippleProps) {
+ // Wrapper component for future custom ripple usage; MDUI buttons already include real ripple.
+ return } />;
}
\ No newline at end of file
diff --git a/scripts/generate:env.sh b/scripts/generate:env.sh
index 0f1df8e..a4d4141 100755
--- a/scripts/generate:env.sh
+++ b/scripts/generate:env.sh
@@ -10,4 +10,5 @@ TURN_USERNAME=
TURN_SECRET=
DEPLOYMENT_SERVER=
FIREBASE_CERT=
+RELEASES_TOKEN=
EOF