update design

This commit is contained in:
2026-08-29 01:54:04 +07:00
parent 8ec7c3b47d
commit 7c9fa4a8c2
15 changed files with 1165 additions and 186 deletions
+21 -2
View File
@@ -1,10 +1,29 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang=""> <html lang="ru">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Родники</title> <meta name="description" content="Расписание выступлений коллектива «Родники»">
<meta name="theme-color" content="#ffffff">
<script>
// Ставим тему до первой отрисовки, чтобы не было «мигания»
(function () {
var t = null;
try {
t = localStorage.getItem('rodniki-theme');
} catch (e) {}
if (t !== 'light' && t !== 'dark') {
t =
window.matchMedia &&
window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light';
}
document.documentElement.setAttribute('data-theme', t);
})();
</script>
<title>Родники — расписание</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
+2 -2
View File
@@ -1,11 +1,11 @@
{ {
"name": "vo1", "name": "${REPO_NAME_SNAKE}",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "vo1", "name": "${REPO_NAME_SNAKE}",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"pinia": "^3.0.1", "pinia": "^3.0.1",
+1 -10
View File
@@ -1,16 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import SchedulePage from './components/SchedulePage.vue'; import SchedulePage from './components/SchedulePage.vue';
</script> </script>
<template> <template>
<div class="app"> <SchedulePage />
<SchedulePage></SchedulePage>
</div>
</template> </template>
<style scoped>
.app {
color: #111;
}
</style>
+184 -63
View File
@@ -1,88 +1,209 @@
/* color palette from <https://github.com/vuejs/theme> */ /* ============================================================
Дизайн-токены «Родники»
Палитра: белый + голубой + синий.
Тёмная тема включается атрибутом data-theme="dark" на <html>
(см. composables/useTheme.ts).
============================================================ */
:root { :root {
--vt-c-white: #ffffff; color-scheme: light;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818; /* Фон и поверхности — белый, с едва заметным голубым отливом */
--vt-c-black-soft: #222222; --c-canvas-1: #ffffff;
--vt-c-black-mute: #282828; --c-canvas-2: #eef5fd;
--c-surface: #ffffff;
--c-surface-soft: #f6faff;
--vt-c-indigo: #2c3e50; /* Текст */
--c-ink: #1b2c47;
--c-ink-soft: #51688a;
--c-ink-faint: #5a7194;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29); /* Акцент — синий */
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12); --c-accent: #2563eb;
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65); --c-accent-deep: #1e4fd6;
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48); --c-accent-soft: #dbeafe;
--c-accent-text: #1e40af;
--vt-c-text-light-1: var(--vt-c-indigo); /* Вторичный акцент — голубой */
--vt-c-text-light-2: rgba(60, 60, 60, 0.66); --c-sky: #4f9cf7;
--vt-c-text-dark-1: var(--vt-c-white); --c-sky-soft: #e6f2ff;
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64); --c-sky-text: #2563eb;
/* Дополнительные краски */
--c-violet: #7c3aed;
--c-violet-soft: #ece4fe;
--c-violet-text: #6d28d9;
--c-pink-soft: #fde3ef;
--c-pink-text: #be185d;
--c-amber: #b45309;
--c-amber-soft: #fdf0d7;
--c-amber-text: #92400e;
/* Бейдж «сегодня» */
--c-today-text: #ffffff;
--c-today-glow: rgba(37, 99, 235, 0.45);
/* Линии */
--c-line: rgba(27, 44, 71, 0.12);
--c-line-strong: rgba(27, 44, 71, 0.2);
/* Форма и тени */
--r-lg: 22px;
--r-md: 14px;
--r-sm: 10px;
--r-pill: 999px;
--shadow-card: 0 1px 2px rgba(30, 64, 175, 0.06),
0 18px 40px -22px rgba(30, 64, 175, 0.35);
/* Свечение фона */
--c-glow: rgba(37, 99, 235, 0.06);
--c-glow-2: rgba(124, 58, 237, 0.07);
/* Типографика */
--font-display: 'Iowan Old Style', 'Palatino Linotype', Palatino, Georgia,
'Times New Roman', serif;
--font-body: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
} }
/* semantic color variables for this project */ :root[data-theme='dark'] {
:root { color-scheme: dark;
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2); --c-canvas-1: #101a2c;
--color-border-hover: var(--vt-c-divider-light-1); --c-canvas-2: #0b1220;
--c-surface: #16233c;
--c-surface-soft: #1d2d4b;
--color-heading: var(--vt-c-text-light-1); --c-ink: #f1f6fc;
--color-text: var(--vt-c-text-light-1); --c-ink-soft: #b9c9de;
--c-ink-faint: #7f93b4;
--section-gap: 160px; --c-accent: #6ea2ff;
} --c-accent-deep: #93b8ff;
--c-accent-soft: rgba(110, 162, 255, 0.16);
@media (prefers-color-scheme: dark) { --c-accent-text: #a7c4ff;
:root {
color-scheme: only dark; --c-sky: #8cc3ff;
--c-sky-soft: rgba(140, 195, 255, 0.14);
--color-background: var(--vt-c-black); --c-sky-text: #b7d6ff;
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute); --c-violet: #a78bfa;
--c-violet-soft: rgba(167, 139, 250, 0.16);
--color-border: var(--vt-c-divider-dark-2); --c-violet-text: #c4b5fd;
--color-border-hover: var(--vt-c-divider-dark-1); --c-pink-soft: rgba(244, 114, 182, 0.16);
--c-pink-text: #f9a8d4;
--color-heading: var(--vt-c-text-dark-1); --c-amber: #fbbf24;
--color-text: var(--vt-c-text-dark-2); --c-amber-soft: rgba(251, 191, 36, 0.16);
} --c-amber-text: #fcd34d;
--c-today-text: #0b1220;
--c-today-glow: rgba(110, 162, 255, 0.35);
--c-line: rgba(241, 246, 252, 0.1);
--c-line-strong: rgba(241, 246, 252, 0.2);
--shadow-card: 0 1px 2px rgba(0, 0, 0, 0.3),
0 18px 40px -22px rgba(0, 0, 0, 0.6);
--c-glow: rgba(110, 162, 255, 0.08);
--c-glow-2: rgba(167, 139, 250, 0.07);
} }
/* ---------- Базовый сброс ---------- */
*, *,
*::before, *::before,
*::after { *::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
font-weight: normal; }
html {
-webkit-text-size-adjust: 100%;
} }
body { body {
min-height: 100vh; min-height: 100vh;
color: var(--color-text); font-family: var(--font-body);
background: #ccc; font-size: 16px;
transition: line-height: 1.55;
color 0.5s, color: var(--c-ink);
background-color 0.5s; background: linear-gradient(180deg, var(--c-canvas-1), var(--c-canvas-2));
line-height: 1.6; background: radial-gradient(
font-family: 900px 420px at 50% -8%,
Inter, var(--c-glow),
-apple-system, transparent 65%
BlinkMacSystemFont, ),
'Segoe UI', radial-gradient(
Roboto, 620px 320px at 100% -18%,
Oxygen, var(--c-glow-2),
Ubuntu, transparent 60%
Cantarell, ),
'Fira Sans', linear-gradient(180deg, var(--c-canvas-1), var(--c-canvas-2));
'Droid Sans', background-attachment: fixed;
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility; text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale; -moz-osx-font-smoothing: grayscale;
transition: color 0.3s ease, background-color 0.3s ease;
}
h1,
h2,
h3,
h4,
h5,
h6,
p {
margin: 0;
overflow-wrap: break-word;
}
ul,
ol {
margin: 0;
padding: 0;
list-style: none;
}
img,
svg {
display: block;
max-width: 100%;
}
button {
font: inherit;
}
a {
color: inherit;
}
::selection {
background: var(--c-accent-soft);
color: var(--c-accent-text);
}
/* ---------- Утилиты ---------- */
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
clip: rect(0 0 0 0);
clip-path: inset(50%);
overflow: hidden;
white-space: nowrap;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
} }
+5 -30
View File
@@ -1,35 +1,10 @@
@import './base.css'; @import './base.css';
#app { #app {
max-width: 1280px; max-width: 720px;
min-height: 100vh;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: clamp(20px, 5vw, 44px) clamp(14px, 4vw, 24px) 64px;
font-weight: normal; display: flex;
} flex-direction: column;
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
} }
+767 -77
View File
@@ -1,98 +1,788 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { apiGetDays } from './client'; import { apiGetDays } from './client';
import type { Schedule } from './models'; import type { Performance, Schedule } from './models';
import { formatRussianDate, cleanTime, getRelativeDayName, timeAgo, formatTimeRange } from './date'; import {
formatRussianDate,
cleanTime,
getRelativeDayName,
timeAgo,
formatTimeRange,
} from './date';
import { capitalizeFirstLetter } from './text'; import { capitalizeFirstLetter } from './text';
import { useTheme } from '../composables/useTheme';
import IconClock from './icons/IconClock.vue';
import IconPin from './icons/IconPin.vue';
import IconMusic from './icons/IconMusic.vue';
import IconSparkles from './icons/IconSparkles.vue';
import IconRefresh from './icons/IconRefresh.vue';
import IconSun from './icons/IconSun.vue';
import IconMoon from './icons/IconMoon.vue';
const schedule = ref<Schedule>({ updateTime: "", days: [] }) const { theme, toggleTheme } = useTheme();
onMounted(async () => { const themeLabel = computed(() =>
schedule.value = await apiGetDays() theme.value === 'dark' ? 'Включить светлую тему' : 'Включить тёмную тему',
}) );
const schedule = ref<Schedule>({ updateTime: '', days: [] });
const loading = ref(true);
const error = ref(false);
async function load(): Promise<void> {
loading.value = true;
try {
schedule.value = await apiGetDays();
error.value = false;
} catch {
// При неудачном обновлении показываем ошибку только если данных ещё нет
if (schedule.value.days.length === 0) {
error.value = true;
}
} finally {
loading.value = false;
}
}
onMounted(load);
function dayInfo(date: string): { weekday: string; date: string; relative: string } {
const title = capitalizeFirstLetter(formatRussianDate(date));
const space = title.indexOf(' ');
return {
weekday: space === -1 ? title : title.slice(0, space),
date: space === -1 ? '' : title.slice(space + 1),
relative: getRelativeDayName(date),
};
}
function badgeClass(relative: string): string {
switch (relative) {
case 'сегодня':
return 'badge--today';
case 'завтра':
return 'badge--tomorrow';
case 'послезавтра':
return 'badge--day-after';
case 'вчера':
return 'badge--yesterday';
default:
return 'badge--soon';
}
}
const updateLabel = computed(() => {
if (!schedule.value.updateTime) return 'время обновления неизвестно';
return timeAgo(schedule.value.updateTime);
});
function showCollection(p: Performance): boolean {
return p.timeCollection !== '' && p.timeCollection !== '-';
}
function showPlace(p: Performance): boolean {
return p.place !== '' && p.place !== '-';
}
function showCostumes(p: Performance): boolean {
return p.costumes !== '' && p.costumes !== '-';
}
function showTime(p: Performance): boolean {
return p.timeStart !== '' && p.timeStart !== '-';
}
type TimeSummary = { primary: string; caption: string; secondary: string };
/**
* Иерархия времени: «сбор» — главное, «начало» — дополнительное.
*/
function timeSummary(p: Performance): TimeSummary {
if (showCollection(p)) {
return {
primary: cleanTime(p.timeCollection),
caption: 'сбор',
secondary: showTime(p) ? `начало ${formatTimeRange(p.timeStart)}` : '',
};
}
if (showTime(p)) {
return { primary: formatTimeRange(p.timeStart), caption: 'начало', secondary: '' };
}
return { primary: 'Время уточняется', caption: '', secondary: '' };
}
const days = computed(() =>
schedule.value.days.map((day) => ({
...day,
info: dayInfo(day.date),
performances: day.performances.map((p) => ({ ...p, time: timeSummary(p) })),
})),
);
</script> </script>
<template> <template>
<div> <div class="schedule">
<div v-for="day in schedule.days" :key="day.date" class="day-block"> <header class="schedule__header">
{{ capitalizeFirstLetter(formatRussianDate(day.date)) }} <div class="schedule__heading">
<span v-if="getRelativeDayName(day.date) !== ''" class="relative-day-name">{{ getRelativeDayName(day.date) <h1 class="schedule__title">Родники</h1>
}}</span> <p class="schedule__subtitle">Расписание выступлений</p>
<div v-for="(performance, index) in day.performances" :key="performance.name" class="performance-block">
<hr v-if="index > 0" class="hr">
<div v-if="performance.timeCollection !== '' && performance.timeCollection !== '-'"
class="time-collection-block">
Сбор: {{ cleanTime(performance.timeCollection) }}
</div>
<div>
Время:
<span v-if="performance.timeStart !== '' && performance.timeStart !== '-'">{{
formatTimeRange(performance.timeStart) }}</span>
<span v-else>Уточняется</span>
</div>
<div class="place-block">
Место: {{ capitalizeFirstLetter(performance.place) }}
</div>
<div>
{{ capitalizeFirstLetter(performance.name) }}
</div>
<div v-if="performance.numbers.length !== 0">
Номера:
<div v-for="number in performance.numbers" :key="number.name" class="number-block">
- {{ capitalizeFirstLetter(number.name) }}
</div>
</div>
<div v-if="performance.costumes !== '' && performance.costumes !== '-'">
{{ capitalizeFirstLetter(performance.costumes) }}
</div>
</div> </div>
</div> <div class="schedule__header-actions">
<div class="footer"> <button
Обновлено: {{ timeAgo(schedule.updateTime) }} type="button"
</div> class="icon-button"
:aria-label="themeLabel"
:title="themeLabel"
@click="toggleTheme"
>
<IconMoon v-if="theme === 'light'" class="icon-button__icon" />
<IconSun v-else class="icon-button__icon" />
</button>
<button
type="button"
class="icon-button"
:class="{ 'icon-button--loading': loading }"
:disabled="loading"
aria-label="Обновить расписание"
title="Обновить расписание"
@click="load"
>
<IconRefresh class="icon-button__icon" />
</button>
</div>
</header>
<main class="schedule__content">
<!-- Загрузка -->
<template v-if="loading">
<p class="visually-hidden" role="status">Загрузка расписания</p>
<section v-for="n in 2" :key="n" class="day day--skeleton" aria-hidden="true">
<div class="skeleton skeleton--title"></div>
<div class="skeleton skeleton--line"></div>
<div class="skeleton skeleton--short"></div>
</section>
</template>
<!-- Ошибка -->
<div v-else-if="error" class="message-card" role="alert">
<p class="message-card__text">Не удалось загрузить расписание</p>
<p class="message-card__hint">Проверьте подключение к сети и попробуйте ещё раз</p>
<button type="button" class="button" @click="load">Попробовать снова</button>
</div>
<!-- Пустое расписание -->
<div v-else-if="days.length === 0" class="message-card">
<p class="message-card__text">Расписание пока пусто</p>
<p class="message-card__hint">Загляните сюда позже выступления скоро появятся</p>
</div>
<!-- Дни -->
<template v-else>
<section v-for="day in days" :key="day.date" class="day">
<header class="day__header">
<h2 class="day__title">
<span class="day__weekday">{{ day.info.weekday }}</span>
<span v-if="day.info.date" class="day__date">{{ day.info.date }}</span>
</h2>
<span
v-if="day.info.relative"
class="badge"
:class="badgeClass(day.info.relative)"
>
{{ day.info.relative }}
</span>
</header>
<p v-if="day.performances.length === 0" class="day__empty">
В этот день выступлений нет
</p>
<article
v-for="performance in day.performances"
:key="performance.name"
class="performance"
>
<div class="performance__head">
<div class="performance__time">
<div class="performance__time-primary">
<span
class="performance__time-main"
:class="{ 'performance__time-main--muted': performance.time.caption === '' }"
>
{{ performance.time.primary }}
</span>
<span v-if="performance.time.caption" class="performance__time-caption">
{{ performance.time.caption }}
</span>
</div>
<span v-if="performance.time.secondary" class="performance__time-start">
<IconClock class="performance__icon" />
{{ performance.time.secondary }}
</span>
</div>
<div class="performance__info">
<h3 class="performance__name">
{{ capitalizeFirstLetter(performance.name) }}
</h3>
<p v-if="showPlace(performance)" class="performance__place">
<IconPin class="performance__icon" />
{{ capitalizeFirstLetter(performance.place) }}
</p>
</div>
</div>
<div v-if="performance.numbers.length > 0" class="performance__numbers">
<p class="performance__section-label">
<IconMusic class="performance__icon" />
Номера
</p>
<ul class="performance__list">
<li
v-for="number in performance.numbers"
:key="number.name"
class="performance__list-item"
>
{{ capitalizeFirstLetter(number.name) }}
</li>
</ul>
</div>
<div v-if="showCostumes(performance)" class="performance__costumes">
<p class="performance__section-label">
<IconSparkles class="performance__icon" />
Костюмы
</p>
<p class="performance__costumes-text">
{{ capitalizeFirstLetter(performance.costumes) }}
</p>
</div>
</article>
</section>
</template>
</main>
<footer v-if="!loading && !error && days.length > 0" class="schedule__footer">
<span class="schedule__footer-dot" aria-hidden="true"></span>
<span>Обновлено: {{ updateLabel }}</span>
</footer>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.day-block { .schedule {
margin: 10px 0; display: flex;
padding: 15px; flex-direction: column;
border-radius: 10px; gap: 22px;
font-weight: 700;
background-color: #fff;
} }
.time-collection-block { /* ---------- Шапка ---------- */
font-size: 20px; .schedule__header {
font-weight: 700; display: flex;
line-height: 25px; align-items: flex-start;
margin: 3px 0; justify-content: space-between;
gap: 16px;
padding-block: 4px;
} }
.place-block { .schedule__title {
font-weight: 700; font-family: var(--font-display);
} font-size: clamp(30px, 6vw, 42px);
.performance-block {}
.number-block {
margin-left: 10px;
}
.footer {
margin: 10px 0;
}
.hr {
margin: 10px;
}
.relative-day-name {
background-color: #70e5ff;
padding: 3px 10px 5px 10px;
border-radius: 15px;
float: right;
font-weight: 600; font-weight: 600;
line-height: 1.08;
letter-spacing: 0.01em;
color: var(--c-ink);
}
.schedule__subtitle {
margin-top: 6px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--c-ink-faint);
}
.schedule__header-actions {
display: flex;
align-items: center;
gap: 10px;
margin-top: 4px;
}
.icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
flex: none;
width: 44px;
height: 44px;
border: 1px solid var(--c-line-strong);
border-radius: 50%;
background: var(--c-surface);
color: var(--c-ink-soft);
font-size: 20px;
cursor: pointer;
transition:
color 0.2s ease,
border-color 0.2s ease,
background-color 0.2s ease;
}
.icon-button:hover:not(:disabled) {
color: var(--c-accent);
border-color: var(--c-accent);
background: var(--c-accent-soft);
}
.icon-button:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
.icon-button:disabled {
opacity: 0.55;
cursor: default;
}
.icon-button--loading .icon-button__icon {
animation: spin 0.9s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
/* ---------- Карточка дня ---------- */
.schedule__content {
display: flex;
flex-direction: column;
gap: 20px;
}
.day {
background: var(--c-surface);
border: 1px solid var(--c-line);
border-radius: var(--r-lg);
box-shadow: var(--shadow-card);
padding: clamp(18px, 3.5vw, 28px);
}
.day__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
gap: 10px 12px;
padding-bottom: 14px;
border-bottom: 1px solid var(--c-line);
}
.day__title {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
flex: 1 1 auto;
font-family: var(--font-display);
font-size: clamp(20px, 4vw, 26px);
font-weight: 600;
line-height: 1.2;
}
.day__weekday {
color: var(--c-ink);
}
.day__date {
font-size: 0.8em;
font-weight: 500;
color: var(--c-ink-soft);
}
.day__empty {
padding: 18px 4px 6px;
font-style: italic;
color: var(--c-ink-faint);
}
.badge {
flex: none;
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 7px;
padding: 5px 14px;
border-radius: var(--r-pill);
font-size: 13px;
font-weight: 700;
line-height: 1.2;
white-space: nowrap;
}
.badge::before {
content: '';
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.85;
}
.badge--today {
background: linear-gradient(135deg, var(--c-accent), var(--c-violet));
color: var(--c-today-text);
box-shadow: 0 4px 14px -6px var(--c-today-glow);
}
.badge--tomorrow {
background: var(--c-violet-soft);
color: var(--c-violet-text);
}
.badge--day-after {
background: var(--c-pink-soft);
color: var(--c-pink-text);
}
.badge--yesterday {
background: var(--c-amber-soft);
color: var(--c-amber-text);
}
.badge--soon {
background: var(--c-accent-soft);
color: var(--c-accent-text);
}
/* ---------- Выступление ---------- */
.performance {
padding-top: 18px;
}
.performance + .performance {
margin-top: 16px;
border-top: 1px dashed var(--c-line-strong);
}
.performance__head {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
}
@media (min-width: 520px) {
.performance__head {
grid-template-columns: minmax(128px, 0.85fr) 2fr;
gap: 24px;
align-items: start;
}
}
.performance__icon {
flex: none;
width: 1.05em;
height: 1.05em;
color: var(--c-accent);
opacity: 0.9;
}
/* Время — главный якорь выступления (сбор важнее начала) */
.performance__time {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.performance__time-primary {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 4px 10px;
}
.performance__time-main {
font-size: clamp(22px, 4.5vw, 27px);
font-weight: 700;
line-height: 1.15;
letter-spacing: -0.01em;
color: var(--c-accent);
font-variant-numeric: tabular-nums;
}
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
.performance__time-main {
background: linear-gradient(120deg, var(--c-accent), var(--c-violet));
-webkit-background-clip: text;
background-clip: text;
color: transparent;
}
}
.performance__time-main--muted {
font-size: 17px;
font-weight: 600;
color: var(--c-ink-faint);
background: none;
-webkit-background-clip: initial;
background-clip: initial;
}
.performance__time-caption {
font-size: 13px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--c-violet);
}
.performance__time-start {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 14px;
line-height: 1.4;
color: var(--c-ink-soft);
font-variant-numeric: tabular-nums;
}
/* Название и место */
.performance__info {
min-width: 0;
}
.performance__name {
font-family: var(--font-display);
font-size: 19px;
font-weight: 600;
line-height: 1.3;
color: var(--c-ink);
}
.performance__place {
display: flex;
align-items: center;
gap: 6px;
margin-top: 5px;
font-size: 15px;
line-height: 1.45;
color: var(--c-ink-soft);
}
.performance__section-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--c-ink-soft);
}
.performance__numbers {
margin-top: 14px;
}
.performance__numbers .performance__icon {
color: var(--c-violet);
}
.performance__list {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 9px;
}
.performance__list-item {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 5px 14px;
border-radius: var(--r-pill);
font-size: 14.5px;
line-height: 1.4;
}
/* Разноцветная ротация пилюль номеров */
.performance__list-item:nth-child(4n + 1) {
background: var(--c-accent-soft);
color: var(--c-accent-text);
}
.performance__list-item:nth-child(4n + 2) {
background: var(--c-violet-soft);
color: var(--c-violet-text);
}
.performance__list-item:nth-child(4n + 3) {
background: var(--c-pink-soft);
color: var(--c-pink-text);
}
.performance__list-item:nth-child(4n + 4) {
background: var(--c-amber-soft);
color: var(--c-amber-text);
}
.performance__list-item::before {
content: '';
flex: none;
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
opacity: 0.75;
}
.performance__costumes {
margin-top: 14px;
padding: 12px 16px;
border-left: 3px solid var(--c-amber);
border-radius: 0 var(--r-md) var(--r-md) 0;
background: var(--c-amber-soft);
}
.performance__costumes .performance__icon {
color: var(--c-amber);
}
.performance__costumes-text {
margin-top: 6px;
font-size: 15px;
line-height: 1.55;
color: var(--c-ink);
}
/* ---------- Скелетон загрузки ---------- */
.day--skeleton {
min-height: 200px;
}
.skeleton {
height: 14px;
border-radius: 7px;
background: linear-gradient(
90deg,
var(--c-line) 25%,
var(--c-line-strong) 40%,
var(--c-line) 55%
);
background-size: 400% 100%;
animation: shimmer 1.6s linear infinite;
}
.skeleton--title {
width: 40%;
height: 24px;
margin-bottom: 20px;
}
.skeleton--line {
width: 88%;
}
.skeleton--short {
width: 58%;
margin-top: 10px;
}
@keyframes shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
/* ---------- Сообщения (ошибка / пусто) ---------- */
.message-card {
padding: 44px 24px;
border: 1px solid var(--c-line);
border-radius: var(--r-lg);
background: var(--c-surface);
box-shadow: var(--shadow-card);
text-align: center;
}
.message-card__text {
font-size: 18px;
font-weight: 600;
color: var(--c-ink);
}
.message-card__hint {
margin-top: 6px;
font-size: 14px;
color: var(--c-ink-soft);
}
.message-card .button {
margin-top: 20px;
}
.button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 24px;
border: none;
border-radius: var(--r-pill);
background: var(--c-accent);
color: #fff;
font-weight: 600;
cursor: pointer;
transition:
background-color 0.2s ease,
transform 0.15s ease;
}
.button:hover {
background: var(--c-accent-deep);
}
.button:focus-visible {
outline: 2px solid var(--c-accent);
outline-offset: 2px;
}
.button:active {
transform: scale(0.98);
}
/* ---------- Подвал ---------- */
.schedule__footer {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
font-size: 13px;
color: var(--c-ink-faint);
}
.schedule__footer-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--c-accent);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.45;
transform: scale(0.8);
}
} }
</style> </style>
+2 -2
View File
@@ -14,8 +14,8 @@ export const apiGetDays = async (): Promise<Schedule> => {
} }
export function getApiUrl(path: string) { export function getApiUrl(path: string) {
const url = 'https://' + window.location.host.split(':')[0] + path // const url = 'https://' + window.location.host.split(':')[0] + path
// const url = 'http://localhost:8210' + path const url = 'http://localhost:8210' + path
return url return url
} }
+17
View File
@@ -0,0 +1,17 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<polyline points="12 6 12 12 16 14" />
</svg>
</template>
+16
View File
@@ -0,0 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
</svg>
</template>
+18
View File
@@ -0,0 +1,18 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M9 18V5l12-2v13" />
<circle cx="6" cy="18" r="3" />
<circle cx="18" cy="16" r="3" />
</svg>
</template>
+17
View File
@@ -0,0 +1,17 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z" />
<circle cx="12" cy="10" r="3" />
</svg>
</template>
+17
View File
@@ -0,0 +1,17 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M23 4v6h-6" />
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" />
</svg>
</template>
+16
View File
@@ -0,0 +1,16 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<path d="M12 3l2.2 6.6L21 12l-6.8 2.4L12 21l-2.2-6.6L3 12l6.8-2.4L12 3Z" />
</svg>
</template>
+24
View File
@@ -0,0 +1,24 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2" />
<path d="M12 20v2" />
<path d="m4.93 4.93 1.41 1.41" />
<path d="m17.66 17.66 1.41 1.41" />
<path d="M2 12h2" />
<path d="M20 12h2" />
<path d="m6.34 17.66-1.41 1.41" />
<path d="m19.07 4.93-1.41 1.41" />
</svg>
</template>
+58
View File
@@ -0,0 +1,58 @@
import { ref } from 'vue'
export type Theme = 'light' | 'dark'
const THEME_KEY = 'rodniki-theme'
const DARK_CANVAS = '#101a2c'
const LIGHT_CANVAS = '#ffffff'
const theme = ref<Theme>('light')
function readStoredTheme(): Theme | null {
try {
const stored = localStorage.getItem(THEME_KEY)
if (stored === 'light' || stored === 'dark') return stored
} catch {
// localStorage может быть недоступен (приватный режим и т.п.)
}
return null
}
function systemTheme(): Theme {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
}
function applyTheme(value: Theme): void {
document.documentElement.setAttribute('data-theme', value)
document
.querySelector<HTMLMetaElement>('meta[name="theme-color"]')
?.setAttribute('content', value === 'dark' ? DARK_CANVAS : LIGHT_CANVAS)
}
if (typeof window !== 'undefined') {
const initial = readStoredTheme() ?? systemTheme()
theme.value = initial
applyTheme(initial)
// Пока пользователь не выбрал тему вручную — следуем за системной
if (readStoredTheme() === null) {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (event) => {
theme.value = event.matches ? 'dark' : 'light'
applyTheme(theme.value)
})
}
}
export function useTheme() {
function toggleTheme(): void {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
try {
localStorage.setItem(THEME_KEY, theme.value)
} catch {
// ignore
}
applyTheme(theme.value)
}
return { theme, toggleTheme }
}