Prepare production deployment

This commit is contained in:
2026-07-20 04:14:24 +05:00
parent 55583e2101
commit 2e3550a4dc
46 changed files with 1497 additions and 882 deletions
+17
View File
@@ -0,0 +1,17 @@
.git
.github
.gitea
.idea
.claude
.npm-cache
node_modules
build
.svelte-kit
.env
.env.*
!.env.example
*.zip
npm-debug.log*
pnpm-debug.log*
Dockerfile
docker-compose.yml
+13
View File
@@ -0,0 +1,13 @@
PUBLIC_SITE_URL=https://trade.flamy.studio
PUBLIC_SITE_NAME=Flamy Trade
PUBLIC_SITE_DESCRIPTION=Публичная витрина ML-прогнозов для крипторынка.
HOST=0.0.0.0
PORT=3000
ORIGIN=https://trade.flamy.studio
BODY_SIZE_LIMIT=1M
SHUTDOWN_TIMEOUT=15
APP_HOST_IP=10.20.0.20
APP_PUBLISHED_PORT=18082
APP_IMAGE_TAG=local
+9
View File
@@ -0,0 +1,9 @@
* text=auto eol=lf
*.svelte text eol=lf
*.ts text eol=lf
*.js text eol=lf
*.json text eol=lf
*.yaml text eol=lf
*.yml text eol=lf
*.md text eol=lf
*.css text eol=lf
+2
View File
@@ -1,4 +1,6 @@
node_modules node_modules
.npm-cache
.corepack
# Output # Output
.output .output
+52
View File
@@ -0,0 +1,52 @@
# syntax=docker/dockerfile:1.7
ARG NODE_IMAGE=node:24.18.0-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd
FROM ${NODE_IMAGE} AS base
WORKDIR /app
ENV PNPM_HOME=/pnpm
ENV PATH=${PNPM_HOME}:${PATH}
RUN corepack enable
FROM base AS deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
RUN pnpm install --frozen-lockfile
FROM deps AS quality
COPY . .
RUN pnpm format:check
RUN pnpm lint
RUN pnpm check
FROM quality AS build
RUN pnpm build
FROM base AS prod-deps
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
RUN pnpm install --frozen-lockfile --prod --ignore-scripts
FROM ${NODE_IMAGE} AS runtime
WORKDIR /app
ARG BUILD_DATE
ARG VCS_REF
LABEL org.opencontainers.image.title="Flamy Trade"
LABEL org.opencontainers.image.description="SvelteKit frontend for trade.flamy.studio"
LABEL org.opencontainers.image.created="${BUILD_DATE}"
LABEL org.opencontainers.image.revision="${VCS_REF}"
ENV NODE_ENV=production
ENV HOST=0.0.0.0
ENV PORT=3000
ENV ORIGIN=https://trade.flamy.studio
ENV BODY_SIZE_LIMIT=1M
ENV SHUTDOWN_TIMEOUT=15
COPY --from=prod-deps --chown=node:node /app/node_modules ./node_modules
COPY --from=build --chown=node:node /app/build ./build
COPY --from=build --chown=node:node /app/package.json ./package.json
USER node
EXPOSE 3000
CMD ["node", "build/index.js"]
+78 -261
View File
@@ -1,215 +1,101 @@
# Flamy Trade — Frontend # Flamy Trade
Публичный лендинг и дашборд ML-прогнозов для крипторынка. Построен на **SvelteKit 5** (Runes API), **Tailwind v4**, **TypeScript** и **lightweight-charts v5**. Публичный frontend для `trade.flamy.studio`: лендинг, блог и демонстрационный дашборд ML-прогнозов по крипторынку.
--- Проект построен на SvelteKit, Svelte 5 Runes, Tailwind CSS v4, TypeScript и `lightweight-charts`.
## Стек ## Требования
| Слой | Технология | ```text
|---|---| Node.js: >=24.0.0 <25
| Фреймворк | SvelteKit 5 (runes, SSR) | pnpm: >=11.15.1 <12
| Язык | TypeScript (strict) | ```
| Стили | Tailwind CSS v4 (Vite plugin, `@theme`) |
| Графики | lightweight-charts v5 (TradingView) |
| Сервер | `@sveltejs/adapter-node` (Node.js) |
| Пакетный менеджер | pnpm |
--- В Windows PowerShell запускайте pnpm через `pnpm.cmd`, если выполнение `.ps1`-скриптов отключено.
## Быстрый старт ## Быстрый старт
**Требования:** Node.js ≥ 20, pnpm ≥ 9
```bash ```bash
# 1. Установить зависимости
pnpm install pnpm install
# 2. Запустить dev-сервер
pnpm dev pnpm dev
# 3. Открыть в браузере
# http://localhost:5173
``` ```
Остальные команды: Локальный адрес по умолчанию:
```text
http://localhost:5173
```
## Команды
```bash ```bash
pnpm build # Сборка для продакшена (папка build/) pnpm format # автоформатирование
pnpm preview # Превью продакшен-сборки pnpm format:check # проверка форматирования
pnpm check # Svelte + TypeScript проверка типов pnpm lint # ESLint
pnpm lint # ESLint + Prettier проверка pnpm check # Svelte + TypeScript
pnpm format # Автоформатирование pnpm build # production build
pnpm preview # preview production build
pnpm audit # аудит зависимостей
``` ```
--- `pnpm outdated` может показывать `@types/node` 26.x и TypeScript 7.x. Это не ошибка текущего стека: проект закреплён на Node 24 LTS, а `@sveltejs/kit` и `typescript-eslint` на текущих версиях требуют TypeScript `<6.1`.
## Структура проекта ## Конфигурация
``` Основные изменяемые значения вынесены в `.env.example`.
src/
├── lib/ Публичные значения сайта:
│ ├── components/
│ │ ├── Chart.svelte # lightweight-charts обёртка ```bash
│ │ ├── home/ PUBLIC_SITE_URL=https://trade.flamy.studio
│ │ │ ├── DashboardMockup.svelte # 3D-макет на главной PUBLIC_SITE_NAME=Flamy Trade
│ │ │ └── DashboardStatus.svelte PUBLIC_SITE_DESCRIPTION=Публичная витрина ML-прогнозов для крипторынка.
│ │ ├── layout/
│ │ │ ├── Header.svelte # Фиксированная шапка + мобильное меню
│ │ │ └── Footer.svelte
│ │ └── ui/
│ │ ├── Button.svelte # Универсальная кнопка/ссылка
│ │ └── Logo.svelte
│ ├── blog/
│ │ └── posts.ts # Статьи блога (статические данные)
│ ├── stores/
│ │ └── chartStore.ts # Типы + генератор фейковых данных
│ ├── utils.ts # cn() — утилита для className
│ └── index.ts
├── routes/
│ ├── +layout.svelte # Корневой layout: Header, Footer, шрифты
│ ├── +page.svelte # Главная страница (секции)
│ ├── +error.svelte # Страница ошибки
│ ├── (sections)/ # Секции главной страницы
│ │ ├── Hero.svelte
│ │ ├── Ticker.svelte
│ │ ├── About.svelte
│ │ ├── Features.svelte
│ │ ├── Stats.svelte
│ │ ├── BlogPreview.svelte
│ │ └── Cta.svelte
│ ├── about/
│ │ ├── +page.svelte
│ │ └── _data.ts
│ ├── blog/
│ │ ├── +page.svelte # Список статей
│ │ └── [slug]/
│ │ ├── +page.ts # Загрузка статьи по slug
│ │ └── +page.svelte # Статья
│ └── dashboard/
│ └── +page.svelte # Интерактивный дашборд с графиком
└── routes/layout.css # Tailwind @theme + глобальные стили
``` ```
--- Runtime-переменные SvelteKit adapter-node:
## Дизайн-система ```bash
HOST=0.0.0.0
### Цвета (`layout.css`) PORT=3000
ORIGIN=https://trade.flamy.studio
```css BODY_SIZE_LIMIT=1M
--color-primary: #fe4b07 /* Акцент — оранжевый */ SHUTDOWN_TIMEOUT=15
--color-primary-h:#c83e06 /* Hover-состояние */
--color-bg: #09080a /* Фон страницы */
--color-bg-e: #0f0e10 /* Elevated (шапка, карточки) */
--color-bg-c: #141318 /* Card background */
--color-bg-h: #1a191e /* Hover / бордеры */
--color-title: #f0ede6 /* Основной текст */
--color-desc: #8a887f /* Вторичный текст */
``` ```
В Tailwind используются как `bg-primary`, `text-desc`, `border-bg-h` и т.д. Docker Compose:
### Шрифты ```bash
APP_HOST_IP=10.20.0.20
| Переменная | Семейство | Применение | APP_PUBLISHED_PORT=18082
|---|---|---| APP_IMAGE_TAG=<git-sha>
| `font-display` | Unbounded | Заголовки, кнопки, тикеры |
| `font-sans` | DM Sans | Основной текст |
| `font-mono` | JetBrains Mono | Цены, метки, метаданные |
Подключаются через Google Fonts в `+layout.svelte`.
### Компонент `Button.svelte`
```svelte
<!-- Первичная (ссылка) -->
<Button href="/dashboard">Открыть дашборд</Button>
<!-- Вторичная (кнопка) -->
<Button variant="secondary" onclick={handler}>Действие</Button>
``` ```
Props: `href?`, `target?`, `variant?: 'primary' | 'secondary'`, `class?` ## Структура
--- ```text
src/lib/blog/posts.ts # статические статьи блога
## Дашборд и Charts src/lib/config/site.ts # единая конфигурация сайта и canonical URL
src/lib/stores/chartStore.ts # типы графика и демонстрационный provider
### Архитектура данных src/lib/components/Chart.svelte # lightweight-charts компонент
src/routes/healthz/+server.ts # health endpoint
``` src/routes/sitemap.xml/+server.ts # sitemap
chartStore.ts → generateChartData(symbolId, timeframeId) docker/nginx/default.conf # project nginx
└── возвращает ChartData docs/deployment.md # runbook развёртывания
├── candles: CandleBar[] // 160 свечей
├── prediction: PredPoint[] // 19 прогнозных точек
├── signal: Signal // direction, confidence, entry/tp/sl
├── currentPrice: number
└── change24h(Pct): number
``` ```
`generateChartData`**заглушка с детерминированными фейковыми данными**. При подключении реального бекенда её нужно заменить на API-запрос, сохранив возвращаемые типы: ## Данные дашборда
```typescript Текущий provider в `chartStore.ts` генерирует демонстрационные данные. Он не является реальным торговым API и не меняет контракт будущего backend.
// chartStore.ts — заменить эту функцию на реальный запрос:
export async function fetchChartData(symbolId: string, tfId: TimeframeId): Promise<ChartData> {
const res = await fetch(`/api/chart/${symbolId}?tf=${tfId}`);
return res.json();
}
```
### Компонент `Chart.svelte` Ожидаемая форма `ChartData`:
```svelte
<Chart data={chartData} decimals={2} height={520} />
```
| Prop | Тип | Описание |
|---|---|---|
| `data` | `ChartData` | Свечи + прогноз + сигнал |
| `decimals` | `number` | Кол-во знаков после запятой для цены |
| `height` | `number` | Высота canvas в пикселях (default: 520) |
Что рендерится:
- **CandlestickSeries** — исторические зелёные/красные свечи
- **LineSeries** — пунктирная оранжевая линия ML-прогноза (19 свечей вперёд)
- **PriceLine** × 3 — горизонтальные линии Entry / TP / SL с подписями на шкале
- **OHLC тултип** — кастомный HTML-оверлей при наведении (показывает O/H/L/C и ML-значение)
- **ResizeObserver** — автоматически подстраивает ширину и высоту при изменении окна
### Таймфреймы и символы
Определены в `chartStore.ts`:
```typescript
SYMBOLS // 8 монет: BTC ETH SOL BNB XRP ADA DOGE LINK
TIMEFRAMES // 6 таймфреймов: 1m 5m 15m 1h 4h 1d
```
---
## Контракт API (для бекенда)
Дашборд ожидает от бекенда следующую форму ответа:
```typescript ```typescript
type ChartData = { type ChartData = {
candles: Array<{ candles: Array<{ time: number; open: number; high: number; low: number; close: number }>;
time: number; // Unix timestamp (UTC seconds) prediction: Array<{ time: number; value: number }>;
open: number;
high: number;
low: number;
close: number;
}>;
prediction: Array<{
time: number; // Unix timestamp будущих свечей
value: number; // Прогнозная цена (mid/close)
}>;
signal: { signal: {
direction: 'long' | 'short'; direction: 'long' | 'short';
confidence: number; // 0100 confidence: number;
entry: number; entry: number;
tp: number; tp: number;
sl: number; sl: number;
@@ -220,97 +106,28 @@ type ChartData = {
}; };
``` ```
Эндпоинт (предполагаемый):
```
GET /api/chart/:symbol?tf=5m
```
---
## Блог ## Блог
Статьи хранятся в `src/lib/blog/posts.ts` как статический массив `Post[]`. Статьи хранятся как типизированные блоки, а не как HTML-строки. Это позволяет рендерить контент без `{@html}` и без отдельной санитаризации.
```typescript ## Production
type Post = {
id: number; Production-сборка рассчитана на схему:
slug: string; // URL: /blog/:slug
date: string; // ISO 8601 ```text
title: string; Internet -> Caddy -> 10.20.0.20:18082 -> nginx -> app:3000
description: string;
tags: string[];
content: string; // HTML-строка
};
``` ```
Для подключения CMS — заменить `posts` на API-запрос в `+page.ts` / `+page.server.ts`. Подробный порядок развёртывания, Caddy block, smoke-check и rollback описаны в [docs/deployment.md](docs/deployment.md).
--- ## Что не входит в текущий этап
## Конфигурация Пока не подключаются:
### SvelteKit (`svelte.config.js`) - ML-runtime;
- реальный API;
- Адаптер: `adapter-node` (деплой как Node.js-сервер) - S3;
- Runes: принудительно включены для всех файлов вне `node_modules` - база данных;
- Redis;
### Tailwind v4 (`layout.css`) - Telegram egress;
- публикация ML API через Caddy.
Tailwind подключается через Vite-плагин (`@tailwindcss/vite`), конфиг CSS-первый — все кастомные токены в блоке `@theme` в `src/routes/layout.css`.
**Добавить новый токен:**
```css
/* layout.css */
@theme {
--color-accent: #your-color;
}
```
После этого доступен как `bg-accent`, `text-accent` и т.д.
---
## Деплой
Сборка производит Node.js-сервер:
```bash
pnpm build
node build/index.js
```
Переменные окружения:
```
PORT=3000 # порт (default: 3000)
HOST=0.0.0.0 # хост
ORIGIN=https://... # обязательно при deploy за проксей
```
Docker (минимальный `Dockerfile`):
```dockerfile
FROM node:22-alpine
WORKDIR /app
COPY build/ ./build/
COPY package.json .
RUN npm install --omit=dev --ignore-scripts
CMD ["node", "build/index.js"]
```
---
## Соглашения по коду
- **Svelte 5 Runes** везде: `$state`, `$derived`, `$effect`, `$props` — никакого legacy API
- **Нет `export let`** — только деструктуризация `$props()`
- **Tailwind-first** — inline-стили только там, где Tailwind не справляется (динамические значения, chart dimensions)
- **cn()** из `$lib/utils` для условного объединения классов
- **Нет комментариев** если смысл очевиден из имён; комментарий — только для неочевидного инварианта
- **SSR-safe** — всё что требует `window`/`document` — только в `onMount`
- **Cleanup** — каждый `onMount` возвращает функцию очистки (removeEventListener, disconnect, remove)
---
## Известные ограничения
- Данные в дашборде — **фейковые** (детерминированный генератор). Требует подключения реального API бекенда через замену `generateChartData` в `chartStore.ts`
- Прогнозная линия — условная визуализация; реальная модель возвращает `prediction[]` из бекенда
- Блог — статические данные в коде; для production рекомендуется CMS или headless API
+100
View File
@@ -0,0 +1,100 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
args:
BUILD_DATE: ${BUILD_DATE:-}
VCS_REF: ${VCS_REF:-}
image: flamy-trade:${APP_IMAGE_TAG:-local}
restart: unless-stopped
init: true
read_only: true
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 3000
ORIGIN: ${ORIGIN:-https://trade.flamy.studio}
BODY_SIZE_LIMIT: ${BODY_SIZE_LIMIT:-1M}
SHUTDOWN_TIMEOUT: ${SHUTDOWN_TIMEOUT:-15}
PUBLIC_SITE_URL: ${PUBLIC_SITE_URL:-https://trade.flamy.studio}
PUBLIC_SITE_NAME: ${PUBLIC_SITE_NAME:-Flamy Trade}
PUBLIC_SITE_DESCRIPTION: ${PUBLIC_SITE_DESCRIPTION:-Публичная витрина ML-прогнозов для крипторынка.}
expose:
- '3000'
networks:
- backend
cpus: 0.50
mem_limit: 384m
mem_reservation: 128m
memswap_limit: 384m
pids_limit: 128
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
tmpfs:
- /tmp:size=32m,noexec,nosuid,nodev
healthcheck:
test:
[
'CMD-SHELL',
'node -e "fetch(''http://127.0.0.1:3000/healthz'').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"'
]
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
logging:
driver: json-file
options:
max-size: 10m
max-file: '3'
nginx:
image: nginx:1.30.4-alpine@sha256:97d490c12ba55b4946b01546d1c3ed324e8d41ab1c9fcb2a616aa470620e5b46
restart: unless-stopped
init: true
read_only: true
depends_on:
app:
condition: service_healthy
ports:
- name: http
target: 8080
published: '${APP_PUBLISHED_PORT:-18082}'
host_ip: '${APP_HOST_IP:-10.20.0.20}'
protocol: tcp
app_protocol: http
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
networks:
- backend
cpus: 0.10
mem_limit: 64m
mem_reservation: 16m
memswap_limit: 64m
pids_limit: 64
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
tmpfs:
- /var/cache/nginx:size=32m,nosuid,nodev
- /var/run:size=8m,nosuid,nodev
- /tmp:size=16m,noexec,nosuid,nodev
healthcheck:
test: ['CMD-SHELL', 'wget -qO- http://127.0.0.1:8080/healthz >/dev/null || exit 1']
interval: 30s
timeout: 5s
retries: 3
start_period: 20s
logging:
driver: json-file
options:
max-size: 10m
max-file: '3'
networks:
backend:
internal: true
+51
View File
@@ -0,0 +1,51 @@
map $http_x_forwarded_proto $forwarded_proto {
default $http_x_forwarded_proto;
"" $scheme;
}
server {
listen 8080;
server_name _;
client_max_body_size 1m;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'self'; base-uri 'self'; form-action 'self'" always;
location /_app/immutable/ {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
location ~* \.(?:css|js|mjs|svg|png|jpg|jpeg|gif|webp|ico|ttf|woff2?)$ {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
add_header Cache-Control "public, max-age=86400" always;
}
location / {
proxy_pass http://app:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $forwarded_proto;
proxy_set_header Connection "";
add_header Cache-Control "no-store" always;
}
}
+134
View File
@@ -0,0 +1,134 @@
# Развёртывание Flamy Trade
Документ описывает production-развёртывание сайта `trade.flamy.studio` без ML-runtime и без будущего API. Эти части подключаются отдельно.
## Схема
```text
Internet
-> Router :80/:443
-> edge-proxy VM / Caddy
-> 10.20.0.20:18082
-> flamy_trade_nginx
-> flamy_trade_app:3000
```
На VM публикуется только nginx. Node-приложение доступно только во внутренней Docker-сети.
## Переменные
Скопируйте `.env.example` в `.env` на сервере и проверьте значения:
```bash
PUBLIC_SITE_URL=https://trade.flamy.studio
PUBLIC_SITE_NAME=Flamy Trade
PUBLIC_SITE_DESCRIPTION=Публичная витрина ML-прогнозов для крипторынка.
HOST=0.0.0.0
PORT=3000
ORIGIN=https://trade.flamy.studio
BODY_SIZE_LIMIT=1M
SHUTDOWN_TIMEOUT=15
APP_HOST_IP=10.20.0.20
APP_PUBLISHED_PORT=18082
APP_IMAGE_TAG=<git-sha>
```
Перед фиксацией порта проверьте, что `18082` свободен:
```bash
sudo ss -lntup | grep ':18082'
docker ps --format 'table {{.Names}}\t{{.Ports}}'
```
## Сборка
```bash
pnpm install --frozen-lockfile
pnpm format:check
pnpm lint
pnpm check
pnpm build
pnpm audit
```
`pnpm outdated` должен проверяться вручную. На текущем стеке допустимы только осознанные удержания: `@types/node` на ветке 24 под Node 24 LTS и TypeScript 6.x до обновления peer constraints в SvelteKit/typescript-eslint.
Docker:
```bash
docker compose config
docker compose build --pull
docker compose up -d --wait
docker compose ps
docker compose logs --tail=200
```
## Caddy
Блок для edge-proxy:
```caddyfile
trade.flamy.studio {
encode zstd gzip
reverse_proxy 10.20.0.20:18082 {
header_up Host {host}
header_up X-Real-IP {remote_host}
}
}
```
Перед reload:
```bash
sudo cp -a /etc/caddy/Caddyfile /etc/caddy/Caddyfile.bak.$(date +%F_%H%M%S)
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
## Smoke-check
С docker-runtime:
```bash
curl -fsS http://10.20.0.20:18082/healthz
curl -I http://10.20.0.20:18082/
```
Снаружи:
```bash
curl -I http://trade.flamy.studio
curl -I https://trade.flamy.studio
curl -fsS https://trade.flamy.studio/healthz
```
Маршруты:
```text
/ 200
/about 200
/blog 200
/blog/osnovy-upravleniya-riskami 200
/dashboard 200
/healthz 200
/favicon.svg 200
/robots.txt 200
/sitemap.xml 200
/nesuschestvuyuschiy-url 404
```
## Rollback
1. Каждый image получает тег git SHA через `APP_IMAGE_TAG`.
2. Предыдущий image не удаляется до успешного smoke-check.
3. Для отката поменяйте `APP_IMAGE_TAG` на предыдущий SHA.
4. Выполните:
```bash
docker compose up -d --no-build
```
Если проблема в маршрутизации, восстановите backup Caddyfile и выполните `sudo systemctl reload caddy`.
+31 -25
View File
@@ -3,6 +3,11 @@
"private": true, "private": true,
"version": "0.0.1", "version": "0.0.1",
"type": "module", "type": "module",
"packageManager": "pnpm@11.15.1",
"engines": {
"node": ">=24.0.0 <25",
"pnpm": ">=11.15.1 <12"
},
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
"build": "vite build", "build": "vite build",
@@ -10,35 +15,36 @@
"prepare": "svelte-kit sync || echo ''", "prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch", "check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check . && eslint .", "format:check": "prettier --check .",
"lint": "eslint .",
"format": "prettier --write ." "format": "prettier --write ."
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "^2.0.4", "@eslint/compat": "2.1.0",
"@eslint/js": "^10.0.1", "@eslint/js": "10.0.1",
"@sveltejs/adapter-node": "^5.5.4", "@sveltejs/adapter-node": "5.5.7",
"@sveltejs/kit": "^2.57.0", "@sveltejs/vite-plugin-svelte": "7.2.0",
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@tailwindcss/vite": "4.3.3",
"@tailwindcss/vite": "^4.2.2", "@types/node": "24.12.4",
"@types/node": "^24", "eslint": "10.7.0",
"eslint": "^10.2.0", "eslint-config-prettier": "10.1.8",
"eslint-config-prettier": "^10.1.8", "eslint-plugin-svelte": "3.21.0",
"eslint-plugin-svelte": "^3.17.0", "globals": "17.7.0",
"globals": "^17.4.0", "prettier": "3.9.5",
"prettier": "^3.8.1", "prettier-plugin-svelte": "4.1.1",
"prettier-plugin-svelte": "^3.5.1", "prettier-plugin-tailwindcss": "0.8.1",
"prettier-plugin-tailwindcss": "^0.7.2", "svelte": "5.56.6",
"svelte": "^5.55.2", "svelte-check": "4.7.3",
"svelte-check": "^4.4.6", "tailwindcss": "4.3.3",
"tailwindcss": "^4.2.2", "typescript": "6.0.3",
"typescript": "^6.0.2", "typescript-eslint": "8.64.0",
"typescript-eslint": "^8.58.1", "vite": "8.1.5"
"vite": "^8.0.7"
}, },
"dependencies": { "dependencies": {
"clsx": "^2.1.1", "@sveltejs/kit": "2.70.1",
"gsap": "^3.15.0", "clsx": "2.1.1",
"lightweight-charts": "^5.2.0", "gsap": "3.15.0",
"tailwind-merge": "^3.6.0" "lightweight-charts": "5.2.0",
"tailwind-merge": "3.6.0"
} }
} }
+436 -356
View File
File diff suppressed because it is too large Load Diff
+10
View File
@@ -1,3 +1,13 @@
packages:
- .
minimumReleaseAgeExclude:
- '@sveltejs/kit@2.70.1'
- eslint-plugin-svelte@3.21.0
overrides:
cookie: 0.7.2
onlyBuiltDependencies: onlyBuiltDependencies:
- '@tailwindcss/oxide' - '@tailwindcss/oxide'
- esbuild - esbuild
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"dependencyDashboard": true,
"labels": ["dependencies"],
"packageRules": [
{
"matchManagers": ["npm"],
"rangeStrategy": "pin"
},
{
"matchDatasources": ["docker"],
"pinDigests": true
}
]
}
+1 -2
View File
@@ -1,9 +1,8 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="ru">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover"> <body data-sveltekit-preload-data="hover">
+85 -36
View File
@@ -1,11 +1,25 @@
export type Post = { export type PostContentBlock =
| {
type: 'paragraph';
text: string;
}
| {
type: 'heading';
text: string;
}
| {
type: 'list';
items: string[];
};
export type Post = {
id: number; id: number;
slug: string; slug: string;
date: string; date: string;
title: string; title: string;
description: string; description: string;
tags: string[]; tags: string[];
content: string; content: PostContentBlock[];
}; };
export const posts: Post[] = [ export const posts: Post[] = [
@@ -17,47 +31,82 @@ export const posts: Post[] = [
description: description:
'Как использовать уровни Stop Loss и Take Profit для грамотного управления капиталом при работе с прогнозами.', 'Как использовать уровни Stop Loss и Take Profit для грамотного управления капиталом при работе с прогнозами.',
tags: ['RISK', 'TRADING', 'EDUCATION'], tags: ['RISK', 'TRADING', 'EDUCATION'],
content: ` content: [
<p>Управление рисками — фундамент любой торговой стратегии. Без него даже точный ML-прогноз не поможет сохранить капитал.</p> {
<h2>Stop Loss: где ваша позиция неправа</h2> type: 'paragraph',
<p>Stop Loss — уровень, при достижении которого позиция закрывается автоматически. Это не признание ошибки, а часть стратегии. На графиках Flamy AI уровень SL рассчитывается моделью на основе исторической волатильности инструмента.</p> text: 'Управление рисками — фундамент любой торговой стратегии. Без него даже точный ML-прогноз не поможет сохранить капитал.'
<h2>Take Profit: когда забирать прибыль</h2> },
<p>Take Profit — целевой уровень закрытия позиции с прибылью. TP рассчитывается пропорционально прогнозному движению с учётом Risk:Reward ratio.</p> { type: 'heading', text: 'Stop Loss: где ваша позиция неправа' },
<h2>Risk:Reward Ratio</h2> {
<p>Оптимальное соотношение риска к доходности — не менее <strong>1:2</strong>. Это означает, что потенциальная прибыль должна минимум вдвое превышать риск.</p> type: 'paragraph',
<ul> text: 'Stop Loss — уровень, при достижении которого позиция закрывается автоматически. Это не признание ошибки, а часть стратегии. На графиках Flamy AI уровень SL рассчитывается моделью на основе исторической волатильности инструмента.'
<li>SL: не более 1% от капитала на сделку</li> },
<li>TP: 2% и выше от точки входа</li> { type: 'heading', text: 'Take Profit: когда забирать прибыль' },
<li>Одна сделка: не более 2% депозита</li> {
</ul> type: 'paragraph',
<h2>Почему это важно при работе с ML-прогнозами</h2> text: 'Take Profit — целевой уровень закрытия позиции с прибылью. TP рассчитывается пропорционально прогнозному движению с учётом Risk:Reward ratio.'
<p>ML-модель не даёт гарантий. Уверенность 80% означает, что 20% прогнозов будут неточными. Управление рисками — страховка на эти 20%.</p> },
` { type: 'heading', text: 'Risk:Reward Ratio' },
{
type: 'paragraph',
text: 'Оптимальное соотношение риска к доходности — не менее 1:2. Это означает, что потенциальная прибыль должна минимум вдвое превышать риск.'
},
{
type: 'list',
items: [
'SL: не более 1% от капитала на сделку',
'TP: 2% и выше от точки входа',
'Одна сделка: не более 2% депозита'
]
},
{ type: 'heading', text: 'Почему это важно при работе с ML-прогнозами' },
{
type: 'paragraph',
text: 'ML-модель не даёт гарантий. Уверенность 80% означает, что 20% прогнозов будут неточными. Управление рисками — страховка на эти 20%.'
}
]
}, },
{ {
id: 2, id: 2,
slug: 'kak-chitat-grafiki-flamy-ai', slug: 'kak-chitat-grafiki-flamy-ai',
date: '2026-05-17', date: '2026-05-17',
title: 'Как читать графики Flamy AI', title: 'Как читать графики Flamy AI',
description: description: 'Краткое объяснение свечей, прогнозной зоны, TP и SL на графиках Flamy AI.',
'Краткое объяснение свечей, прогнозной зоны, TP и SL на графиках Flamy AI.',
tags: ['ML', 'FORECAST', 'CHARTS'], tags: ['ML', 'FORECAST', 'CHARTS'],
content: ` content: [
<p>Графики Flamy AI содержат три слоя: исторические свечи, прогнозную зону и уровни торгового плана. Разберём каждый из них.</p> {
<h2>Исторические свечи</h2> type: 'paragraph',
<p>Левая часть графика — реальные OHLC-свечи. Зелёные — бычьи (close &gt; open), красные — медвежьи. Это основа для анализа модели.</p> text: 'Графики Flamy AI содержат три слоя: исторические свечи, прогнозную зону и уровни торгового плана. Разберём каждый из них.'
<h2>Прогнозная зона</h2> },
<p>Правая часть — прогнозные свечи на горизонт <strong>19 периодов</strong> вперёд. Это не точные значения, а вероятностный коридор движения цены на ближайшие 95 минут.</p> { type: 'heading', text: 'Исторические свечи' },
<h2>Уровни Entry, TP и SL</h2> {
<p>Если модель публикует режим TRADE_PLAN, на графике появляются три горизонтальные линии:</p> type: 'paragraph',
<ul> text: 'Левая часть графика — реальные OHLC-свечи. Зелёные — бычьи, красные — медвежьи. Это основа для анализа модели.'
<li><strong>Entry</strong> — рекомендуемый уровень входа в позицию</li> },
<li><strong>TP</strong> — цель Take Profit</li> { type: 'heading', text: 'Прогнозная зона' },
<li><strong>SL</strong> — Stop Loss для ограничения убытков</li> {
</ul> type: 'paragraph',
<h2>Таймфрейм и горизонт</h2> text: 'Правая часть — прогнозные свечи на горизонт 19 периодов вперёд. Это не точные значения, а вероятностный коридор движения цены на ближайшие 95 минут.'
<p>Все графики работают на таймфрейме <strong>5 минут</strong>. Горизонт прогноза — 19 свечей = 95 минут вперёд. Прогнозы обновляются каждый час автоматически.</p> },
` { type: 'heading', text: 'Уровни Entry, TP и SL' },
{
type: 'paragraph',
text: 'Если модель публикует режим TRADE_PLAN, на графике появляются три горизонтальные линии.'
},
{
type: 'list',
items: [
'Entry — рекомендуемый уровень входа в позицию',
'TP — цель Take Profit',
'SL — Stop Loss для ограничения убытков'
]
},
{ type: 'heading', text: 'Таймфрейм и горизонт' },
{
type: 'paragraph',
text: 'Все графики работают на таймфрейме 5 минут. Горизонт прогноза — 19 свечей = 95 минут вперёд. Прогнозы обновляются каждый час автоматически.'
}
]
} }
]; ];
+31 -26
View File
@@ -9,7 +9,7 @@
type IChartApi, type IChartApi,
type ISeriesApi, type ISeriesApi,
type MouseEventParams, type MouseEventParams,
type UTCTimestamp, type UTCTimestamp
} from 'lightweight-charts'; } from 'lightweight-charts';
import type { ChartData, CandleBar } from '$lib/stores/chartStore'; import type { ChartData, CandleBar } from '$lib/stores/chartStore';
@@ -50,7 +50,7 @@
high: 0, high: 0,
low: 0, low: 0,
close: 0, close: 0,
isUp: true, isUp: true
}); });
function fmt(n: number): string { function fmt(n: number): string {
@@ -75,7 +75,7 @@
lineWidth: 1, lineWidth: 1,
lineStyle: LineStyle.Solid, lineStyle: LineStyle.Solid,
axisLabelVisible: true, axisLabelVisible: true,
title: 'Entry', title: 'Entry'
}), }),
candleSeries.createPriceLine({ candleSeries.createPriceLine({
price: d.signal.tp, price: d.signal.tp,
@@ -83,7 +83,7 @@
lineWidth: 1, lineWidth: 1,
lineStyle: LineStyle.Dashed, lineStyle: LineStyle.Dashed,
axisLabelVisible: true, axisLabelVisible: true,
title: 'TP', title: 'TP'
}), }),
candleSeries.createPriceLine({ candleSeries.createPriceLine({
price: d.signal.sl, price: d.signal.sl,
@@ -91,8 +91,8 @@
lineWidth: 1, lineWidth: 1,
lineStyle: LineStyle.Dashed, lineStyle: LineStyle.Dashed,
axisLabelVisible: true, axisLabelVisible: true,
title: 'SL', title: 'SL'
}), })
]; ];
chart.timeScale().fitContent(); chart.timeScale().fitContent();
@@ -107,11 +107,11 @@
textColor: '#8a887f', textColor: '#8a887f',
fontFamily: "'JetBrains Mono', monospace", fontFamily: "'JetBrains Mono', monospace",
fontSize: 11, fontSize: 11,
attributionLogo: false, attributionLogo: false
}, },
grid: { grid: {
vertLines: { color: '#1a191e' }, vertLines: { color: '#1a191e' },
horzLines: { color: '#1a191e' }, horzLines: { color: '#1a191e' }
}, },
crosshair: { crosshair: {
mode: CrosshairMode.Normal, mode: CrosshairMode.Normal,
@@ -119,25 +119,25 @@
color: '#3d3b42', color: '#3d3b42',
labelBackgroundColor: '#1a191e', labelBackgroundColor: '#1a191e',
style: LineStyle.Dashed, style: LineStyle.Dashed,
width: 1, width: 1
}, },
horzLine: { horzLine: {
color: '#3d3b42', color: '#3d3b42',
labelBackgroundColor: '#1a191e', labelBackgroundColor: '#1a191e',
style: LineStyle.Dashed, style: LineStyle.Dashed,
width: 1, width: 1
}, }
}, },
rightPriceScale: { rightPriceScale: {
borderColor: '#1a191e', borderColor: '#1a191e',
scaleMargins: { top: 0.06, bottom: 0.04 }, scaleMargins: { top: 0.06, bottom: 0.04 }
}, },
timeScale: { timeScale: {
borderColor: '#1a191e', borderColor: '#1a191e',
timeVisible: true, timeVisible: true,
secondsVisible: false, secondsVisible: false,
rightOffset: 24, rightOffset: 24
}, }
}); });
candleSeries = chart.addSeries(CandlestickSeries, { candleSeries = chart.addSeries(CandlestickSeries, {
@@ -146,7 +146,7 @@
borderUpColor: '#26a69a', borderUpColor: '#26a69a',
borderDownColor: '#ef5350', borderDownColor: '#ef5350',
wickUpColor: '#26a69a', wickUpColor: '#26a69a',
wickDownColor: '#ef5350', wickDownColor: '#ef5350'
}); });
predSeries = chart.addSeries(LineSeries, { predSeries = chart.addSeries(LineSeries, {
@@ -158,7 +158,7 @@
crosshairMarkerBorderColor: '#fe4b07', crosshairMarkerBorderColor: '#fe4b07',
crosshairMarkerBackgroundColor: '#09080a', crosshairMarkerBackgroundColor: '#09080a',
priceLineVisible: false, priceLineVisible: false,
lastValueVisible: true, lastValueVisible: true
}); });
applyData(data); applyData(data);
@@ -169,17 +169,22 @@
return; return;
} }
const c = param.seriesData.get(candleSeries) as CandleBar; const c = param.seriesData.get(candleSeries) as CandleBar;
if (!c) { tip = { ...tip, visible: false }; return; } if (!c) {
tip = { ...tip, visible: false };
return;
}
const pred = predSeries && param.seriesData.has(predSeries) const pred =
// eslint-disable-next-line @typescript-eslint/no-explicit-any predSeries && param.seriesData.has(predSeries)
? (param.seriesData.get(predSeries) as any)?.value ? (param.seriesData.get(predSeries) as { value?: number })?.value
: undefined; : undefined;
const ts = param.time as number; const ts = param.time as number;
const d = new Date(ts * 1000); const d = new Date(ts * 1000);
const timeStr = d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }) const timeStr =
+ ' ' + d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' }); d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'short' }) +
' ' +
d.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' });
tip = { tip = {
visible: true, visible: true,
@@ -191,14 +196,14 @@
low: c.low, low: c.low,
close: c.close, close: c.close,
isUp: c.close >= c.open, isUp: c.close >= c.open,
pred, pred
}; };
}); });
const ro = new ResizeObserver(() => { const ro = new ResizeObserver(() => {
chart?.applyOptions({ chart?.applyOptions({
width: container.clientWidth, width: container.clientWidth,
height: container.clientHeight, height: container.clientHeight
}); });
}); });
ro.observe(container); ro.observe(container);
@@ -229,7 +234,7 @@
style="left: {left}px; top: {top}px" style="left: {left}px; top: {top}px"
> >
<p class="mb-2 text-[0.5625rem] tracking-widest text-desc uppercase">{tip.time}</p> <p class="mb-2 text-[0.5625rem] tracking-widest text-desc uppercase">{tip.time}</p>
{#each [['O', tip.open], ['H', tip.high], ['L', tip.low], ['C', tip.close]] as [lbl, val]} {#each [['O', tip.open], ['H', tip.high], ['L', tip.low], ['C', tip.close]] as [lbl, val] (lbl)}
<div class="flex justify-between gap-5"> <div class="flex justify-between gap-5">
<span class="text-desc">{lbl}</span> <span class="text-desc">{lbl}</span>
<span class={tip.isUp ? 'text-emerald-400' : 'text-red-400'}>{fmt(val as number)}</span> <span class={tip.isUp ? 'text-emerald-400' : 'text-red-400'}>{fmt(val as number)}</span>
@@ -237,7 +242,7 @@
{/each} {/each}
{#if tip.pred !== undefined} {#if tip.pred !== undefined}
<div class="mt-1.5 flex justify-between gap-5 border-t border-white/8 pt-1.5"> <div class="mt-1.5 flex justify-between gap-5 border-t border-white/8 pt-1.5">
<span class="text-primary text-[0.5625rem] tracking-widest uppercase">ML</span> <span class="text-[0.5625rem] tracking-widest text-primary uppercase">ML</span>
<span class="text-primary">{fmt(tip.pred)}</span> <span class="text-primary">{fmt(tip.pred)}</span>
</div> </div>
{/if} {/if}
@@ -62,7 +62,7 @@
} }
</script> </script>
<div class="w-full max-w-170 z-20 mx-auto xl:mx-0" style="perspective: 1000px;"> <div class="z-20 mx-auto w-full max-w-170 xl:mx-0" style="perspective: 1000px;">
<div <div
class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 shadow-[0_40px_100px_rgba(0,0,0,0.7),0_0_0_1px_rgba(255,255,255,0.04)] will-change-transform" class="overflow-hidden rounded-2xl border border-white/10 bg-zinc-950 shadow-[0_40px_100px_rgba(0,0,0,0.7),0_0_0_1px_rgba(255,255,255,0.04)] will-change-transform"
style="transform: perspective(1000px) rotateY(-6deg) rotateX(3deg); transform-style: preserve-3d;" style="transform: perspective(1000px) rotateY(-6deg) rotateX(3deg); transform-style: preserve-3d;"
@@ -55,7 +55,7 @@
}; };
</script> </script>
<div class="hidden h-full w-full max-w-140 items-center justify-end lg:flex z-20"> <div class="z-20 hidden h-full w-full max-w-140 items-center justify-end lg:flex">
<div <div
class="w-full overflow-hidden rounded-2xl border border-white/8 bg-zinc-950 shadow-[0_0_0_1px_rgba(255,255,255,0.03),0_40px_80px_rgba(0,0,0,0.5),0_0_80px_rgba(255,69,0,0.05)]" class="w-full overflow-hidden rounded-2xl border border-white/8 bg-zinc-950 shadow-[0_0_0_1px_rgba(255,255,255,0.03),0_40px_80px_rgba(0,0,0,0.5),0_0_80px_rgba(255,69,0,0.05)]"
> >
@@ -81,9 +81,7 @@
class="grid grid-cols-[1fr_56px_76px] gap-2.5 border-b border-white/6 bg-white/1 px-4 py-3.5" class="grid grid-cols-[1fr_56px_76px] gap-2.5 border-b border-white/6 bg-white/1 px-4 py-3.5"
> >
<span class="font-mono text-[0.55rem] tracking-widest text-zinc-600">ИНСТРУМЕНТ</span> <span class="font-mono text-[0.55rem] tracking-widest text-zinc-600">ИНСТРУМЕНТ</span>
<span class="text-right font-mono text-[0.55rem] tracking-widest text-zinc-600" <span class="text-right font-mono text-[0.55rem] tracking-widest text-zinc-600">КОНФИД.</span>
>КОНФИД.</span
>
<span class="pl-2 font-mono text-[0.55rem] tracking-widest text-zinc-600">ПРОГНОЗ</span> <span class="pl-2 font-mono text-[0.55rem] tracking-widest text-zinc-600">ПРОГНОЗ</span>
</div> </div>
+3 -3
View File
@@ -12,7 +12,6 @@
<footer class="mt-auto border-t border-white/6 bg-bg-e"> <footer class="mt-auto border-t border-white/6 bg-bg-e">
<div class="mx-auto max-w-400 px-5 py-20 sm:px-10"> <div class="mx-auto max-w-400 px-5 py-20 sm:px-10">
<div class="grid grid-cols-2 gap-10 sm:grid-cols-4 sm:justify-between"> <div class="grid grid-cols-2 gap-10 sm:grid-cols-4 sm:justify-between">
<div class="col-span-2 flex flex-col gap-4 lg:col-span-1"> <div class="col-span-2 flex flex-col gap-4 lg:col-span-1">
<Logo /> <Logo />
@@ -23,7 +22,9 @@
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<span class="font-mono text-base tracking-widest text-zinc-600 uppercase mb-1">Навигация</span> <span class="mb-1 font-mono text-base tracking-widest text-zinc-600 uppercase"
>Навигация</span
>
{#each navLinks as link (link.id)} {#each navLinks as link (link.id)}
<a <a
href={link.href} href={link.href}
@@ -59,6 +60,5 @@
</a> </a>
</span> </span>
</div> </div>
</div> </div>
</footer> </footer>
+3 -3
View File
@@ -21,12 +21,12 @@
opacity: 0, opacity: 0,
duration: 0.5, duration: 0.5,
ease: 'power2.out', ease: 'power2.out',
clearProps: 'transform,opacity', clearProps: 'transform,opacity'
}); });
}); });
</script> </script>
<header bind:this={header} class="fixed right-0 left-0 border-b border-b-bg-h bg-bg-e z-9999"> <header bind:this={header} class="fixed right-0 left-0 z-9999 border-b border-b-bg-h bg-bg-e">
<div class="mx-auto flex max-w-400 items-center justify-between px-5 py-5 sm:px-10 lg:px-5"> <div class="mx-auto flex max-w-400 items-center justify-between px-5 py-5 sm:px-10 lg:px-5">
<Logo /> <Logo />
@@ -60,7 +60,7 @@
<span <span
class={cn( class={cn(
'block h-0.5 w-6 bg-title transition-all duration-200', 'block h-0.5 w-6 bg-title transition-all duration-200',
mobileOpen && 'opacity-0 scale-x-0' mobileOpen && 'scale-x-0 opacity-0'
)} )}
></span> ></span>
<span <span
+2 -2
View File
@@ -19,9 +19,9 @@
{target} {target}
rel={target === '_blank' ? 'noopener noreferrer' : undefined} rel={target === '_blank' ? 'noopener noreferrer' : undefined}
class={cn( class={cn(
'flex items-center justify-center gap-2 w-fit rounded-xl px-6 py-3 font-display text-lg font-medium transition-colors duration-300', 'flex w-fit items-center justify-center gap-2 rounded-xl px-6 py-3 font-display text-lg font-medium transition-colors duration-300',
variant === 'primary' && 'bg-primary hover:bg-primary-h', variant === 'primary' && 'bg-primary hover:bg-primary-h',
variant === 'secondary' && 'bg-bg border hover:text-desc', variant === 'secondary' && 'border bg-bg hover:text-desc',
className className
)} )}
> >
+2 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
</script> </script>
<a href="/" class="flex items-center gap-2.5 no-underline max-w-fit"> <a href="/" class="flex max-w-fit items-center gap-2.5 no-underline">
<img src="images/icons/logo.svg" alt="Логотип" class="w-8 h-8" /> <img src="images/icons/logo.svg" alt="Логотип" class="h-8 w-8" />
<span class="font-display text-xl font-black tracking-tighter"> <span class="font-display text-xl font-black tracking-tighter">
FLAMY<span class="text-primary">TRADE</span> FLAMY<span class="text-primary">TRADE</span>
</span> </span>
+16
View File
@@ -0,0 +1,16 @@
import { env } from '$env/dynamic/public';
export const site = {
name: env.PUBLIC_SITE_NAME || 'Flamy Trade',
url: (env.PUBLIC_SITE_URL || 'https://trade.flamy.studio').replace(/\/$/, ''),
description: env.PUBLIC_SITE_DESCRIPTION || 'Публичная витрина ML-прогнозов для крипторынка.'
};
export function pageTitle(title?: string): string {
return title ? `${title} | ${site.name}` : site.name;
}
export function canonical(path = '/'): string {
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${site.url}${normalizedPath}`;
}
+5 -5
View File
@@ -67,7 +67,7 @@ export function scrollFadeUp(el: HTMLElement, params: FadeUpParams = {}) {
delay, delay,
ease: 'power3.out', ease: 'power3.out',
clearProps: 'transform,opacity', clearProps: 'transform,opacity',
scrollTrigger: { trigger: el, start, once: true }, scrollTrigger: { trigger: el, start, once: true }
}); });
}); });
@@ -93,7 +93,7 @@ export function scrollRevealLeft(el: HTMLElement, params: RevealLeftParams = {})
delay, delay,
ease: 'power3.out', ease: 'power3.out',
clearProps: 'transform,opacity', clearProps: 'transform,opacity',
scrollTrigger: { trigger: el, start, once: true }, scrollTrigger: { trigger: el, start, once: true }
}); });
}); });
@@ -115,7 +115,7 @@ export function scrollStagger(el: HTMLElement, params: StaggerParams = {}) {
duration = 0.65, duration = 0.65,
stagger = 0.1, stagger = 0.1,
start = 'top 80%', start = 'top 80%',
selector = ':scope > *', selector = ':scope > *'
} = params; } = params;
const targets = el.querySelectorAll<HTMLElement>(selector); const targets = el.querySelectorAll<HTMLElement>(selector);
@@ -133,7 +133,7 @@ export function scrollStagger(el: HTMLElement, params: StaggerParams = {}) {
ease: 'power3.out', ease: 'power3.out',
stagger: { each: stagger, ease: 'power1.inOut' }, stagger: { each: stagger, ease: 'power1.inOut' },
clearProps: 'transform,opacity', clearProps: 'transform,opacity',
scrollTrigger: { trigger: el, start, once: true }, scrollTrigger: { trigger: el, start, once: true }
}); });
}); });
@@ -160,7 +160,7 @@ export function scrollScaleIn(el: HTMLElement, params: { delay?: number; start?:
delay, delay,
ease: 'power3.out', ease: 'power3.out',
clearProps: 'transform,opacity', clearProps: 'transform,opacity',
scrollTrigger: { trigger: el, start, once: true }, scrollTrigger: { trigger: el, start, once: true }
}); });
}); });
+18 -11
View File
@@ -39,7 +39,7 @@ export const SYMBOLS = [
{ id: 'XRPUSDT', label: 'XRP/USDT', short: 'XRP', base: 0.55, dec: 5 }, { id: 'XRPUSDT', label: 'XRP/USDT', short: 'XRP', base: 0.55, dec: 5 },
{ id: 'ADAUSDT', label: 'ADA/USDT', short: 'ADA', base: 0.43, dec: 5 }, { id: 'ADAUSDT', label: 'ADA/USDT', short: 'ADA', base: 0.43, dec: 5 },
{ id: 'DOGEUSDT', label: 'DOGE/USDT', short: 'DOGE', base: 0.131, dec: 5 }, { id: 'DOGEUSDT', label: 'DOGE/USDT', short: 'DOGE', base: 0.131, dec: 5 },
{ id: 'LINKUSDT', label: 'LINK/USDT', short: 'LINK', base: 18.5, dec: 3 }, { id: 'LINKUSDT', label: 'LINK/USDT', short: 'LINK', base: 18.5, dec: 3 }
] as const; ] as const;
export const TIMEFRAMES = [ export const TIMEFRAMES = [
@@ -48,7 +48,7 @@ export const TIMEFRAMES = [
{ id: '15m' as TimeframeId, label: '15м', sec: 900 }, { id: '15m' as TimeframeId, label: '15м', sec: 900 },
{ id: '1h' as TimeframeId, label: '1ч', sec: 3600 }, { id: '1h' as TimeframeId, label: '1ч', sec: 3600 },
{ id: '4h' as TimeframeId, label: '4ч', sec: 14400 }, { id: '4h' as TimeframeId, label: '4ч', sec: 14400 },
{ id: '1d' as TimeframeId, label: '1д', sec: 86400 }, { id: '1d' as TimeframeId, label: '1д', sec: 86400 }
]; ];
// Simple LCG PRNG — deterministic per seed so switching back gives the same chart // Simple LCG PRNG — deterministic per seed so switching back gives the same chart
@@ -91,7 +91,13 @@ export function generateChartData(symbolId: string, tfId: TimeframeId): ChartDat
const low = Math.min(open, close) - bodyH * wk * 0.7 - rand() * vol * 0.2; const low = Math.min(open, close) - bodyH * wk * 0.7 - rand() * vol * 0.2;
const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8))); const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8)));
candles.push({ time: startTime + i * tf.sec, open: round(open), high: round(high), low: round(low), close: round(close) }); candles.push({
time: startTime + i * tf.sec,
open: round(open),
high: round(high),
low: round(low),
close: round(close)
});
price = close; price = close;
} }
@@ -107,15 +113,12 @@ export function generateChartData(symbolId: string, tfId: TimeframeId): ChartDat
const predDir = rand() > 0.42 ? 1 : -1; const predDir = rand() > 0.42 ? 1 : -1;
const predStrength = (0.006 + rand() * 0.018) * sym.base; const predStrength = (0.006 + rand() * 0.018) * sym.base;
const prediction: PredPoint[] = [ const prediction: PredPoint[] = [{ time: candles[HISTORY - 1].time, value: currentPrice }];
{ time: candles[HISTORY - 1].time, value: currentPrice },
];
let pPrice = currentPrice;
for (let i = 1; i <= HORIZON; i++) { for (let i = 1; i <= HORIZON; i++) {
const t = i / HORIZON; const t = i / HORIZON;
const noise = (rand() - 0.5) * vol * 0.6; const noise = (rand() - 0.5) * vol * 0.6;
pPrice = currentPrice + predDir * predStrength * t + noise; const pPrice = currentPrice + predDir * predStrength * t + noise;
const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8))); const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8)));
prediction.push({ time: now + i * tf.sec, value: round(Math.max(pPrice, currentPrice * 0.1)) }); prediction.push({ time: now + i * tf.sec, value: round(Math.max(pPrice, currentPrice * 0.1)) });
} }
@@ -127,8 +130,12 @@ export function generateChartData(symbolId: string, tfId: TimeframeId): ChartDat
const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8))); const round = (n: number) => parseFloat(n.toFixed(Math.min(sym.dec + 2, 8)));
const entry = round(currentPrice); const entry = round(currentPrice);
const tp = round(direction === 'long' ? currentPrice + predRange * 1.3 : currentPrice - predRange * 1.3); const tp = round(
const sl = round(direction === 'long' ? currentPrice - predRange * 0.65 : currentPrice + predRange * 0.65); direction === 'long' ? currentPrice + predRange * 1.3 : currentPrice - predRange * 1.3
);
const sl = round(
direction === 'long' ? currentPrice - predRange * 0.65 : currentPrice + predRange * 0.65
);
return { return {
candles, candles,
@@ -136,6 +143,6 @@ export function generateChartData(symbolId: string, tfId: TimeframeId): ChartDat
signal: { direction, confidence, entry, tp, sl }, signal: { direction, confidence, entry, tp, sl },
currentPrice, currentPrice,
change24h, change24h,
change24hPct, change24hPct
}; };
} }
+3 -4
View File
@@ -11,10 +11,9 @@
lg:h-[70vh] lg:flex-row lg:items-center lg:justify-center lg:gap-40 lg:py-0" lg:h-[70vh] lg:flex-row lg:items-center lg:justify-center lg:gap-40 lg:py-0"
> >
<div> <div>
<h2 <h2 use:scrollRevealLeft class="font-display text-4xl font-bold sm:text-5xl lg:text-7xl">
use:scrollRevealLeft Дашборд
class="font-display text-4xl font-bold sm:text-5xl lg:text-7xl" </h2>
>Дашборд</h2>
<p <p
use:scrollFadeUp={{ delay: 0.1 }} use:scrollFadeUp={{ delay: 0.1 }}
+2 -5
View File
@@ -27,10 +27,7 @@
{#if posts.length > 0} {#if posts.length > 0}
<div class="border-b border-white/6"> <div class="border-b border-white/6">
<section class="px-5 py-24 sm:px-10 sm:py-32"> <section class="px-5 py-24 sm:px-10 sm:py-32">
<div <div use:scrollFadeUp={{ y: 20 }} class="mb-12 flex items-end justify-between">
use:scrollFadeUp={{ y: 20 }}
class="mb-12 flex items-end justify-between"
>
<h2 class="font-display text-4xl font-black tracking-tighter uppercase">БЛОГ</h2> <h2 class="font-display text-4xl font-black tracking-tighter uppercase">БЛОГ</h2>
<a <a
href="/blog" href="/blog"
@@ -44,7 +41,7 @@
use:scrollStagger={{ selector: 'a', stagger: 0.1, y: 32 }} use:scrollStagger={{ selector: 'a', stagger: 0.1, y: 32 }}
class="grid grid-cols-1 overflow-hidden rounded-xl border border-white/6 sm:grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]" class="grid grid-cols-1 overflow-hidden rounded-xl border border-white/6 sm:grid-cols-[repeat(auto-fit,minmax(20rem,1fr))]"
> >
{#each posts as post, i} {#each posts as post, i (post.slug)}
<a <a
href="/blog/{post.slug}" href="/blog/{post.slug}"
class="flex flex-col gap-5 p-10 no-underline transition-colors duration-150 hover:bg-white/2 class="flex flex-col gap-5 p-10 no-underline transition-colors duration-150 hover:bg-white/2
+15 -8
View File
@@ -19,28 +19,35 @@
if (heading) { if (heading) {
gsap.set(heading, { opacity: 0, scale: 0.88, y: 32 }); gsap.set(heading, { opacity: 0, scale: 0.88, y: 32 });
gsap.to(heading, { gsap.to(heading, {
opacity: 1, scale: 1, y: 0, opacity: 1,
duration: 0.8, ease: 'power3.out', scale: 1,
y: 0,
duration: 0.8,
ease: 'power3.out',
clearProps: 'transform,opacity', clearProps: 'transform,opacity',
scrollTrigger: { trigger: section, start: 'top 75%', once: true }, scrollTrigger: { trigger: section, start: 'top 75%', once: true }
}); });
} }
if (btnWrap) { if (btnWrap) {
gsap.set(btnWrap, { opacity: 0, y: 20 }); gsap.set(btnWrap, { opacity: 0, y: 20 });
gsap.to(btnWrap, { gsap.to(btnWrap, {
opacity: 1, y: 0, opacity: 1,
duration: 0.6, delay: 0.2, ease: 'power3.out', y: 0,
duration: 0.6,
delay: 0.2,
ease: 'power3.out',
clearProps: 'transform,opacity', clearProps: 'transform,opacity',
scrollTrigger: { trigger: section, start: 'top 75%', once: true }, scrollTrigger: { trigger: section, start: 'top 75%', once: true }
}); });
} }
if (disclaimer) { if (disclaimer) {
gsap.set(disclaimer, { opacity: 0 }); gsap.set(disclaimer, { opacity: 0 });
gsap.to(disclaimer, { gsap.to(disclaimer, {
opacity: 1, opacity: 1,
duration: 0.6, delay: 0.4, duration: 0.6,
delay: 0.4,
clearProps: 'opacity', clearProps: 'opacity',
scrollTrigger: { trigger: section, start: 'top 75%', once: true }, scrollTrigger: { trigger: section, start: 'top 75%', once: true }
}); });
} }
}, section); }, section);
+3 -1
View File
@@ -42,7 +42,9 @@
<h2 <h2
use:scrollRevealLeft use:scrollRevealLeft
class="mb-16 font-display text-4xl font-black tracking-tighter uppercase" class="mb-16 font-display text-4xl font-black tracking-tighter uppercase"
>ВОЗМОЖНОСТИ</h2> >
ВОЗМОЖНОСТИ
</h2>
<div <div
use:scrollStagger={{ selector: '[data-feature-row]', stagger: 0.07, y: 28 }} use:scrollStagger={{ selector: '[data-feature-row]', stagger: 0.07, y: 28 }}
+6 -4
View File
@@ -22,7 +22,7 @@
const tl = gsap.timeline({ const tl = gsap.timeline({
defaults: { ease: 'power3.out' }, defaults: { ease: 'power3.out' },
onComplete: () => gsap.set([words, desc, cta, mock], { clearProps: 'all' }), onComplete: () => gsap.set([words, desc, cta, mock], { clearProps: 'all' })
}); });
words.forEach((w, i) => words.forEach((w, i) =>
@@ -47,12 +47,14 @@
<h1 class="flex flex-col font-display leading-[0.95] font-black uppercase"> <h1 class="flex flex-col font-display leading-[0.95] font-black uppercase">
<span data-hero-word class="text-[clamp(2.5rem,10vw,6.25rem)]">Рыночный</span> <span data-hero-word class="text-[clamp(2.5rem,10vw,6.25rem)]">Рыночный</span>
<span data-hero-word class="text-[clamp(3rem,13vw,8rem)]">прогноз</span> <span data-hero-word class="text-[clamp(3rem,13vw,8rem)]">прогноз</span>
<span data-hero-word class="text-[clamp(1.5rem,6.5vw,4rem)] leading-none text-primary">от ML-модели</span> <span data-hero-word class="text-[clamp(1.5rem,6.5vw,4rem)] leading-none text-primary"
>от ML-модели</span
>
</h1> </h1>
<p <p
data-hero-desc data-hero-desc
class="mt-6 max-w-full text-base leading-[1.3] text-desc sm:mt-8 sm:text-xl lg:mt-10 xl:max-w-180 lg:text-2xl lg:leading-[1.1]" class="mt-6 max-w-full text-base leading-[1.3] text-desc sm:mt-8 sm:text-xl lg:mt-10 lg:text-2xl lg:leading-[1.1] xl:max-w-180"
> >
Исторические свечи, прогнозная зона, уровни входа и выхода — всё на одном интерактивном Исторические свечи, прогнозная зона, уровни входа и выхода — всё на одном интерактивном
графике. Горизонт 19 свечей, таймфрейм 5M. графике. Горизонт 19 свечей, таймфрейм 5M.
@@ -64,7 +66,7 @@
</div> </div>
</div> </div>
<div data-hero-mockup class="w-full mx-auto xl:mx-0 z-20"> <div data-hero-mockup class="z-20 mx-auto w-full xl:mx-0">
<DashboardMockup /> <DashboardMockup />
</div> </div>
+23 -7
View File
@@ -1,12 +1,28 @@
<script> <script lang="ts">
import Button from '$lib/components/ui/Button.svelte'; import { page } from '$app/state';
import { pageTitle } from '$lib/config/site';
</script> </script>
<section class="flex h-screen items-center justify-center pt-16"> <svelte:head>
<h1 <title>{pageTitle('Страница не найдена')}</title>
class="flex flex-col text-center font-display text-[110px] leading-[1.1] font-black uppercase" </svelte:head>
>
<section class="flex min-h-screen items-center justify-center px-5 pt-16 text-center">
<div class="max-w-2xl">
<p class="mb-4 font-mono text-xs tracking-widest text-primary uppercase">
Ошибка {page.status}
</p>
<h1 class="font-display text-[clamp(2.75rem,9vw,6.5rem)] leading-none font-black uppercase">
Страница не найдена Страница не найдена
<span class="text-[64px] leading-none text-primary"> Возможно стр </span>
</h1> </h1>
<p class="mx-auto mt-5 max-w-lg text-base leading-relaxed text-desc sm:text-lg">
Проверьте адрес или вернитесь к публичному дашборду Flamy Trade.
</p>
<a
href="/"
class="mt-8 inline-flex rounded-xl bg-primary px-5 py-3 font-display text-sm transition-colors hover:bg-primary-h"
>
На главную
</a>
</div>
</section> </section>
+12 -7
View File
@@ -2,19 +2,24 @@
import './layout.css'; import './layout.css';
import Header from '$lib/components/layout/Header.svelte'; import Header from '$lib/components/layout/Header.svelte';
import Footer from '$lib/components/layout/Footer.svelte'; import Footer from '$lib/components/layout/Footer.svelte';
import { canonical, pageTitle, site } from '$lib/config/site';
let { children } = $props(); let { children } = $props();
</script> </script>
<svelte:head> <svelte:head>
<link rel="preconnect" href="https://fonts.googleapis.com" /> <title>{pageTitle()}</title>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin="anonymous" /> <meta name="description" content={site.description} />
<link <link rel="canonical" href={canonical('/')} />
href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,100..1000;1,9..40,100..1000&family=JetBrains+Mono:ital,wght@0,100..800;1,100..800&family=Unbounded:wght@200..900&display=swap"
rel="stylesheet"
/>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta property="og:type" content="website" />
<meta property="og:site_name" content={site.name} />
<meta property="og:title" content={pageTitle()} />
<meta property="og:description" content={site.description} />
<meta property="og:url" content={canonical('/')} />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content={pageTitle()} />
<meta name="twitter:description" content={site.description} />
</svelte:head> </svelte:head>
<div class="noise"></div> <div class="noise"></div>
+19 -9
View File
@@ -1,20 +1,30 @@
<script lang="ts"> <script lang="ts">
import Hero from './(sections)/Hero.svelte';
import Hero from './(sections)/Hero.svelte'; import Ticker from './(sections)/Ticker.svelte';
import Ticker from './(sections)/Ticker.svelte'; import About from './(sections)/About.svelte';
import About from './(sections)/About.svelte'; import Features from './(sections)/Features.svelte';
import Features from './(sections)/Features.svelte'; import Stats from './(sections)/Stats.svelte';
import Stats from './(sections)/Stats.svelte'; import BlogPreview from './(sections)/BlogPreview.svelte';
import BlogPreview from './(sections)/BlogPreview.svelte'; import Cta from './(sections)/Cta.svelte';
import Cta from './(sections)/Cta.svelte'; import { posts } from '$lib/blog/posts';
import { canonical, pageTitle, site } from '$lib/config/site';
</script> </script>
<svelte:head>
<title>{pageTitle()}</title>
<meta name="description" content={site.description} />
<link rel="canonical" href={canonical('/')} />
<meta property="og:title" content={pageTitle()} />
<meta property="og:description" content={site.description} />
<meta property="og:url" content={canonical('/')} />
</svelte:head>
<main> <main>
<Hero /> <Hero />
<Ticker /> <Ticker />
<About /> <About />
<Features /> <Features />
<Stats /> <Stats />
<BlogPreview posts={[]} /> <BlogPreview {posts} />
<Cta /> <Cta />
</main> </main>
+18 -2
View File
@@ -1,9 +1,25 @@
<script lang="ts"> <script lang="ts">
import Button from '$lib/components/ui/Button.svelte'; import Button from '$lib/components/ui/Button.svelte';
import { about, params} from './_data'; import { about, params } from './_data';
import { scrollFadeUp, scrollRevealLeft, scrollStagger } from '$lib/gsap/actions'; import { canonical, pageTitle } from '$lib/config/site';
import { scrollFadeUp, scrollRevealLeft } from '$lib/gsap/actions';
</script> </script>
<svelte:head>
<title>{pageTitle('О проекте')}</title>
<meta
name="description"
content="Flamy Trade показывает демонстрационные результаты ML-модели для анализа крипторынка."
/>
<link rel="canonical" href={canonical('/about')} />
<meta property="og:title" content={pageTitle('О проекте')} />
<meta
property="og:description"
content="Flamy Trade показывает демонстрационные результаты ML-модели для анализа крипторынка."
/>
<meta property="og:url" content={canonical('/about')} />
</svelte:head>
<main> <main>
<div class="relative border-b border-b-bg-h bg-bg-e"> <div class="relative border-b border-b-bg-h bg-bg-e">
<section <section
+21 -6
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { posts } from '$lib/blog/posts'; import { posts } from '$lib/blog/posts';
import { canonical, pageTitle } from '$lib/config/site';
import { scrollRevealLeft, scrollFadeUp, scrollStagger } from '$lib/gsap/actions'; import { scrollRevealLeft, scrollFadeUp, scrollStagger } from '$lib/gsap/actions';
function fmtDate(d: string) { function fmtDate(d: string) {
@@ -15,12 +16,27 @@
} }
</script> </script>
<svelte:head>
<title>{pageTitle('Блог')}</title>
<meta
name="description"
content="Гайды Flamy Trade по чтению графиков, управлению рисками и работе с ML-прогнозами."
/>
<link rel="canonical" href={canonical('/blog')} />
<meta property="og:title" content={pageTitle('Блог')} />
<meta
property="og:description"
content="Гайды Flamy Trade по чтению графиков, управлению рисками и работе с ML-прогнозами."
/>
<meta property="og:url" content={canonical('/blog')} />
</svelte:head>
<main> <main>
<div class="relative overflow-hidden border-b border-bg-h bg-bg-e"> <div class="relative overflow-hidden border-b border-bg-h bg-bg-e">
<section class="z-10 flex flex-col items-start justify-end px-5 py-16 sm:px-10 sm:py-20"> <section class="z-10 flex flex-col items-start justify-end px-5 py-16 sm:px-10 sm:py-20">
<h1 <h1
use:scrollRevealLeft={{ start: 'top 95%' }} use:scrollRevealLeft={{ start: 'top 95%' }}
class="font-display text-[clamp(2.5rem,10vw,8rem)] font-black uppercase leading-none" class="font-display text-[clamp(2.5rem,10vw,8rem)] leading-none font-black uppercase"
> >
Блог Блог
</h1> </h1>
@@ -39,7 +55,7 @@
<div <div
use:scrollStagger={{ selector: 'a', stagger: 0.08, y: 24, start: 'top 88%' }} use:scrollStagger={{ selector: 'a', stagger: 0.08, y: 24, start: 'top 88%' }}
class="mx-auto max-w-400 px-5 sm:px-10 lg:px-0 mb-32" class="mx-auto mb-32 max-w-400 px-5 sm:px-10 lg:px-0"
> >
{#if posts.length === 0} {#if posts.length === 0}
<div class="flex flex-col items-center gap-3 py-20 text-center"> <div class="flex flex-col items-center gap-3 py-20 text-center">
@@ -64,12 +80,11 @@
<div <div
class="absolute inset-0 bg-linear-to-br from-primary/10 via-transparent to-transparent" class="absolute inset-0 bg-linear-to-br from-primary/10 via-transparent to-transparent"
></div> ></div>
</div> </div>
<div class="flex min-w-0 flex-1 flex-col gap-2"> <div class="flex min-w-0 flex-1 flex-col gap-2">
<h2 <h2
class="font-display text-[clamp(1.125rem,2.5vw,1.625rem)] font-black leading-[1.15] tracking-tight text-title transition-colors duration-150 group-hover:text-primary" class="font-display text-[clamp(1.125rem,2.5vw,1.625rem)] leading-[1.15] font-black tracking-tight text-title transition-colors duration-150 group-hover:text-primary"
> >
{post.title} {post.title}
</h2> </h2>
@@ -80,7 +95,7 @@
<div class="mt-1 flex flex-wrap gap-1.5"> <div class="mt-1 flex flex-wrap gap-1.5">
{#each post.tags.slice(0, 3) as tag, i (i)} {#each post.tags.slice(0, 3) as tag, i (i)}
<span <span
class="rounded-sm border border-white/6 bg-white/3 px-1.75 py-0.5 font-mono text-xs uppercase tracking-widest text-desc/60" class="rounded-sm border border-white/6 bg-white/3 px-1.75 py-0.5 font-mono text-xs tracking-widest text-desc/60 uppercase"
> >
{tag} {tag}
</span> </span>
@@ -96,7 +111,7 @@
</div> </div>
<div <div
class="shrink-0 pt-1 text-desc/30 transition-[color,translate] duration-700 group-hover:translate-x-2 group-hover:text-primary mr-6" class="mr-6 shrink-0 pt-1 text-desc/30 transition-[color,translate] duration-700 group-hover:translate-x-2 group-hover:text-primary"
> >
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"> <svg width="20" height="20" viewBox="0 0 20 20" fill="none">
<path <path
+38 -9
View File
@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import type { PageData } from './$types'; import type { PageData } from './$types';
import { canonical, pageTitle } from '$lib/config/site';
let { data }: { data: PageData } = $props(); let { data }: { data: PageData } = $props();
const { post } = $derived(data); const { post } = $derived(data);
@@ -17,11 +18,24 @@
} }
</script> </script>
<svelte:head>
<title>{pageTitle(post.title)}</title>
<meta name="description" content={post.description} />
<link rel="canonical" href={canonical(`/blog/${post.slug}`)} />
<meta property="og:type" content="article" />
<meta property="og:title" content={pageTitle(post.title)} />
<meta property="og:description" content={post.description} />
<meta property="og:url" content={canonical(`/blog/${post.slug}`)} />
<meta property="article:published_time" content={post.date} />
<meta name="twitter:title" content={pageTitle(post.title)} />
<meta name="twitter:description" content={post.description} />
</svelte:head>
<main> <main>
<div class="mx-auto max-w-3xl px-5 pb-24 pt-16 sm:px-10 sm:pb-32 sm:pt-20 lg:px-0 mt-10"> <div class="mx-auto mt-10 max-w-3xl px-5 pt-16 pb-24 sm:px-10 sm:pt-20 sm:pb-32 lg:px-0">
<a <a
href="/blog" href="/blog"
class="back-link mb-6 inline-flex items-center gap-1.5 font-mono text-[0.625rem] uppercase tracking-[0.08em] transition-colors" class="back-link mb-6 inline-flex items-center gap-1.5 font-mono text-[0.625rem] tracking-[0.08em] uppercase transition-colors"
> >
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"> <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path <path
@@ -40,7 +54,7 @@
<div class="mb-5 flex flex-wrap gap-1.5"> <div class="mb-5 flex flex-wrap gap-1.5">
{#each post.tags as tag (tag)} {#each post.tags as tag (tag)}
<span <span
class="rounded-sm border border-primary/25 bg-primary/10 px-1.75 py-0.5 font-mono text-xs uppercase tracking-widest text-primary" class="rounded-sm border border-primary/25 bg-primary/10 px-1.75 py-0.5 font-mono text-xs tracking-widest text-primary uppercase"
> >
{tag} {tag}
</span> </span>
@@ -49,7 +63,7 @@
{/if} {/if}
<h1 <h1
class="mb-5 font-display text-[clamp(1.875rem,5vw,2.75rem)] font-black leading-[1.08] tracking-[-0.035em] text-title" class="mb-5 font-display text-[clamp(1.875rem,5vw,2.75rem)] leading-[1.08] font-black tracking-[-0.035em] text-title"
> >
{post.title} {post.title}
</h1> </h1>
@@ -58,19 +72,34 @@
{post.description} {post.description}
</p> </p>
<time datetime={post.date} class="font-mono text-[0.625rem] uppercase tracking-[0.08em] text-desc/50"> <time
datetime={post.date}
class="font-mono text-[0.625rem] tracking-[0.08em] text-desc/50 uppercase"
>
{fmtDate(post.date)} {fmtDate(post.date)}
</time> </time>
</header> </header>
<article class="prose-article"> <article class="prose-article">
{@html post.content} {#each post.content as block, i (`${block.type}-${i}`)}
{#if block.type === 'heading'}
<h2>{block.text}</h2>
{:else if block.type === 'paragraph'}
<p>{block.text}</p>
{:else}
<ul>
{#each block.items as item (item)}
<li>{item}</li>
{/each}
</ul>
{/if}
{/each}
</article> </article>
<div class="mt-16 flex items-center justify-between gap-3 border-t border-bg-h pt-7"> <div class="mt-16 flex items-center justify-between gap-3 border-t border-bg-h pt-7">
<a <a
href="/blog" href="/blog"
class="back-link inline-flex items-center gap-1.5 font-mono text-[0.625rem] uppercase tracking-[0.08em] transition-colors" class="back-link inline-flex items-center gap-1.5 font-mono text-[0.625rem] tracking-[0.08em] uppercase transition-colors"
> >
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"> <svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path <path
@@ -106,7 +135,7 @@
} }
.prose-article :global(h2) { .prose-article :global(h2) {
font-family: var(--font-display),sans-serif; font-family: var(--font-display), sans-serif;
font-size: 1.25rem; font-size: 1.25rem;
font-weight: 800; font-weight: 800;
color: var(--color-title); color: var(--color-title);
@@ -158,7 +187,7 @@
color: var(--color-title); color: var(--color-title);
} }
.prose-article :global(code) { .prose-article :global(code) {
font-family: var(--font-mono),sans-serif; font-family: var(--font-mono), sans-serif;
font-size: 0.875em; font-size: 0.875em;
color: var(--color-primary); color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 10%, transparent); background: color-mix(in srgb, var(--color-primary) 10%, transparent);
+103 -35
View File
@@ -3,6 +3,7 @@
import { gsap } from 'gsap'; import { gsap } from 'gsap';
import { generateChartData, SYMBOLS, TIMEFRAMES, type TimeframeId } from '$lib/stores/chartStore'; import { generateChartData, SYMBOLS, TIMEFRAMES, type TimeframeId } from '$lib/stores/chartStore';
import Chart from '$lib/components/Chart.svelte'; import Chart from '$lib/components/Chart.svelte';
import { canonical, pageTitle } from '$lib/config/site';
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
let activeSymbol = $state('BTCUSDT'); let activeSymbol = $state('BTCUSDT');
@@ -24,19 +25,38 @@
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
const ctx = gsap.context(() => { const ctx = gsap.context(() => {
gsap.from(titleEl, { opacity: 0, y: 20, duration: 0.6, ease: 'power3.out', clearProps: 'all' }); gsap.from(titleEl, {
gsap.from(panelEl, { opacity: 0, y: 32, duration: 0.7, delay: 0.12, ease: 'power3.out', clearProps: 'all' }); opacity: 0,
y: 20,
duration: 0.6,
ease: 'power3.out',
clearProps: 'all'
}); });
return () => { ctx.revert(); window.removeEventListener('resize', update); }; gsap.from(panelEl, {
opacity: 0,
y: 32,
duration: 0.7,
delay: 0.12,
ease: 'power3.out',
clearProps: 'all'
});
});
return () => {
ctx.revert();
window.removeEventListener('resize', update);
};
} }
return () => window.removeEventListener('resize', update); return () => window.removeEventListener('resize', update);
}); });
function fmtPrice(n: number): string { function fmtPrice(n: number): string {
if (n >= 10000) return n.toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 }); if (n >= 10000)
if (n >= 100) return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return n.toLocaleString('en-US', { minimumFractionDigits: 1, maximumFractionDigits: 1 });
if (n >= 1) return n.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 4 }); if (n >= 100)
return n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
if (n >= 1)
return n.toLocaleString('en-US', { minimumFractionDigits: 4, maximumFractionDigits: 4 });
return n.toLocaleString('en-US', { minimumFractionDigits: 6, maximumFractionDigits: 6 }); return n.toLocaleString('en-US', { minimumFractionDigits: 6, maximumFractionDigits: 6 });
} }
@@ -45,10 +65,27 @@
} }
</script> </script>
<svelte:head>
<title>{pageTitle('Дашборд')}</title>
<meta
name="description"
content="Демонстрационный дашборд Flamy Trade с графиком, ML-прогнозом и уровнями Entry, TP и SL."
/>
<link rel="canonical" href={canonical('/dashboard')} />
<meta property="og:title" content={pageTitle('Дашборд')} />
<meta
property="og:description"
content="Демонстрационный дашборд Flamy Trade с графиком, ML-прогнозом и уровнями Entry, TP и SL."
/>
<meta property="og:url" content={canonical('/dashboard')} />
</svelte:head>
<main class="min-h-screen bg-bg"> <main class="min-h-screen bg-bg">
<div class="mx-auto max-w-400 px-5 pt-22 pb-16 sm:px-10 sm:pt-26 lg:pt-24"> <div class="mx-auto max-w-400 px-5 pt-22 pb-16 sm:px-10 sm:pt-26 lg:pt-24">
<div bind:this={titleEl} class="mb-5 mx-auto w-fit"> <div bind:this={titleEl} class="mx-auto mb-5 w-fit">
<h1 class="font-display text-center text-2xl font-black uppercase leading-none tracking-tight mb-2 sm:text-4xl "> <h1
class="mb-2 text-center font-display text-2xl leading-none font-black tracking-tight uppercase sm:text-4xl"
>
Дашборд Дашборд
</h1> </h1>
<p class="mt-1.5 font-mono text-[0.625rem] tracking-widest text-desc uppercase sm:text-xs"> <p class="mt-1.5 font-mono text-[0.625rem] tracking-widest text-desc uppercase sm:text-xs">
@@ -56,10 +93,16 @@
</p> </p>
</div> </div>
<div bind:this={panelEl} class="overflow-hidden rounded-2xl border border-bg-h bg-bg-c shadow-[0_40px_80px_rgba(0,0,0,0.6)]"> <div
<div class="flex flex-col border-b border-bg-h sm:flex-row sm:items-stretch sm:justify-between"> bind:this={panelEl}
class="overflow-hidden rounded-2xl border border-bg-h bg-bg-c shadow-[0_40px_80px_rgba(0,0,0,0.6)]"
<div class="flex overflow-hidden border-b border-bg-h sm:border-b-0 [&::-webkit-scrollbar]:hidden"> >
<div
class="flex flex-col border-b border-bg-h sm:flex-row sm:items-stretch sm:justify-between"
>
<div
class="flex overflow-hidden border-b border-bg-h sm:border-b-0 [&::-webkit-scrollbar]:hidden"
>
{#each SYMBOLS as s (s.id)} {#each SYMBOLS as s (s.id)}
<button <button
class={cn( class={cn(
@@ -90,44 +133,59 @@
</div> </div>
</div> </div>
<div class="flex flex-wrap items-center gap-x-5 gap-y-3 border-b border-bg-h px-4 py-3 sm:px-5"> <div
class="flex flex-wrap items-center gap-x-5 gap-y-3 border-b border-bg-h px-4 py-3 sm:px-5"
>
<div class="flex items-baseline gap-2.5"> <div class="flex items-baseline gap-2.5">
<span class="font-display text-[0.625rem] font-black tracking-tight text-desc uppercase hidden sm:inline"> <span
class="hidden font-display text-[0.625rem] font-black tracking-tight text-desc uppercase sm:inline"
>
{sym.label} {sym.label}
</span> </span>
<span class="font-mono text-lg font-medium text-title sm:text-2xl"> <span class="font-mono text-lg font-medium text-title sm:text-2xl">
{fmtPrice(data.currentPrice)} {fmtPrice(data.currentPrice)}
</span> </span>
<span class={cn( <span
class={cn(
'font-mono text-xs font-medium sm:text-sm', 'font-mono text-xs font-medium sm:text-sm',
data.change24hPct >= 0 ? 'text-emerald-400' : 'text-red-400' data.change24hPct >= 0 ? 'text-emerald-400' : 'text-red-400'
)}> )}
>
{fmtPct(data.change24hPct)} {fmtPct(data.change24hPct)}
</span> </span>
</div> </div>
<div class={cn( <div
class={cn(
'flex items-center gap-1.5 rounded-lg px-2.5 py-1.5', 'flex items-center gap-1.5 rounded-lg px-2.5 py-1.5',
data.signal.direction === 'long' ? 'bg-emerald-400/10' : 'bg-red-400/10' data.signal.direction === 'long' ? 'bg-emerald-400/10' : 'bg-red-400/10'
)}> )}
<span class={cn( >
'font-display text-[0.6875rem] font-black uppercase tracking-tight', <span
class={cn(
'font-display text-[0.6875rem] font-black tracking-tight uppercase',
data.signal.direction === 'long' ? 'text-emerald-400' : 'text-red-400' data.signal.direction === 'long' ? 'text-emerald-400' : 'text-red-400'
)}> )}
>
{data.signal.direction === 'long' ? '▲ LONG' : '▼ SHORT'} {data.signal.direction === 'long' ? '▲ LONG' : '▼ SHORT'}
</span> </span>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<span class="font-mono text-[0.5625rem] tracking-widest text-desc uppercase hidden sm:inline"> <span
class="hidden font-mono text-[0.5625rem] tracking-widest text-desc uppercase sm:inline"
>
Уверенность Уверенность
</span> </span>
<div class="h-1.5 w-16 overflow-hidden rounded-full bg-bg-h sm:w-20"> <div class="h-1.5 w-16 overflow-hidden rounded-full bg-bg-h sm:w-20">
<div <div
class={cn( class={cn(
'h-full rounded-full transition-all duration-700', 'h-full rounded-full transition-all duration-700',
data.signal.confidence >= 70 ? 'bg-emerald-400' : data.signal.confidence >= 55 ? 'bg-amber-400' : 'bg-red-400' data.signal.confidence >= 70
? 'bg-emerald-400'
: data.signal.confidence >= 55
? 'bg-amber-400'
: 'bg-red-400'
)} )}
style="width: {data.signal.confidence}%" style="width: {data.signal.confidence}%"
></div> ></div>
@@ -136,22 +194,22 @@
</div> </div>
<div class="ml-auto flex gap-4 sm:gap-6"> <div class="ml-auto flex gap-4 sm:gap-6">
{#each [ {#each [{ label: 'Entry', val: data.signal.entry, cls: 'text-primary' }, { label: 'TP', val: data.signal.tp, cls: 'text-emerald-400' }, { label: 'SL', val: data.signal.sl, cls: 'text-red-400' }] as lvl (lvl.label)}
{ label: 'Entry', val: data.signal.entry, cls: 'text-primary' },
{ label: 'TP', val: data.signal.tp, cls: 'text-emerald-400' },
{ label: 'SL', val: data.signal.sl, cls: 'text-red-400' },
] as lvl (lvl.label)}
<div class="flex flex-col gap-0.5 text-right"> <div class="flex flex-col gap-0.5 text-right">
<span class="font-mono text-[0.5rem] tracking-widest text-desc uppercase">{lvl.label}</span> <span class="font-mono text-[0.5rem] tracking-widest text-desc uppercase"
>{lvl.label}</span
>
<span class="font-mono text-xs {lvl.cls}">{fmtPrice(lvl.val)}</span> <span class="font-mono text-xs {lvl.cls}">{fmtPrice(lvl.val)}</span>
</div> </div>
{/each} {/each}
</div> </div>
</div> </div>
<Chart data={data} decimals={sym.dec} height={chartHeight} /> <Chart {data} decimals={sym.dec} height={chartHeight} />
<div class="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-bg-h px-4 py-3 sm:px-5"> <div
class="flex flex-wrap items-center gap-x-5 gap-y-2 border-t border-bg-h px-4 py-3 sm:px-5"
>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<div class="flex gap-0.5"> <div class="flex gap-0.5">
<span class="h-3.5 w-1.5 rounded-[2px] bg-emerald-400/80"></span> <span class="h-3.5 w-1.5 rounded-[2px] bg-emerald-400/80"></span>
@@ -162,9 +220,19 @@
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
<svg width="22" height="4" aria-hidden="true"> <svg width="22" height="4" aria-hidden="true">
<line x1="0" y1="2" x2="22" y2="2" stroke="#fe4b07" stroke-width="2" stroke-dasharray="4 3" /> <line
x1="0"
y1="2"
x2="22"
y2="2"
stroke="#fe4b07"
stroke-width="2"
stroke-dasharray="4 3"
/>
</svg> </svg>
<span class="font-mono text-[0.5625rem] tracking-widest text-desc uppercase">ML-прогноз</span> <span class="font-mono text-[0.5625rem] tracking-widest text-desc uppercase"
>ML-прогноз</span
>
</div> </div>
<div class="flex items-center gap-1.5"> <div class="flex items-center gap-1.5">
@@ -190,7 +258,7 @@
</div> </div>
<span <span
class="pointer-events-none fixed top-0 left-1/2 -translate-x-1/2 h-80 w-full max-w-3xl rounded-full bg-primary opacity-[0.07] blur-[160px]" class="pointer-events-none fixed top-0 left-1/2 h-80 w-full max-w-3xl -translate-x-1/2 rounded-full bg-primary opacity-[0.07] blur-[160px]"
aria-hidden="true" aria-hidden="true"
></span> ></span>
</main> </main>
+8
View File
@@ -0,0 +1,8 @@
import { json } from '@sveltejs/kit';
export const GET = () => {
return json({
status: 'ok',
service: 'flamy-trade'
});
};
+25 -1
View File
@@ -1,5 +1,29 @@
@import 'tailwindcss'; @import 'tailwindcss';
@font-face {
font-family: 'DM Sans';
src: url('/fonts/DMSans.ttf') format('truetype');
font-weight: 100 1000;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'JetBrains Mono';
src: url('/fonts/JetBrainsMono.ttf') format('truetype');
font-weight: 100 800;
font-style: normal;
font-display: swap;
}
@font-face {
font-family: 'Unbounded';
src: url('/fonts/Unbounded.ttf') format('truetype');
font-weight: 200 900;
font-style: normal;
font-display: swap;
}
@theme { @theme {
--font-display: 'Unbounded', sans-serif; --font-display: 'Unbounded', sans-serif;
--font-sans: 'DM Sans', sans-serif; --font-sans: 'DM Sans', sans-serif;
@@ -42,7 +66,7 @@
} }
section { section {
@apply relative mx-auto max-w-400 lg:min-h-175 lg:max-h-300 px-10; @apply relative mx-auto max-w-400 px-10 lg:max-h-300 lg:min-h-175;
} }
} }
+30
View File
@@ -0,0 +1,30 @@
import { posts } from '$lib/blog/posts';
import { canonical } from '$lib/config/site';
const staticPages = ['/', '/about', '/blog', '/dashboard'];
export const GET = () => {
const urls = [
...staticPages.map((path) => ({ loc: canonical(path), lastmod: undefined })),
...posts.map((post) => ({ loc: canonical(`/blog/${post.slug}`), lastmod: post.date }))
];
const body = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls
.map(
(url) => ` <url>
<loc>${url.loc}</loc>${url.lastmod ? `\n <lastmod>${url.lastmod}</lastmod>` : ''}
</url>`
)
.join('\n')}
</urlset>
`;
return new Response(body, {
headers: {
'content-type': 'application/xml; charset=utf-8',
'cache-control': 'public, max-age=3600'
}
});
};
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64" role="img" aria-label="Flamy Trade">
<rect width="64" height="64" rx="12" fill="#09080a" />
<path d="M18 43V17h28v8H27v7h15v8H27v3h19v8H18z" fill="#f0ede6" />
<path d="M38 13h9v9h-9zM47 22h-9v9h9z" fill="#fe4b07" />
</svg>

After

Width:  |  Height:  |  Size: 309 B

Binary file not shown.
Binary file not shown.
Binary file not shown.
+3 -2
View File
@@ -1,3 +1,4 @@
# allow crawling everything by default
User-agent: * User-agent: *
Disallow: Allow: /
Sitemap: https://trade.flamy.studio/sitemap.xml