From a266de179342974c81c0a67460e77f301e43c11f Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 15 Mar 2026 23:36:32 +0300 Subject: [PATCH] Implement download table, backend API for downloads --- backend/app.py | 5 +- backend/routes/download.py | 381 ++++++++++++++++++ .../pages/download-app/DownloadAppPage.tsx | 33 +- .../download-app/download-app.module.scss | 63 ++- frontend/src/pages/home/HomeFooter.tsx | 87 ++++ frontend/src/pages/home/HomeHeader.tsx | 68 ++++ frontend/src/pages/home/HomePage.tsx | 291 ++----------- frontend/src/pages/home/_home-shared.scss | 11 + .../src/pages/home/home-footer.module.scss | 120 ++++++ .../src/pages/home/home-header.module.scss | 89 ++++ frontend/src/pages/home/home.module.scss | 324 +++------------ frontend/src/pages/home/homeLinks.tsx | 43 ++ frontend/src/utils/material.tsx | 9 + scripts/generate:env.sh | 1 + 14 files changed, 997 insertions(+), 528 deletions(-) create mode 100644 backend/routes/download.py create mode 100644 frontend/src/pages/home/HomeFooter.tsx create mode 100644 frontend/src/pages/home/HomeHeader.tsx create mode 100644 frontend/src/pages/home/_home-shared.scss create mode 100644 frontend/src/pages/home/home-footer.module.scss create mode 100644 frontend/src/pages/home/home-header.module.scss create mode 100644 frontend/src/pages/home/homeLinks.tsx 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 - - +
+ + + Android + + + + iOS + +

- Если возникнут сложности или есть вопросы, нажмите кнопку! + Написать в поддержку

- - - Написать в поддержку -
- ) + ); } 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 ( +
+
+
+
+
+

FromChat

+
+
+ + 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 (
-
-
-
-
-
-

FromChat

-
-
- - 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.

-
- {!isMobile ? ( - <> - - - Для ПК - Windows, macOS, Linux - - - Скачать - - - - - Android - APK на GitHub - - - Скачать - - -
+
+
+ Платформа + Описание + +
+ {ALL_OS.map((os) => ( +
+
- - Веб-версия - - - Без установки - - navigate("/login")} - > - - Открыть - + {OS_CONFIG[os].label}
- - ) : ( - - - Android - Скачайте APK на GitHub - - - Скачать приложение - - - )} + + {OS_CONFIG[os].description} + + +
+ ))}
@@ -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