18 Commits

34 changed files with 6194 additions and 984 deletions
+4
View File
@@ -576,3 +576,7 @@ backend/alembic/**
!backend/alembic/script.py.mako
!frontend/src/css/lib
**/*.module.scss.d.ts
.cursor/plans
tmp
compliance_keypair.txt
+65 -78
View File
@@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
it under the terms of the GNU Affero General Public License as published
by the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.
+2 -1
View File
@@ -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
@@ -176,3 +176,4 @@ 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)
app.include_router(download.router)
+381
View File
@@ -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
+26
View File
@@ -79,6 +79,32 @@ beta.fromchat.ru {
}
}
git.fromchat.ru {
reverse_proxy 172.18.0.1:3000 host.docker.internal:3000 172.17.0.1:3000 {
lb_policy first
header_up X-Real-IP {remote_host}
}
# Security headers
header {
X-XSS-Protection "1; mode=block" # Prevent XSS attacks
X-Content-Type-Options "nosniff" # Prevent MIME type sniffing
X-Frame-Options "DENY" # Prevent clickjacking
Referrer-Policy "strict-origin-when-cross-origin"
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
}
rate_limit {
zone global {
key {remote_ip}
window 1m
burst 20
events 500
}
}
}
api.getgadgets.toolbox-io.ru {
reverse_proxy 95.165.0.162:8400
}
@@ -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;
}
}
+18
View File
@@ -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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 879 KiB

+4
View File
@@ -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

+3
View File
@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

+3
View File
@@ -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

+6
View File
@@ -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

