Redesign the download table, finish download dialog

This commit is contained in:
2026-03-19 10:01:39 +03:00
Unverified
parent 7f69e38e71
commit da76f9df74
12 changed files with 350 additions and 136 deletions
+5 -11
View File
@@ -40,7 +40,6 @@ export function SplitButton({
top?: number;
bottom?: number;
left: number;
transform: string;
maxHeight: number;
} | null>(null);
const { width: windowWidth, height: windowHeight } = useWindowSize();
@@ -58,9 +57,8 @@ export function SplitButton({
const menuWidth = menuEl?.offsetWidth ?? 220;
const menuHeight = menuEl?.offsetHeight ?? 320;
const anchorX = rect.left + rect.width / 2;
const anchorCenterX = rect.left + rect.width / 2;
let left: number;
let transform: string;
let top: number | undefined;
let bottom: number | undefined;
let maxHeight: number;
@@ -82,18 +80,15 @@ export function SplitButton({
maxHeight = Math.max(100, availableBelow);
}
if (anchorX - menuWidth / 2 < EDGE_PAD) {
if (anchorCenterX - menuWidth / 2 < EDGE_PAD) {
left = EDGE_PAD;
transform = "none";
} else if (anchorX + menuWidth / 2 > vw - EDGE_PAD) {
} else if (anchorCenterX + menuWidth / 2 > vw - EDGE_PAD) {
left = vw - menuWidth - EDGE_PAD;
transform = "none";
} else {
left = anchorX;
transform = "translateX(-50%)";
left = anchorCenterX - menuWidth / 2;
}
setMenuPosition({ top, bottom, left, transform, maxHeight });
setMenuPosition({ top, bottom, left, maxHeight });
}, []);
const closeMenu = useCallback(() => {
@@ -245,7 +240,6 @@ export function SplitButton({
? { bottom: menuPosition.bottom }
: { top: menuPosition.top }),
left: menuPosition.left,
transform: menuPosition.transform,
maxHeight: menuPosition.maxHeight,
overflowY: "auto",
}}
@@ -224,4 +224,8 @@ $menu-padding: 8px;
scrollbar-width: thin;
scrollbar-color: rgba($color-dark-on-surface, 0.25) transparent;
mdui-list {
padding: 0;
}
}
@@ -1,8 +1,8 @@
import type { ReactNode } from "react";
import { StyledDialog } from "./StyledDialog";
import { StyledDialog } from "@/core/components/StyledDialog";
import { MaterialButton, MaterialIcon } from "@/utils/material";
import { OS_CONFIG, type DownloadOs } from "@/core/downloads/os";
import styles from "./css/download-dialog.module.scss";
import { OS_CONFIG, type DownloadOs } from "@/pages/home/os";
import styles from "@/pages/home/download-dialog.module.scss";
interface DownloadDialogProps {
open: boolean;
@@ -98,7 +98,7 @@ export function DownloadDialog({ open, onOpenChange, os }: DownloadDialogProps)
<MaterialIcon name="download" className={styles.icon} />
</div>
<div className={styles.titleBlock}>
<h2 className={styles.title}>Thanks for downloading</h2>
<h2 className={styles.title}>Спасибо за скачивание!</h2>
<p className={styles.subtitle}>
FromChat для&nbsp;
<span className={styles.osName}>{osInfo.label}</span>
@@ -110,4 +110,3 @@ export function DownloadDialog({ open, onOpenChange, os }: DownloadDialogProps)
</StyledDialog>
);
}
+2 -2
View File
@@ -1,7 +1,7 @@
import { Link } from "react-router-dom";
import { MaterialIcon } from "@/utils/material";
import { GitHubLink, GITHUB_WEB, GITHUB_APP, GITHUB_LICENSE } from "./homeLinks";
import styles from "./home-footer.module.scss";
import { GitHubLink, GITHUB_WEB, GITHUB_APP, GITHUB_LICENSE } from "@/pages/home/homeLinks";
import styles from "@/pages/home/home-footer.module.scss";
export function HomeFooter() {
return (
+2 -2
View File
@@ -2,8 +2,8 @@ import { useNavigate } from "react-router-dom";
import { useUserStore } from "@/state/user";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialIconButton } from "@/utils/material";
import { GitHubLink, SupportLink } from "./homeLinks";
import styles from "./home-header.module.scss";
import { GitHubLink, SupportLink } from "@/pages/home/homeLinks";
import styles from "@/pages/home/home-header.module.scss";
export function HomeHeader() {
const navigate = useNavigate();
+61 -26
View File
@@ -1,18 +1,18 @@
import { useNavigate } from "react-router-dom";
import { useState, type ReactNode } from "react";
import styles from "./home.module.scss";
import styles from "@/pages/home/home.module.scss";
import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen";
import { MaterialButton, MaterialIcon, 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 "./HomeHeader";
import { HomeFooter } from "./HomeFooter";
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 "@/core/components/DownloadDialog";
import { OS_CONFIG, ALL_OS, detectOs, type DownloadOs } from "@/core/downloads/os";
import { DownloadDialog } from "@/pages/home/DownloadDialog";
import { OS_CONFIG, ALL_OS, detectOs, type DownloadOs } from "@/pages/home/os";
interface FeatureSectionProps {
title: ReactNode;
@@ -74,6 +74,14 @@ export default function HomePage() {
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}>
<HomeHeader />
@@ -156,36 +164,63 @@ export default function HomePage() {
Настольное приложение с уведомлениями и автономной работой
или мобильное приложение для Android и iOS.
</p>
<div className={styles.downloadTable}>
<div className={styles.downloadTableHeader}>
<span>Платформа</span>
<span>Описание</span>
<span />
</div>
<table className={styles.downloadTable}>
<thead>
<tr>
<th>Платформа</th>
<th>Описание</th>
<th />
</tr>
</thead>
<tbody>
{ALL_OS.map((os) => (
<div key={os} className={styles.downloadTableRow}>
<div className={styles.downloadTableOs}>
<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}
/>
) : (
<MaterialIcon
name={OS_CONFIG[os].icon}
className={styles.downloadTableIcon}
/>
)}
<span>{OS_CONFIG[os].label}</span>
</div>
<span className={styles.downloadTableDesc}>
</span>
</td>
<td className={styles.downloadTableDesc}>
<span className={styles.downloadTableDescInner}>
{OS_CONFIG[os].description}
</span>
<div className={styles.downloadTableAction}>
</td>
<td className={styles.downloadTableAction}>
<span className={styles.downloadTableActionInner}>
<MaterialButton
variant="outlined"
variant={getButtonVariant(os)}
icon="download"
className={styles.downloadButton}
onClick={() => triggerDownload(os)}
>
Скачать
</MaterialButton>
</div>
</div>
<MaterialIconButton
variant={getButtonVariant(os)}
icon="download"
className={styles.downloadButtonIcon}
onClick={() => triggerDownload(os)}
title={`Скачать ${OS_CONFIG[os].label}`}
/>
</span>
</td>
</tr>
))}
</div>
</tbody>
</table>
</div>
</div>
</section>
+1 -1
View File
@@ -1,4 +1,4 @@
@use "../../css/material" as *;
@use "@/css/material" as *;
$gradient-rainbow: linear-gradient(45deg, #9333EA, #6366F1, #3B82F6, #A855F7, #D946EF, #EC4899, #7E22CE);
$glow-purple: rgba(147, 51, 234, 0.5);
@@ -1,5 +1,5 @@
@use "../../../css/material" as *;
@use "../../../css/colors" as *;
@use "@/css/material" as *;
@use "@/css/colors" as *;
@use "sass:color";
.downloadDialog {
@@ -111,4 +111,3 @@
padding-inline: 16px;
}
}
@@ -1,4 +1,4 @@
@use "../../css/material" as *;
@use "@/css/material" as *;
@use "home-shared" as shared;
.homepageFooter {
@@ -37,7 +37,7 @@
.footerLogo {
width: 40px;
height: 40px;
background-image: url('../../images/logo_square.svg');
background-image: url('@/images/logo_square.svg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
@@ -111,11 +111,11 @@
}
.footerLinkIconSvgTelegram {
mask-image: url('../../images/telegram.svg');
-webkit-mask-image: url('../../images/telegram.svg');
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');
mask-image: url('@/images/max.svg');
-webkit-mask-image: url('@/images/max.svg');
}
@@ -1,4 +1,4 @@
@use "../../css/material" as *;
@use "@/css/material" as *;
@use "home-shared" as shared;
.homepageHeader {
@@ -35,7 +35,7 @@
.logoIcon {
width: 40px;
height: 40px;
background-image: url('../../images/logo_square.svg');
background-image: url('@/images/logo_square.svg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
+175 -48
View File
@@ -1,4 +1,4 @@
@use "../../css/material" as *;
@use "@/css/material" as *;
@use "home-shared" as shared;
// Shared variables (home-specific)
@@ -15,7 +15,18 @@ $gradient-conic: conic-gradient(
rgba(147, 51, 234, 0.5) 100%
);
$radius-card: 20px;
$radius-pill: 9999px;
$radius-pill: 25px;
// Download table variables
$download-cell-padding: 14px 20px;
$download-cell-gap: 12px;
$download-icon-size: 24px;
$download-col1-w: 35%;
$download-col2-w: 30%;
$download-col3-w: 35%;
$download-row-bg-hover: rgba($color-dark-surface-container-high, 0.6);
$download-header-bg: rgba($color-dark-surface-container-high, 0.4);
$download-text-color: $color-dark-on-surface-variant;
// Mixins
@mixin section-heading {
@@ -44,6 +55,30 @@ $radius-pill: 9999px;
padding: 0 32px;
}
@mixin download-cell-inner($justify) {
display: flex;
justify-content: $justify;
align-items: center;
width: 100%;
min-width: 0;
}
@mixin masked-icon($url-var) {
display: inline-block;
width: $download-icon-size;
height: $download-icon-size;
flex-shrink: 0;
background-color: $download-text-color;
mask-image: var(#{$url-var});
-webkit-mask-image: var(#{$url-var});
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
}
.homepage {
min-height: 100vh;
display: flex;
@@ -96,7 +131,7 @@ $radius-pill: 9999px;
.titleLogo {
width: 120px;
height: 120px;
background-image: url('../../images/logo_square.svg');
background-image: url('@/images/logo_square.svg');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
@@ -212,66 +247,169 @@ $radius-pill: 9999px;
}
.downloadTable {
display: table;
user-select: none;
width: 100%;
max-width: 720px;
margin: 0 auto;
border-radius: 20px;
background: rgba($color-dark-surface-container, 0.8);
border: 1px solid rgba($color-dark-outline, 0.3);
padding: 8px 16px;
display: flex;
flex-direction: column;
gap: 4px;
border-radius: 16px;
border-collapse: separate;
border-spacing: 0 6px;
background: transparent;
border: none;
table-layout: auto;
th:first-child,
td:first-child {
width: 1%;
white-space: nowrap;
}
.downloadTableHeader {
display: grid;
grid-template-columns: 2fr 2fr auto;
padding: 8px 4px;
font-size: 12px;
th:nth-child(2),
td:nth-child(2) {
width: 1%;
white-space: nowrap;
}
th:last-child,
td:last-child {
width: 100%;
}
thead {
display: table-header-group;
tr th {
padding: $download-cell-padding;
display: table-cell;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: $color-dark-on-surface-variant;
letter-spacing: 0.1em;
color: $download-text-color;
background: $download-header-bg;
vertical-align: middle;
transition: background-color 0.2s ease;
text-align: left;
&:first-child {
border-radius: 12px 0 0 12px;
}
.downloadTableRow {
display: grid;
grid-template-columns: 2fr 2fr auto;
align-items: center;
padding: 8px 4px;
border-radius: 12px;
&:hover {
background-color: rgba($color-dark-surface-container-high, 0.8);
&:last-child {
border-radius: 0 12px 12px 0;
}
}
}
tbody {
display: table-row-group;
tr {
display: table-row;
background: $color-dark-surface-container-low;
td {
padding: $download-cell-padding;
display: table-cell;
text-align: center;
vertical-align: middle;
transition: background-color 0.2s ease;
background: transparent;
&:first-child {
text-align: left;
border-radius: $radius-pill 0 0 $radius-pill;
}
&:last-child {
text-align: right;
border-radius: 0 $radius-pill $radius-pill 0;
}
}
&:hover td {
background: $download-row-bg-hover;
}
}
}
.downloadTableOs {
display: flex;
align-items: center;
gap: 8px;
text-align: left;
.downloadTableOsContent {
@include download-cell-inner(flex-start);
gap: $download-cell-gap;
min-height: 100%;
height: 100%;
}
}
.downloadTableIcon {
font-size: 20px;
color: $color-dark-primary;
opacity: 0.9;
font-size: $download-icon-size;
width: $download-icon-size;
height: $download-icon-size;
color: $download-text-color;
}
.tableOsIcon {
@include masked-icon("--table-os-icon-url");
}
.downloadTableDesc {
font-size: 14px;
color: $color-dark-on-surface-variant;
color: $download-text-color;
.downloadTableDescInner {
@include download-cell-inner(flex-start);
}
}
.downloadTableAction {
display: flex;
justify-content: flex-end;
text-align: right;
.downloadTableActionInner {
@include download-cell-inner(flex-end);
}
}
.downloadTableActionLink {
text-decoration: none;
.downloadButton {
display: block;
}
.downloadButtonIcon {
display: none;
}
}
}
@media (max-width: 490px) {
.downloadContent .downloadTable {
.downloadButton {
display: none;
}
.downloadButtonIcon {
display: block;
}
}
}
@media (max-width: 405px) {
.downloadContent .downloadTable {
th:nth-child(2),
td:nth-child(2) {
display: none !important;
}
.downloadButton {
display: block !important;
}
.downloadButtonIcon {
display: none !important;
}
}
}
}
@@ -341,18 +479,7 @@ $radius-pill: 9999px;
}
.menuCustomIcon {
display: inline-block;
width: 24px;
height: 24px;
background-color: $color-dark-on-surface-variant;
mask-image: var(--menu-custom-icon-url);
-webkit-mask-image: var(--menu-custom-icon-url);
mask-size: contain;
mask-repeat: no-repeat;
mask-position: center;
-webkit-mask-size: contain;
-webkit-mask-repeat: no-repeat;
-webkit-mask-position: center;
@include masked-icon("--menu-custom-icon-url");
}
// Keyframes for animations
+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";
}