+39 -68
View File
@@ -1,5 +1,5 @@
import { AuthContainer } from "./Auth";
import { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react";
import { useState, useEffect, useRef } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { motion, AnimatePresence } from "motion/react";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
@@ -8,18 +8,23 @@ import { RegisterForm } from "./RegisterForm";
import type { Variants, Transition } from "motion/react";
import styles from "./auth.module.scss";
const MIN_HEIGHT = 400;
const slideVariants: Variants = {
enter: (direction: number) => ({
x: direction > 0 ? 300 : -300,
opacity: 0
opacity: 0,
y: 0 // Ensure no vertical movement
}),
center: {
x: 0,
opacity: 1
opacity: 1,
y: 0 // Ensure no vertical movement
},
exit: (direction: number) => ({
x: direction > 0 ? -300 : 300,
opacity: 0
opacity: 0,
y: 0 // Ensure no vertical movement
})
};
@@ -45,80 +50,55 @@ export default function AuthPage() {
const containerRef = useRef<HTMLDivElement>(null);
const loginFormRef = useRef<HTMLDivElement>(null);
const registerFormRef = useRef<HTMLDivElement>(null);
const [containerHeight, setContainerHeight] = useState<number | "auto">("auto");
const [containerHeight, setContainerHeight] = useState<number>(400);
const [isTransitioning, setIsTransitioning] = useState(false);
const currentMode = searchParams.get("mode") || "login";
const enteringElementRef = useRef<"login" | "register" | null>(null);
const [effectActivated, setEffectActivated] = useState(false);
useEffect(() => {
if (prevMode.current !== currentMode) {
setDirection(currentMode === "register" ? 1 : -1);
prevMode.current = currentMode;
enteringElementRef.current = currentMode as "login" | "register";
setIsTransitioning(true);
}
}, [currentMode]);
const measureActiveHeight = useCallback(() => {
const activeComponent = currentMode === "login" ? loginFormRef.current : registerFormRef.current;
if (activeComponent) {
const height = activeComponent.scrollHeight;
// Setup ResizeObserver to watch for content changes
useEffect(() => {
const activeRef = currentMode === "login" ? loginFormRef : registerFormRef;
if (activeRef.current) {
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
const height = entry.contentRect.height;
if (height > 0) {
setContainerHeight(height);
setContainerHeight(Math.max(height, MIN_HEIGHT));
}
}
}, [currentMode, loginFormRef, registerFormRef]);
useLayoutEffect(() => {
if (!effectActivated) {
setEffectActivated(true);
return;
}
// Always measure, but prioritize the entering element during transitions
// Use double requestAnimationFrame to ensure DOM is fully updated and layout is complete
let rafId2: number | null = null;
const rafId1 = requestAnimationFrame(() => {
rafId2 = requestAnimationFrame(() => {
measureActiveHeight();
});
});
resizeObserver.observe(activeRef.current);
// Initial measurement
const initialHeight = activeRef.current.scrollHeight;
if (initialHeight > 0) {
setContainerHeight(Math.max(initialHeight, MIN_HEIGHT));
}
return () => {
cancelAnimationFrame(rafId1);
if (rafId2 !== null) {
cancelAnimationFrame(rafId2);
}
resizeObserver.disconnect();
};
}
}, [currentMode]);
function switchMode(newMode: "login" | "register") {
navigate(`/auth?mode=${newMode}`, { replace: true });
}
function handleAnimationComplete(
currentMode: "login" | "register",
mode: "login" | "register",
enteringElementRef: RefObject<"login" | "register" | null>,
formRef: React.RefObject<HTMLDivElement | null>,
setContainerHeight: (height: number) => void
) {
return () => {
if (currentMode === mode && enteringElementRef.current === mode) {
enteringElementRef.current = null;
function handleAnimationComplete() {
setIsTransitioning(false);
}
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (formRef.current && currentMode === mode) {
const height = formRef.current.scrollHeight;
if (height > 0) {
setContainerHeight(height);
}
}
});
});
}
}
}
return (
<AuthContainer>
@@ -127,14 +107,8 @@ export default function AuthPage() {
style={{
position: "relative",
width: "100%",
height: containerHeight === "auto" ? "auto" : `${containerHeight}px`,
transition: "height 0.3s ease"
}}
onAnimationStart={() => {
}}
onAnimationEnd={() => {
setContainerHeight("auto");
height: `${containerHeight}px`,
transition: isTransitioning ? "height 0.3s ease" : "none"
}}
>
<AnimatePresence mode="sync" custom={direction}>
@@ -148,11 +122,8 @@ export default function AuthPage() {
animate="center"
exit="exit"
transition={slideTransition}
onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)}
onAnimationComplete={handleAnimationComplete}
className={styles.formWrapper}
style={{
position: containerHeight === "auto" ? "relative" : "absolute"
}}
>
<LoginForm onSwitchMode={() => switchMode("register")} />
</motion.div>
@@ -166,7 +137,7 @@ export default function AuthPage() {
animate="center"
exit="exit"
transition={slideTransition}
onAnimationComplete={handleAnimationComplete("register", "register", enteringElementRef, registerFormRef, setContainerHeight)}
onAnimationComplete={handleAnimationComplete}
className={styles.formWrapper}
>
<RegisterForm onSwitchMode={() => switchMode("login")} />
+6
View File
@@ -98,6 +98,12 @@
top: 0;
left: 0;
&.relative {
position: relative;
top: auto;
left: auto;
}
.authHeader {
margin: 0;
padding: 24px;
@@ -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 (
<div className={styles.downloadAppScreen}>
<div>
<h1>Чтобы пользоваться мессенджером, скачайте приложение</h1>
<div className={styles.downloadAppCard}>
<h1>Скачайте приложение</h1>
<p>
Этот сайт <b>не предназначен</b> для работы на маленьких экранах, поэтому
вам нужно скачать приложение мессенджера.
Этот сайт не предназначен для работы на маленьких экранах.
Выберите вашу платформу:
</p>
<a href="https://github.com/denis0001-dev/FromChat-android/releases/latest">
<MaterialButton>Скачать на GitHub</MaterialButton>
<div className={styles.downloadAppButtons}>
<a href="/download?os=android" className={styles.downloadAppBtn}>
<MaterialIcon name="android" />
Android
</a>
<p>
Если возникнут сложности или есть вопросы, нажмите кнопку!
</p>
<a href="https://t.me/denis0001-dev">
<MaterialButton>Написать в поддержку</MaterialButton>
<a href="/download?os=ios" className={styles.downloadAppBtn}>
<MaterialIcon name="phone_iphone" />
iOS
</a>
</div>
<p>
<a href="https://t.me/denis0001-dev">Написать в поддержку</a>
</p>
</div>
)
</div>
);
}
@@ -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;
}
}
+112
View File
@@ -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>
Если появится запрос &quot;Разрешить установку из неизвестных источников&quot; дайте
разрешение для браузера, из которого вы скачивали APK.
</li>
<li>
Google Play Protect может предупредить о неизвестном приложении. Если вы доверяете FromChat,
нажмите &quot;Подробнее&quot; &quot;Всё равно установить&quot; (или аналогичную кнопку).
</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 для&nbsp;
<span className={styles.osName}>{osInfo.label}</span>
</p>
</div>
</div>
{renderInstructions(os)}
</div>
</StyledDialog>
);
}
+95
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+202 -209
View File
@@ -1,228 +1,236 @@
import { useNavigate } from "react-router-dom";
import { useUserStore } from "@/state/user";
import styles from "./home.module.scss";
import { useRef, useState, type ReactNode } from "react";
import styles from "@/pages/home/home.module.scss";
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 }) {
return (
<a href="https://github.com/denis0001-dev/FromChat" target="_blank">{children}</a>
);
interface FeatureSectionProps {
title: ReactNode;
children: ReactNode;
screenshot: string;
right?: boolean;
}
function SupportLink({ children }: { children: React.ReactNode }) {
return (
<a href="https://t.me/denis0001-dev" target="_blank">{children}</a>
function FeatureSection({
title,
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() {
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 [dialogOpen, setDialogOpen] = useState(false);
const [dialogOs, setDialogOs] = useState<DownloadOs>(() => detectOs());
const [menuOpen, setMenuOpen] = useState(false);
const downloadSectionRef = useRef<HTMLElement>(null);
const scrollToDownload = () => {
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 = (
<MaterialButton variant="filled" onClick={handleGetStarted}>
{isMobile ? "Скачать приложение" : isLoggedIn ? "Перейти в чат" : "Войти"}
</MaterialButton>
);
setDialogOs(os);
setDialogOpen(true);
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 (
<div className={styles.homepage}>
<header className={styles.homepageHeader}>
<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>
<HomeHeader onScrollToDownload={scrollToDownload} />
<main>
<section className={styles.hero}>
<div className={styles.container}>
<div className={styles.heroContent}>
<h2 className={styles.heroTitle}>
Безопасный мессенджер с открытым исходным кодом
</h2>
<p className={styles.heroDescription}>
FromChat это полностью открытый мессенджер с end-to-end шифрованием,
поддержкой файлов и уведомлений. Создан для тех, кто ценит приватность и свободу.
</p>
<div className={styles.heroActions}>
{openBtn}
{!isMobile && (
<section className={styles.title}>
<div className={styles.titleLogoWrapper}>
<div className={styles.titleLogo} />
</div>
<div className={styles.titleContent}>FromChat</div>
<div className={styles.titleDesc}>
100% бесплатный и открытый мессенджер. Поддерживает self-hosted установку на своём сервере.
</div>
<div className={styles.titleButtons}>
{isMobile ? null : (
<MaterialButton
variant="outlined"
onClick={() => navigate("/register")}>
Зарегистрироваться
variant="filled"
onClick={() => navigate("/auth?mode=login")}
icon="devices"
>
Открыть веб-версию
</MaterialButton>
)}
</div>
</div>
<div className={styles.heroVisual}>
<div className={styles.chatPreview}>
<div className={styles.chatWindow}>
<div className={styles.chatHeader}>
<div className={styles.chatTitle}>Общий чат</div>
<div className={styles.onlineIndicator}></div>
</div>
<div className={styles.chatMessages}>
<div className={`${styles.message} ${styles.received}`}>
<div className={styles.messageAvatar}>А</div>
<div className={styles.messageContent}>
<div className={styles.messageText}>Привет! Как дела?</div>
<div className={styles.messageTime}>14:30</div>
</div>
</div>
<div className={`${styles.message} ${styles.sent}`}>
<div className={styles.messageContent}>
<div className={styles.messageText}>Всё отлично! А у тебя как?</div>
<div className={styles.messageTime}>14:32</div>
</div>
</div>
<div className={`${styles.message} ${styles.received}`}>
<div className={styles.messageAvatar}>Б</div>
<div className={styles.messageContent}>
<div className={styles.messageText}>Отправляю файл 📎</div>
<div className={styles.messageTime}>14:35</div>
</div>
</div>
</div>
</div>
</div>
</div>
<SplitButton
variant={isMobile ? "filled" : "tonal"}
text="Скачать приложение"
icon="download"
onPrimaryClick={() => triggerDownload(detectOs())}
menuOpen={menuOpen}
onMenuOpen={setMenuOpen}
menu={(
<MaterialList>
{ALL_OS.map((os) => (
<MaterialListItem
key={os}
icon={["windows", "linux", "macos"].includes(os) ? undefined : OS_CONFIG[os].icon}
headline={OS_CONFIG[os].label}
rounded
onClick={() => {
if (triggerDownload(os)) setMenuOpen(false);
}}
>
{["windows", "linux", "macos"].includes(os) && (
<span
slot="icon"
className={styles.menuCustomIcon}
style={{
"--menu-custom-icon-url": `url("${os === "windows" ? windowsIcon : os === "linux" ? linuxIcon : macIcon}")`,
} as React.CSSProperties}
/>
)}
</MaterialListItem>
))}
</MaterialList>
)}
/>
</div>
</section>
<section className={styles.features}>
<div className={styles.container}>
<h3 className={styles.sectionTitle}>Возможности</h3>
<div className={styles.featuresGrid}>
<div className={styles.featureCard}>
<div className={styles.featureIcon}>
<MaterialIcon name="security" />
</div>
<h4>End-to-End Шифрование</h4>
<p>
Ваши личные сообщения защищены современным шифрованием X25519 + AES-GCM.
Только вы и получатель можете прочитать сообщения.
</p>
</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>
<FeatureSection
title={<>Общий чат</>}
screenshot={generalChatScreenshot}
right
>
Открытый форум для всех пользователей сервера. Пишите сообщения, делитесь файлами и общайтесь в реальном времени.
</FeatureSection>
<FeatureSection
title={<>Личные сообщения</>}
screenshot={dmScreenshot}>
Общайтесь с одним человеком в личной переписке.
</FeatureSection>
</section>
<section className={styles.download}>
<section ref={downloadSectionRef} className={styles.download}>
<div className={styles.container}>
<div className={styles.downloadContent}>
<h3>Скачайте приложение</h3>
<p>
Для лучшего опыта используйте настольное приложение с поддержкой
уведомлений и автономной работы.
Настольное приложение с уведомлениями и автономной работой
или мобильное приложение для Android и iOS.
</p>
<div className={styles.downloadButtons}>
{!isMobile ? (
<>
<a
href="https://github.com/Toolbox-io/FromChat/actions/workflows/build.yml"
target="_blank"
rel="noopener noreferrer"
>
<MaterialButton variant="filled">
<MaterialIcon name="download" slot="icon" />
Скачать для ПК
</MaterialButton>
</a>
<MaterialButton variant="outlined" onClick={() => navigate("/login")}>
<MaterialIcon name="language" slot="icon" />
Веб-версия
</MaterialButton>
</>
<table className={styles.downloadTable}>
<thead>
<tr>
<th>Платформа</th>
<th>Описание</th>
<th />
</tr>
</thead>
<tbody>
{ALL_OS.map((os) => (
<tr key={os}>
<td className={styles.downloadTableOs}>
<span className={styles.downloadTableOsContent}>
{["windows", "linux", "macos"].includes(os) ? (
<span
className={styles.tableOsIcon}
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}
className={styles.downloadTableIcon}
/>
)}
</div>
<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>
</section>
@@ -232,7 +240,8 @@ export default function HomePage() {
<div className={styles.ctaContent}>
<h3>Готовы начать общение?</h3>
<p>
Присоединяйтесь к FromChat и общайтесь безопасно с друзьями и коллегами.
Создайте аккаунт за минуту. Общайтесь в общем чате, ведите личную переписку
или звоните всё бесплатно и с открытым кодом.
</p>
<div className={styles.ctaActions}>
{isMobile ? (
@@ -259,24 +268,8 @@ export default function HomePage() {
</section>
</main>
<footer className={styles.homepageFooter}>
<div className={styles.container}>
<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>&copy; 2025 FromChat. Сделано программистом denis0001-dev с для свободы общения.</p>
</div>
</div>
</footer>
<DownloadDialog open={dialogOpen} onOpenChange={setDialogOpen} os={dialogOs} />
<HomeFooter onScrollToDownload={scrollToDownload} />
</div>
);
}
+11
View File
@@ -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;
}
}
File diff suppressed because it is too large Load Diff
+43
View File
@@ -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 };
+56
View File
@@ -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";
}
+70 -1
View File
@@ -17,18 +17,21 @@ 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";
import 'mdui/components/circular-progress';
import { useCallback, useRef } from "react";
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 { 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 +61,7 @@ export type MDUISwitch = Override<HTMLElement, Switch>;
export type MDUIButton = Override<HTMLElement, Button>;
export type MDUIButtonIcon = Override<HTMLElement, ButtonIcon>;
export type MDUIIcon = Override<HTMLElement, Icon>;
export type MDUIRipple = Override<HTMLElement, Ripple>;
export type MDUIFab = Override<HTMLElement, Fab>;
export type MDUITabs = Override<HTMLElement, Tabs>;
export type MDUITab = Override<HTMLElement, Tab>;
@@ -141,3 +145,68 @@ export type MaterialBottomAppBarProps = BasePropCustomization<"mdui-bottom-app-b
export function MaterialBottomAppBar(props: MaterialBottomAppBarProps) {
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 };
}
+6
View File
@@ -8,6 +8,12 @@ declare global {
interface SyntheticEvent<T = Element, E = Event> {
target: EventTarget & T;
}
namespace JSX {
interface IntrinsicElements {
"mdui-ripple": DetailedHTMLProps<HTMLAttributes<HTMLElement>, HTMLElement>;
}
}
}
// Augment DOM event listeners to provide typed target for ALL elements
+78 -4
View File
@@ -407,15 +407,89 @@ if ! docker pussh --help > /dev/null 2>&1; then
echo " Install: npm run install:pussh"
fi
# Detect images
IMAGES=($(docker images --format "{{.Repository}}:{{.Tag}}" | grep "^${PROJECT_NAME}-" || true))
# Detect images based on docker-compose.yml (prefer explicit `image:` entries; fall back to built tags)
cd "$DEPLOYMENT_DIR"
COMPOSE_SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null || true)
IMAGES=()
for S in $COMPOSE_SERVICES; do
# Try to read explicit image: field from the compose config for this service
IMAGE_FROM_COMPOSE=$(docker compose -f docker-compose.yml config 2>/dev/null | \
grep -A5 "^[[:space:]]*${S}:" | \
grep -m1 "image:" || true)
IMAGE_FROM_COMPOSE=$(echo "$IMAGE_FROM_COMPOSE" | sed 's/.*image:[[:space:]]*//' | tr -d '"' | tr -d "'" | xargs || true)
if [ -n "$IMAGE_FROM_COMPOSE" ]; then
IMAGES+=("$IMAGE_FROM_COMPOSE")
else
# If service has a build section (we built it above), use the tag pattern used during build
TAG="${PROJECT_NAME}-${S}:latest"
# Only include the tag if the image exists locally (avoid pushing unrelated images)
if docker images --format "{{.Repository}}:{{.Tag}}" | grep -q "^${TAG}$"; then
IMAGES+=("$TAG")
fi
fi
done
# Deduplicate while preserving order
if [ ${#IMAGES[@]} -gt 0 ]; then
IMAGES=($(printf "%s\n" "${IMAGES[@]}" | awk '!seen[$0]++'))
fi
# Verify that all built images are among the detected images to be pushed.
# This prevents accidentally pushing unrelated images.
BUILT_COUNT=${#BUILT_IMAGES[@]}
MATCHING_BUILT=0
MISSING_FROM_DETECTED=()
for BI in "${BUILT_IMAGES[@]}"; do
found=false
for DI in "${IMAGES[@]}"; do
if [ "$BI" = "$DI" ]; then
found=true
break
fi
done
if [ "$found" = true ]; then
MATCHING_BUILT=$((MATCHING_BUILT + 1))
else
MISSING_FROM_DETECTED+=("$BI")
fi
done
# Also list detected images that weren't built locally (these are likely external images)
NOT_BUILT_DETECTED=()
for DI in "${IMAGES[@]}"; do
built=false
for BI in "${BUILT_IMAGES[@]}"; do
if [ "$DI" = "$BI" ]; then
built=true
break
fi
done
if [ "$built" = false ]; then
NOT_BUILT_DETECTED+=("$DI")
fi
done
if [ "$BUILT_COUNT" -ne "$MATCHING_BUILT" ]; then
error "Mismatch between built images (${BUILT_COUNT}) and detected built images (${MATCHING_BUILT})."
if [ ${#MISSING_FROM_DETECTED[@]} -gt 0 ]; then
echo " Built but not detected: ${MISSING_FROM_DETECTED[*]}"
fi
if [ ${#NOT_BUILT_DETECTED[@]} -gt 0 ]; then
echo " Detected but not built (external images): ${NOT_BUILT_DETECTED[*]}"
fi
echo "Aborting to avoid pushing incorrect images."
exit 1
fi
if [ ${#IMAGES[@]} -eq 0 ]; then
error "No ${PROJECT_NAME} images found"
error "No images found in docker-compose.yml or built locally for project ${PROJECT_NAME}"
fi
# Pre-pull unregistry image if needed
UNREGISTRY_IMAGE="ghcr.io/psviderski/unregistry:0.3.1"
UNREGISTRY_IMAGE="ghcr.io/psviderski/unregistry"
if ! ssh "$SERVER" "docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^${UNREGISTRY_IMAGE}$'" 2>/dev/null; then
substep "Pulling unregistry image (one-time setup)..."
ssh "$SERVER" "docker pull ${UNREGISTRY_IMAGE}" > /dev/null 2>&1 || true
+1
View File
@@ -10,4 +10,5 @@ TURN_USERNAME=<set>
TURN_SECRET=<set>
DEPLOYMENT_SERVER=<set>
FIREBASE_CERT=<set>
RELEASES_TOKEN=<set>
EOF