javascript

От пустоты к идее: как я создал свою первую доску вдохновения

  • воскресенье, 25 января 2026 г. в 00:00:02
https://habr.com/ru/companies/ruvds/articles/987426/
Как выглядит моя доска.
Как выглядит моя доска.

Эта история не о строчках кода, а о том, как мы с другом устали смотреть на пустую доску в офисе. Сначала мы сделали свою версию проекта — с партнёрами и погодой, как мы предполагали по возможным требованиям. Потом я создал версию для себя: с котиками из Giphy API, бегущей строкой цитат и анимированной погодой. В этой статье — о том, как из простого желания добавить красок в рабочее пространство родился личный Preloader перед началом работы, и как пустота корпоративного экрана превратилась в доску вдохновения, которая теперь висит фоном на моём мониторе.

План статьи:

  1. Введение (мотивация и предыстория).

  2. Основная часть (как проходила работа и проект).

  3. Итог (выводы и личные размышления + как менялся сам проект: от начала до конца).

1. Введение: когда пустая доска становится холстом.

Вот как выглядит экран: на нём — старая версия доски, чтобы было понятно, о чём идёт речь.
Вот как выглядит экран: на нём — старая версия доски, чтобы было понятно, о чём идёт речь.

Представьте: на работе висит большая электронная доска. Она должна вдохновлять, мотивировать, информировать, но вместо этого — пустота. Месяцами ничего не было, кроме чёрного экрана, иногда появлялось мотивационное видео для поступающих, но после просмотра возвращаться к своим задачам было ещё грустнее, чем до него. А ведь эта доска висит прямо у входа, её видят все сотрудники каждый день. Раньше там были рамки с партнёрами, и трогать их было нельзя, а потом появилась доска, которая постепенно превратилась в цифровую пустоту.

Однажды мы с другом (он же коллега по разработке) посмотрели на этот экран и подумали: «А ведь это же идеальный проект, над которым можно и даже нужно поработать. Почему бы не сделать что-то, что будет радовать нас каждый день?». Так родилась идея создать свою маленькую «доску настроения», которая будет отражать корпоративные ценности и одновременно что-то настоящее, живое.

Цель — убрать этот скучный чёрный экран, который напоминал нам только о рутине, и сделать что-то своё. Сначала это был просто эксперимент — так, для себя, чтобы просто создавать что-то новое. Но когда начальство увидело нашу задумку, их реакция стала для нас неожиданной. Они сказали: «Поменяйте стиль, но… можно оставить. Это интересно». И у нас зажглись глаза (ненадолго).

1.1 Предыстория: Как логотипы стали котиками.

Сначала мы сделали минимальную версию — просто для себя. Добавили текущее время, погоду и партнёров (т.к. это было первое требование, о котором мы знали до согласования). Эту версию можно увидеть в начале введения. Когда мы показывали результат, получали похвалу, но тут же новые вводные, от которых нельзя отказаться, т. к. это говорит начальство. Думаю, это чувство знакомо любому разработчику (не только на работе, но и на учёбе).

Я, когда сказали, что что-то не нравится и нужно что-то добавить.
Я, когда сказали, что что-то не нравится и нужно что-то добавить.

Но постоянные просьбы «что-то подправить» стали утомлять. Это же была наша инициатива, а не работа по договору. Поэтому мы разделили проект: официальную версию передали на доработку, а для себя оставили ту, что для души.

В итоге у нас родилось два проекта:

  • Официальная версия для компании (её поддерживает мой коллега и как раз передали официальному работнику по таким вопросам).

  • Моя личная версия, которую я иногда включаю фоном (о ней и пойдёт речь). Тут тоже есть официальная версия, но мы её не используем.

И раз уж это моя доска, я решил: здесь будут только те вещи, которые действительно радуют. Котики, мудрые цитаты, танцующие гифки и ничего лишнего)


2. Основная часть: Собираем свою digital-доску.

2.1 Архитектура двойного проекта.

Передо мной стояла задача: создать две версии одной доски в единой структуре, так как официальная у меня уже была, и от неё не хотелось избавляться, а просто добавить ещё один вариант. Архитектура получилась элегантной и гибкой:

docs/
├── pages/           # Официальная версия с партнерами
│   ├── index.html
│   ├── style.css
│   ├── script.js
│   └── img/
│       ├── logo.png
│       ├── ...
│       └── weather/
│           ├── sunny.png
│           └── ...
│
└── version_for_me/  # Личная версия с котиками
    ├── index.html
    ├── style.css
    ├── script.js
    └── img/
        ├── logo.png
        ├── dance.gif
        └── weather/
            ├── sunny.png
            └── ...

Такая структура позволяет:

  • Держать обе версии в одном репозитории и развернуть обе доски на GitHub Pages.

  • Быстро переключаться между проектами.

2.2 Бегущая строка: философия вместо логотипов

Первая идея, которую надо было сделать — это заменить скучные логотипы партнёров. Идея родилась из наблюдения и моего опыта: люди редко смотрят на изображения, но обязательно задержат взгляд на движущемся тексте. Если в официальной версии была карусель с партнёрами, в личной я заменил её бегущей строкой с цитатой, которую можно менять по настроению.

function createQuoteSlider() {
    const quote = "Когда я сплю, я не знаю ни страха, ни надежд, ни трудов, ни блаженств...";
    const track = document.getElementById("quoteTrack");
    
    if (!track) return;
    
    // Очищаем трек
    track.innerHTML = "";
    
    // Создаем элемент с цитатой
    const quoteElement = document.createElement("span");
    quoteElement.className = "quote-text";
    quoteElement.textContent = quote + "     ";
    
    // Добавляем две копии для бесконечной петли
    for (let i = 0; i < 2; i++) {
        const clone = quoteElement.cloneNode(true);
        track.appendChild(clone);
    }
    
    // Динамически рассчитываем скорость
    const quoteLength = quote.length;
    const animationDuration = Math.max(30, Math.floor(quoteLength * 0.3));
    
    // Устанавливаем скорость анимации через CSS
    track.style.animationDuration = `${animationDuration}s`;
    
    // Плавный переход между цитатами
    track.addEventListener('animationiteration', () => {
        track.style.transition = 'none';
        track.style.animation = 'none';
        
        setTimeout(() => {
            track.style.animation = `quote-scroll ${animationDuration}s linear infinite`;
        }, 10);
    });
}
.quote-track {
    display: flex;
    white-space: nowrap;
    animation: quote-scroll 40s linear infinite;
}

@keyframes quote-scroll {
    0% {
        transform: translateX(0);
    }
    100% {
        transform: translateX(-50%);
    }
}

.quote-slider-container {
    width: 90%;
    max-width: 600px;
    overflow: hidden;
    position: relative;
    border-radius: 26px;
    background: rgba(255, 255, 255, 0.3);
    border: 2px solid rgba(255, 255, 255, 1);
    backdrop-filter: blur(10px);
    padding: 20px 0;
    box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
    min-height: 120px;
    display: flex;
    align-items: center;
}

Интерактивность: при наведении курсора анимация приостанавливается, позволяя прочитать цитату полностью — небольшая, но важная деталь.

2.3 Анимированная погода: оживляем данные

Я не хотел показывать просто сухие цифры — хотелось передать атмосферу. Раньше для этого использовались GIF-анимации, а сейчас — просто фотографии разной погоды.

Каждое состояние — солнечно, облачно, дождь, снег — теперь имеет свою иконку-фотографию. Не анимацию, а статичные, но красивые кадры. Эти изображения передают настроение не хуже, чем GIF-анимации.

Погода перестала быть просто сводкой, она стала частью интерфейса, который хочется видеть каждый день.

function getWeatherImage(weatherCode, isDay) {
    const weatherImages = {
        '01': isDay ? './img/weather/sunny.png' : './img/weather/clear-night.png',
        '02': isDay ? './img/weather/partly-cloudy.png' : './img/weather/cloudy-night.png',
        '03': './img/weather/cloudy.png',
        '04': './img/weather/overcast.png',
        '09': './img/weather/rain.png',
        '10': './img/weather/rainy-day.png',
        // ... другие состояния
    };
    
    return weatherImages[weatherCode] || './img/weather/default.png';
}
body {
    background: linear-gradient(to left, #1A59B0, #1A59B0, #1A59B0);
    background-size: 400% 100%;
    animation: gradientMove 60s ease infinite;
    background-repeat: no-repeat;
    background-attachment: fixed;
}

@keyframes gradientMove {
    0% { background-position: 0% 50%; }
    50% { background-position: 100% 50%; }
    100% { background-position: 0% 50%; }
}

2.4 «Живые» элементы: котики и танцы.

Самые простые элементы оказались самыми эффективными в создании настроения. Две гифки — и атмосфера меняется:

Танцующая девочка из мема — локальный файл, символ лёгкости и иронии. Работает всегда, не зависит от интернета, создаёт узнаваемый эмоциональный якорь. Просто файл в папке img/, который всегда на месте.

Случайные коты с сайта — доза неожиданности и уюта. API приносит свежие гифки, а система кэширования оставляет их с собой, даже когда сеть недоступна.

// Константы для Giphy API
const GIPHY_API_KEY = '.........'; // Ключ API
const GIPHY_API_URL = 'https://api.giphy.com/v1/gifs/random';

async function fetchRandomCatGif() {
    try {
        const response = await fetch(
            `${GIPHY_API_URL}?api_key=${GIPHY_API_KEY}&tag=cat&rating=g`
        );
        
        const data = await response.json();
        
        if (data.data && data.data.images && data.data.images.original) {
            return data.data.images.original.url;
        } else {
            return getFallbackCatGif();
        }
        
    } catch (error) {
        console.error('Error fetching cat gif:', error);
        return getFallbackCatGif();
    }
}

Система кэширования: чтобы не загружать API слишком часто и обеспечить работу без интернета, реализована система кэширования через localStorage:

function initializeCatGif() {
    const lastGifTime = localStorage.getItem('lastCatGifTime');
    const lastGifUrl = localStorage.getItem('lastCatGif');
    
    const catGifElement = document.getElementById('cat-gif');
    if (!catGifElement) return;
    
    // Если прошло меньше 5 минут с последней загрузки, используем кэш
    if (lastGifTime && lastGifUrl && (Date.now() - lastGifTime < 5 * 60 * 1000)) {
        catGifElement.src = lastGifUrl;
        catGifElement.alt = "Случайный кот (из кэша)";
    } else {
        updateCatGif();
    }
}

2.5 Система новостей: Google Sheets как бэкенд.

Вместо сложной системы администрирования использован простой и элегантный подход: Google Sheets как база данных для новостей.

const SHEET_ID = '.........';

async function fetchNewsFromGoogleSheets() {
    try {
        const url = `https://docs.google.com/spreadsheets/d/${SHEET_ID}/gviz/tq?tqx=out:json`;
        const response = await fetch(url);
        const text = await response.text();
        const json = JSON.parse(text.substring(47, text.length - 2));
        
        return parseSheetData(json);
    } catch (error) {
        // Резервный источник
        return createDemoNews();
    }
}

Преимущества подхода:

  • Администратор может обновлять контент через привычный интерфейс таблиц.

  • Автоматическое обновление каждые 60 секунд.

Весь код, кому интересно.
CSS
@font-face {
    font-family: "Helvetica";
    src: url("./fonts/Helvetica.woff2") format("woff2"),
         url("./fonts/Helvetica.woff") format("woff");
    font-weight: normal;
    font-style: normal;
}

body {
    min-height: 100vh;
    margin: 0;
    padding: 0;
    font-family: "Helvetica", "Arial", sans-serif;
    background: linear-gradient(to left, #1A59B0, #1A59B0, #1A59B0);
    background-size: 400% 100%;
    animation: gradientMove 60s ease infinite;
    background-repeat: no-repeat;
    background-attachment: fixed;
}

@keyframes gradientMove {
    0% {
        background-position: 0% 50%;
    }
    50% {
        background-position: 100% 50%;
    }
    100% {
        background-position: 0% 50%;
    }
}

.container {
    width: 100%;
    min-height: 100vh;
    box-sizing: border-box;
    padding: 0;
    margin: 0 auto;
    max-width: 1400px; /* Добавляем максимальную ширину для больших экранов */
}

.top-row {
    display: flex;
    justify-content: space-between;
    align-items: stretch;
    width: 100%;
    padding: 20px 3% 10px 3%; /* Используем проценты вместо vw */
    gap: 30px;
    box-sizing: border-box;
}

.main-title-card {
    display: flex;
    flex-direction: column;
    background: transparent;
    border: none;
    color: rgb(255, 255, 255);
    gap: 2px;
    padding: 0;
    flex: 1;
}

.main-title {
    padding: 10px 0 0 0;
    font-size: clamp(14px, 1.5vw, 24px); /* Добавляем clamp для плавного изменения */
    text-align: left;
    width: 100%;
    white-space: nowrap;
    font-weight: normal;
    color: white;
    text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
}

.main-subtitle {
    padding: 0;
    font-weight: 800;
    font-size: clamp(24px, 3vw, 48px); /* clamp для адаптивности */
    text-align: left;
    width: 100%;
    color: white;
    text-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
}

.main-logo {
    width: 110px;
    height: 110px;
    object-fit: contain;
    flex-shrink: 0;
}

.main-widgets {
    width: 100%;
    display: grid;
    grid-template-columns: 50% 50%;
    gap: 20px; /* Используем фиксированные px вместо vw */
    box-sizing: border-box;
    padding: 10px 3%; /* Проценты вместо vw */
    align-items: start;
}

.main-widgets > div:first-child {
    display: flex;
    flex-direction: column;
    gap: 20px; /* Фиксированный gap */
    width: 100%;
}

.admission-news-block {
    display: flex;
    flex-direction: column;
    align-items: center;
}

.admission-news-title {
    color: #ffffff;
    font-size: clamp(20px, 2.5vw, 36px);
    font-weight: bold;
    margin-bottom: 15px;
    text-align: center;
    text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
    width: 100%;
}

.important-block {
    border-radius: 26px;
    background: rgba(255, 255, 255, 0.3);
    border: 2px solid rgba(255, 255, 255, 1);
    backdrop-filter: blur(10px);
    padding: 30px 26px 20px 26px;
    display: flex;
    flex-direction: column;
    align-items: center;
    min-height: 140px;
    box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
    position: relative;
    width: 90%;
    max-width: 600px;
}

.important-content {
    width: 100%;
    min-height: 80px;
    position: relative;
    overflow: hidden;
}

.partners-block {
    display: flex;
    flex-direction: column;
    align-items: center;
    position: relative;
}

.partners-title {
    color: #ffffff;
    font-size: clamp(20px, 2.5vw, 36px);
    font-weight: bold;
    margin-bottom: 20px;
    text-align: center;
    width: 100%;
}

/* Стили для бегущей строки с цитатой */
.quote-slider-container {
    width: 90%;
    max-width: 600px;
    overflow: hidden;
    position: relative;
    border-radius: 26px;
    background: rgba(255, 255, 255, 0.3);
    border: 2px solid rgba(255, 255, 255, 1);
    backdrop-filter: blur(10px);
    padding: 20px 0;
    box-shadow: 0 4px 30px rgba(0, 0, 0, 0.1);
    min-height: 120px;
    display: flex;
    align-items: center;
}

.quote-track {
    display: flex;
    white-space: nowrap;
    animation: quote-scroll 40s linear infinite;
    padding: 0 20px;
    font-size: clamp(16px, 1.8vw, 24px);
    font-weight: 500;
    color: #ffffff;
    text-shadow: 2px 2px 3px rgba(0, 0, 0, 0.5);
}

.quote-track:hover {
    animation-play-state: paused;
}

@keyframes quote-scroll {
    0% {
        transform: translateX(0);
    }
    100% {
        transform: translateX(-50%);
    }
}

.quote-text {
    display: inline-block;
    padding-right: 60px;
    white-space: nowrap;
}

.quote-text:last-child {
    padding-right: 0;
}

.info-block {
    display: flex;
    flex-direction: column;
    gap: 20px;
    height: 100%;
    width: 100%;
    margin-top: 30px;
}

.date-weather-row {
    display: flex;
    gap: 20px;
    align-items: stretch;
}

.weather-time-wrapper {
    position: relative;
    width: 100%;
    padding-top: 15px;
    min-height: 190px;
}

.time-card {
    position: absolute;
    width: 55%;
    max-width: 300px;
    top: -20px;
    right: 15px;
    background: #ffffff;
    color: #004a99;
    border-radius: 16px;
    padding: 10px 18px;
    display: flex;
    align-items: center;
    text-align: center;
    gap: 14px;
    border: 3px solid #ffffff;
    box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3);
    z-index: 2;
}

.time-big {
    font-size: clamp(28px, 3vw, 40px);
    font-weight: 700;
}

.time-date {
    text-align: right;
}

.time-weekday {
    font-size: clamp(12px, 1.2vw, 16px);
    font-weight: 600;
}

.time-fulldate {
    font-size: clamp(10px, 1vw, 14px);
}

.weather-card {
    background: rgba(255, 255, 255, 0.15);
    color: #e1efff;
    border-radius: 16px;
    padding: 30px 18px 15px;
    display: flex;
    flex-direction: column;
    gap: 5px;
    position: relative;
    z-index: 1;
    text-align: center;
    min-height: 190px;
}

.weather-main-row {
    display: grid;
    grid-template-columns: auto 1fr auto;
    gap: 10px;
    align-items: center;
    margin-bottom: 5px;
}

.weather-icon-large {
    width: 85px;
    height: 85px;
    display: flex;
    align-items: center;
    justify-content: center;
}

.weather-icon-large img {
    width: 100%;
    height: 100%;
    object-fit: contain;
    filter: drop-shadow(2px 2px 4px rgba(0, 0, 0, 0.3));
}

.weather-main {
    display: flex;
    flex-direction: column;
    gap: 2px;
    text-align: left;
}

.weather-temp {
    font-size: clamp(28px, 3vw, 34px);
    font-weight: 800;
    color: #ffffff;
    text-shadow: 
        2px 2px 0 #000,
        -1px -1px 0 #000,
        1px -1px 0 #000,
        -1px 1px 0 #000,
        1px 1px 3px #000;
}

.weather-feel {
    font-size: clamp(11px, 1.1vw, 14px);
    color: #b3d9ff;
    font-weight: 500;
    text-shadow: 
        1px 1px 0 #000,
        -1px -1px 0 #000,
        1px -1px 0 #000,
        -1px 1px 0 #000,
        1px 1px 2px #000;
}

.weather-desc {
    font-size: clamp(12px, 1.2vw, 15px);
    color: #ffffff;
    font-weight: 500;
    text-shadow: 
        1px 1px 0 #000,
        -1px -1px 0 #000,
        1px -1px 0 #000,
        -1px 1px 0 #000,
        1px 1px 2px #000;
}

.sun-block {
    text-align: right;
    font-size: clamp(10px, 1vw, 13px);
    display: flex;
    flex-direction: column;
    gap: 3px;
}

.sun-row {
    color: #b3d9ff;
    text-shadow: 
        1px 1px 0 #000,
        -1px -1px 0 #000,
        1px -1px 0 #000,
        -1px 1px 0 #000,
        1px 1px 2px #000;
    font-weight: 500;
}

.sun-time {
    font-weight: 700;
    color: #ffffff;
    margin-left: 5px;
    text-shadow: 
        1px 1px 0 #000,
        -1px -1px 0 #000,
        1px -1px 0 #000,
        -1px 1px 0 #000,
        1px 1px 3px #000;
}

.weather-footer {
    display: flex;
    justify-content: space-between;
    gap: 10px;
    font-size: clamp(10px, 1vw, 13px);
    margin-top: 10px;
    padding-top: 8px;
    border-top: 1px solid rgba(255, 255, 255, 0.2);
}

.weather-item {
    flex: 1;
    text-align: center;
}

.weather-item span {
    color: #b3d9ff;
    margin: 0;
    display: block;
    font-size: clamp(9px, 0.9vw, 12px);
    font-weight: 600;
    text-shadow: 
        1px 1px 0 #000,
        -1px -1px 0 #000,
        1px -1px 0 #000,
        -1px 1px 0 #000,
        1px 1px 2px #000;
}

.weather-item b {
    color: #ffffff;
    font-size: clamp(11px, 1.1vw, 14px);
    font-weight: 800;
    display: block;
    margin-top: 2px;
    text-shadow: 
        1px 1px 0 #000,
        -1px -1px 0 #000,
        1px -1px 0 #000,
        -1px 1px 0 #000,
        1px 1px 3px #000;
}

.entertainment-block {
    margin-top: 20px;
    margin-left: 0;
    width: 100%;
    display: flex;
    justify-content: center;
}

.entertainment-vertical {
    display: flex;
    flex-direction: column;
    gap: 15px;
    align-items: center;
    width: 100%;
    max-width: 500px;
}

.entertainment-item {
    display: flex;
    align-items: center;
    gap: 20px;
    background: transparent;
    border: none;
    backdrop-filter: none;
    padding: 0;
    box-shadow: none;
    width: 100%;
    justify-content: center;
}

.entertainment-wrapper {
    background: transparent;
    border-radius: 0;
    border: none;
    backdrop-filter: none;
    padding: 0;
    box-shadow: none;
    overflow: hidden;
    flex-shrink: 0;
}

.entertainment-wrapper img {
    width: 140px;
    height: 140px;
    object-fit: contain;
}

.entertainment-text {
    color: #ffffff;
    font-size: clamp(14px, 1.2vw, 18px);
    line-height: 1.4;
    flex: 1;
    max-width: 300px;
}

.entertainment-text .first-line {
    font-weight: normal;
    display: block;
    margin-bottom: 5px;
}

.entertainment-text .second-line {
    font-weight: bold;
    display: block;
}

#dance-gif,
#cat-gif {
    width: 140px;
    height: 140px;
    object-fit: contain;
}

.modal-bg {
    display: none;
    position: fixed;
    left: 0;
    top: 0;
    width: 100vw;
    height: 100vh;
    background: rgba(0, 0, 0, 0.5);
    z-index: 2000;
    align-items: center;
    justify-content: center;
}

.modal-window {
    background: #f3faff;
    border-radius: 18px;
    max-width: 400px;
    padding: 30px 24px 18px;
    box-shadow: 0 8px 32px #377ce099;
    text-align: center;
    position: relative;
}

.modal-title {
    font-size: 20px;
    font-weight: 700;
    margin-bottom: 12px;
    color: #1563b5;
}

.modal-close {
    position: absolute;
    right: 12px;
    top: 12px;
    font-size: 26px;
    color: #174fb6;
    cursor: pointer;
}

.modal-text {
    font-size: 14px;
    color: #333;
    line-height: 1.5;
}

.bg-words {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    z-index: -100;
    pointer-events: none;
    overflow: hidden;
}

.bg-word {
    position: absolute;
    width: auto;
    font-weight: 900;
    color: #2a74c5;
    user-select: none;
    white-space: nowrap;
    animation: moveWord 9s linear infinite;
}

@keyframes moveWord {
    from {
        top: var(--start-top, -20vh);
    }
    to {
        top: 110vh;
    }
}

.news-item {
    padding: 10px 15px;
    text-align: left;
    opacity: 1;
    transform: translateX(0);
    transition: all 0.3s ease;
}

.news-title {
    font-size: clamp(16px, 1.6vw, 24px);
    color: #ffffff;
    line-height: 1.4;
    font-weight: 700;
    margin-bottom: 5px;
    text-align: left;
    text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
}

.news-date {
    font-size: clamp(12px, 1.1vw, 16px);
    color: #b3d9ff;
    font-weight: 500;
    text-align: left;
    text-shadow: 1px 1px 1px rgba(0, 0, 0, 0.3);
}

.news-slide-out {
    animation: newsSlideOut 0.3s ease forwards;
}

@keyframes newsSlideOut {
    from {
        opacity: 1;
        transform: translateX(0);
    }
    to {
        opacity: 0;
        transform: translateX(-100px);
    }
}

.news-slide-in {
    animation: newsSlideIn 0.5s ease forwards;
    opacity: 0;
    transform: translateX(100px);
}

@keyframes newsSlideIn {
    from {
        opacity: 0;
        transform: translateX(100px);
    }
    to {
        opacity: 1;
        transform: translateX(0);
    }
}

.news-pagination {
    position: absolute;
    right: 15px;
    top: 50%;
    transform: translateY(-50%);
    display: flex;
    flex-direction: column;
    gap: 6px;
}

.news-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    background: rgba(0, 0, 0, 0.3);
    border: 1px solid rgba(255, 255, 255, 1);
    cursor: pointer;
    transition: all 0.3s ease;
    box-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
}

.news-dot.active {
    background: rgba(255, 255, 255, 1);
    border: 1px solid rgba(255, 255, 255, 0.7);
    transform: scale(1.2);
    box-shadow: 0 0 2px rgba(0, 0, 0, 0.7);
}

/* Адаптивность для планшетов и ноутбуков */
@media (max-width: 1200px) {
    .main-widgets {
        grid-template-columns: 1fr;
        gap: 25px;
    }
    
    .weather-time-wrapper {
        min-height: 180px;
    }
    
    .weather-icon-large {
        width: 75px;
        height: 75px;
    }
    
    .entertainment-wrapper img,
    #dance-gif,
    #cat-gif {
        width: 120px;
        height: 120px;
    }
}

/* Адаптивность для планшетов */
@media (max-width: 992px) {
    .top-row {
        padding: 15px 4% 8px 4%;
    }
    
    .main-widgets {
        padding: 10px 4%;
    }
    
    .weather-card {
        padding: 25px 15px 10px;
    }
    
    .entertainment-item {
        gap: 15px;
    }
    
    .entertainment-wrapper img,
    #dance-gif,
    #cat-gif {
        width: 110px;
        height: 110px;
    }
}

/* Адаптивность для мобильных */
@media (max-width: 768px) {
    .container {
        width: 100%;
        min-height: 100vh;
        margin: 0 auto;
        padding: 0;
    }
    
    .top-row {
        flex-direction: column;
        align-items: center;
        gap: 15px;
        padding: 10px 5% 5px;
    }
    
    .main-title-card {
        text-align: center;
        align-items: center;
        width: 100%;
    }
    
    .main-title {
        font-size: 16px;
        text-align: center;
        white-space: normal;
        padding: 5px 0 0 0;
    }
    
    .main-subtitle {
        font-size: 28px;
        text-align: center;
        line-height: 1.2;
    }
    
    .main-logo {
        width: 80px;
        height: 80px;
        order: -1;
        margin-bottom: 10px;
    }
    
    .main-widgets {
        grid-template-columns: 1fr;
        gap: 20px;
        padding: 10px 5%;
    }
    
    .main-widgets > div:first-child {
        gap: 20px;
    }
    
    .admission-news-title,
    .partners-title {
        font-size: 22px;
        margin-bottom: 10px;
    }
    
    .important-block,
    .quote-slider-container {
        width: 100%;
        padding: 20px 15px 15px;
        min-height: 110px;
    }
    
    .quote-track {
        font-size: 18px;
        animation-duration: 50s;
    }
    
    .weather-time-wrapper {
        min-height: 170px;
        padding-top: 10px;
    }
    
    .time-card {
        width: 85%;
        top: -15px;
        right: 10px;
        padding: 8px 12px;
    }
    
    .time-big {
        font-size: 28px;
    }
    
    .weather-card {
        padding: 25px 12px 10px;
        min-height: 170px;
    }
    
    .weather-main-row {
        grid-template-columns: 1fr;
        text-align: center;
        gap: 8px;
    }
    
    .weather-icon-large {
        width: 70px;
        height: 70px;
        margin: 0 auto;
        order: -1;
    }
    
    .weather-main {
        text-align: center;
        order: 0;
        align-items: center;
    }
    
    .weather-temp {
        font-size: 30px;
    }
    
    .sun-block {
        text-align: center;
        margin-top: 8px;
        order: 1;
        align-items: center;
    }
    
    .weather-footer {
        flex-direction: column;
        gap: 8px;
        margin-top: 10px;
        padding-top: 10px;
    }
    
    .weather-item {
        display: flex;
        justify-content: space-between;
        align-items: center;
        padding: 5px 0;
        border-bottom: 1px solid rgba(255, 255, 255, 0.1);
        width: 100%;
    }
    
    .weather-item:last-child {
        border-bottom: none;
    }
    
    .entertainment-block {
        margin-top: 15px;
    }
    
    .entertainment-vertical {
        gap: 20px;
        align-items: center;
    }
    
    .entertainment-item {
        flex-direction: column;
        text-align: center;
        gap: 10px;
        width: 100%;
    }
    
    .entertainment-wrapper img,
    #dance-gif,
    #cat-gif {
        width: 130px;
        height: 130px;
    }
    
    .entertainment-text {
        text-align: center;
        font-size: 16px;
    }
    
    .news-title {
        font-size: 18px;
    }
    
    .news-date {
        font-size: 14px;
    }
}

/* Для очень маленьких экранов */
@media (max-width: 480px) {
    .main-subtitle {
        font-size: 24px;
    }
    
    .admission-news-title,
    .partners-title {
        font-size: 20px;
    }
    
    .time-card {
        width: 90%;
    }
    
    .time-big {
        font-size: 24px;
    }
    
    .weather-temp {
        font-size: 26px;
    }
    
    .quote-track {
        font-size: 16px;
    }
    
    .entertainment-wrapper img,
    #dance-gif,
    #cat-gif {
        width: 110px;
        height: 110px;
    }
    
    .entertainment-text {
        font-size: 14px;
    }
}
JS
// Константы для Giphy API
const GIPHY_API_KEY = '..............';
const GIPHY_API_URL = 'https://api.giphy.com/v1/gifs/random';

// Функция для получения случайной гифки с котами
async function fetchRandomCatGif() {
    try {
        const response = await fetch(`${GIPHY_API_URL}?api_key=${GIPHY_API_KEY}&tag=cat&rating=g`);
        
        if (!response.ok) {
            throw new Error(`Giphy API error: ${response.status}`);
        }
        
        const data = await response.json();
        
        if (data.data && data.data.images && data.data.images.original) {
            return data.data.images.original.url;
        } else {
            return getFallbackCatGif();
        }
        
    } catch (error) {
        console.error('Error fetching cat gif:', error);
        return getFallbackCatGif();
    }
}

// Функция для получения резервной гифки (если API не работает)
function getFallbackCatGif() {
    const fallbackGifs = [
        'https://giphy.com/gifs/901mxGLGQN2PyCQpochttps://media0.giphy.com/media/v1.Y2lkPTc5MGI3NjExc2M4N2JrY290ODQ0dzI4b2VneGp2MzFoZDk5dXd5NGhrOTd5ZXpvYyZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/901mxGLGQN2PyCQpoc/giphy.gif',
        'https://media4.giphy.com/media/v1.Y2lkPTc5MGI3NjExOTNxYW94cXQwM2d1ejk3Y3RjbThqNHp0a3NuNTI5bW01dGU0Y2c1byZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/Ev477g37MJORyOWfdG/giphy.gif',
        'https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExeHljdzBhZmpvb20wYzE4cDhlcDVvMDRjOWc1YW9kMWN5amIzNjFhdSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/vPzbDN4rBxuvtpSpzF/giphy.gif',
        'https://media0.giphy.com/media/v1.Y2lkPTc5MGI3NjExd24zODhlYWJyd3hudXJkanl0aGtveGxxNHoxdTJxMWUwdmhxOHkzZSZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/MDJ9IbxxvDUQM/giphy.gif',
        'https://media3.giphy.com/media/v1.Y2lkPTc5MGI3NjExbXl6MjF1cml2aGpubWw3dmtmdGlod2dpNjQxMWVxcWt2ajZuNzFzYiZlcD12MV9pbnRlcm5hbF9naWZfYnlfaWQmY3Q9Zw/vFKqnCdLPNOKc/giphy.gif'
    ];
    
    const randomIndex = Math.floor(Math.random() * fallbackGifs.length);
    return fallbackGifs[randomIndex];
}

// Функция для обновления гифки с котом
async function updateCatGif() {
    const catGifElement = document.getElementById('cat-gif');
    if (!catGifElement) return;
    
    try {
        const gifUrl = await fetchRandomCatGif();
        catGifElement.src = gifUrl;
        catGifElement.alt = "Случайный кот";
        
        localStorage.setItem('lastCatGif', gifUrl);
        localStorage.setItem('lastCatGifTime', Date.now());
        
    } catch (error) {
        console.error('Failed to update cat gif:', error);
        catGifElement.src = getFallbackCatGif();
        catGifElement.alt = "Кот (резервный вариант)";
    }
}

// Функция инициализации гифки (с кэшированием на 5 минут)
function initializeCatGif() {
    const lastGifTime = localStorage.getItem('lastCatGifTime');
    const lastGifUrl = localStorage.getItem('lastCatGif');
    
    const catGifElement = document.getElementById('cat-gif');
    if (!catGifElement) return;
    
    // Если прошло меньше 5 минут с последней загрузки, используем кэшированную гифку
    if (lastGifTime && lastGifUrl && (Date.now() - lastGifTime < 5 * 60 * 1000)) {
        catGifElement.src = lastGifUrl;
        catGifElement.alt = "Случайный кот (из кэша)";
    } else {
        // Иначе загружаем новую
        updateCatGif();
    }
}

function syncHeaderBlocks() {
    const titleCard = document.getElementById("mainTitleCard");
    const weatherWidget = document.querySelector(".weather-time-wrapper");
    if (titleCard && weatherWidget) {
        weatherWidget.style.minHeight = titleCard.offsetHeight + "px";
    }
}
window.addEventListener("resize", syncHeaderBlocks);
window.addEventListener("DOMContentLoaded", syncHeaderBlocks);

// Бегущая строка с цитатой (исправленная версия, т.к. цитата не полная)
function createQuoteSlider() {
    const quote = "Когда я сплю, я не знаю ни страха, ни надежд, ни трудов, ни блаженств. Спасибо тому, кто изобрел сон. Это единые часы, ровняющие пастуха и короля, дуралея и мудреца. Одним только плох крепкий сон, говорят, что он смахивает на .....";
    const track = document.getElementById("quoteTrack");
    
    if (!track) return;
    
    // Очищаем трек
    track.innerHTML = "";
    
    // Создаем элемент с цитатой
    const quoteElement = document.createElement("span");
    quoteElement.className = "quote-text";
    quoteElement.textContent = quote + "     "; // Добавляем пробелы для разделения
    
    // Добавляем две копии для плавного перехода (бесконечная петля)
    for (let i = 0; i < 2; i++) {
        const clone = quoteElement.cloneNode(true);
        track.appendChild(clone);
    }
    
    // Рассчитываем длину цитаты и устанавливаем скорость анимации
    const quoteLength = quote.length;
    // Медленная скорость: примерно 10 секунд на 100 символов
    const animationDuration = Math.max(30, Math.floor(quoteLength * 0.3));
    
    // Устанавливаем скорость анимации через CSS
    track.style.animationDuration = `${animationDuration}s`;
    
    // Добавляем событие для перезапуска анимации при её завершении
    track.addEventListener('animationiteration', () => {
        // Плавный переход между цитатами
        track.style.transition = 'none';
        track.style.animation = 'none';
        
        // Небольшая задержка для сброса
        setTimeout(() => {
            track.style.animation = `quote-scroll ${animationDuration}s linear infinite`;
        }, 10);
    });
}

let newsItems = [];
let currentNewsIndex = 0;
let isNewsAnimating = false;

const SHEET_ID = '........';

async function fetchNewsFromGoogleSheets() {
    try {
        const url = `https://docs.google.com/spreadsheets/d/${SHEET_ID}/gviz/tq?tqx=out:json`;
        
        const response = await fetch(url);
        
        if (!response.ok) {
            throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const text = await response.text();
        const json = JSON.parse(text.substring(47, text.length - 2));
        
        return parseSheetData(json);
        
    } catch (error) {
        try {
            const proxyUrl = 'https://api.allorigins.win/raw?url=';
            const encodedUrl = encodeURIComponent(`https://docs.google.com/spreadsheets/d/${SHEET_ID}/gviz/tq?tqx=out:json`);
            
            const response = await fetch(proxyUrl + encodedUrl);
            const text = await response.text();
            const json = JSON.parse(text.substring(47, text.length - 2));
            
            return parseSheetData(json);
            
        } catch (proxyError) {
            return null;
        }
    }
}

function parseSheetData(json) {
    const newsItems = [];
    
    if (json.table && json.table.rows) {
        let startIndex = 0;
        
        if (json.table.rows[0] && json.table.rows[0].c) {
            const firstRow = json.table.rows[0].c;
            const firstTitle = firstRow[1]?.v || '';
            const firstDate = firstRow[0]?.v || '';
            
            if (firstTitle.toLowerCase().includes('заголовок') || 
                firstDate.toLowerCase().includes('дата')) {
                startIndex = 1;
            }
        }
        
        for (let i = startIndex; i < json.table.rows.length; i++) {
            const row = json.table.rows[i];
            const cells = row.c;
            
            if (cells && cells.length >= 2) {
                const title = cells[1]?.v || '';
                const dateValue = cells[0]?.v || '';
                
                let formattedDate = '';
                if (dateValue) {
                    if (typeof dateValue === 'string' && dateValue.startsWith('Date(')) {
                        formattedDate = parseDateObject(dateValue);
                    } else {
                        formattedDate = dateValue;
                    }
                } else {
                    formattedDate = formatCurrentDate();
                }
                
                if (title && title.trim() !== '') {
                    newsItems.push({
                        title: title,
                        date: formattedDate,
                        link: '#'
                    });
                }
            }
        }
    }
    
    return newsItems;
}

function parseDateObject(dateString) {
    try {
        const match = dateString.match(/Date\((\d+),(\d+),(\d+)\)/);
        if (match) {
            const year = parseInt(match[1]);
            const month = parseInt(match[2]) + 1;
            const day = parseInt(match[3]);
            
            return `${day.toString().padStart(2, '0')}.${month.toString().padStart(2, '0')}.${year}`;
        }
    } catch (error) {
    }
    
    return formatCurrentDate();
}

function formatCurrentDate() {
    const now = new Date();
    const day = now.getDate().toString().padStart(2, '0');
    const month = (now.getMonth() + 1).toString().padStart(2, '0');
    const year = now.getFullYear();
    return `${day}.${month}.${year}`;
}

function createDemoNews() {
    const demoNews = [
        {
            title: "Более 800 абитуриентов посетили День открытых дверей",
            date: "17.11.2024",
            link: "#"
        },
        {
            title: "РТУ МИРЭА приглашает к участию в олимпиаде",
            date: "27.11.2024",
            link: "#"
        }
    ];
    
    return demoNews;
}

async function initializeNews() {
    let loadedNews = await fetchNewsFromGoogleSheets();
    
    if (!loadedNews || loadedNews.length === 0) {
        loadedNews = createDemoNews();
    }
    
    newsItems = loadedNews;
    
    if (newsItems.length > 0) {
        renderNews();
        setupNewsPagination();
        return true;
    }
    
    return false;
}

async function updateNews() {
    const loadedNews = await fetchNewsFromGoogleSheets();
    
    if (loadedNews && loadedNews.length > 0) {
        newsItems = loadedNews;
        
        if (currentNewsIndex >= newsItems.length) {
            currentNewsIndex = 0;
        }
        
        renderNews();
        setupNewsPagination();
    }
}

function renderNews() {
    const content = document.querySelector('.important-content');
    if (!content || newsItems.length === 0) {
        return;
    }
    
    const currentNews = newsItems[currentNewsIndex];
    
    content.innerHTML = `
        <div class="news-item ${isNewsAnimating ? 'news-slide-out' : ''}">
            <div class="news-title">${currentNews.title}</div>
            <div class="news-date">${currentNews.date}</div>
        </div>
    `;
    
    if (isNewsAnimating) {
        setTimeout(() => {
            content.innerHTML = `
                <div class="news-item news-slide-in">
                    <div class="news-title">${currentNews.title}</div>
                    <div class="news-date">${currentNews.date}</div>
                </div>
            `;
            
            setTimeout(() => {
                isNewsAnimating = false;
            }, 500);
        }, 300);
    }
    
    updateNewsPagination();
}

function setupNewsPagination() {
    const importantBlock = document.querySelector('.important-block');
    if (!importantBlock) return;
    
    const oldPagination = importantBlock.querySelector('.news-pagination');
    if (oldPagination) {
        oldPagination.remove();
    }
    
    const pagination = document.createElement('div');
    pagination.className = 'news-pagination';
    
    let dotsHtml = '';
    const dotsToShow = Math.min(newsItems.length, 5);
    
    for (let i = 0; i < dotsToShow; i++) {
        dotsHtml += `<span class="news-dot ${i === currentNewsIndex ? 'active' : ''}" data-index="${i}"></span>`;
    }
    
    pagination.innerHTML = dotsHtml;
    importantBlock.appendChild(pagination);
    
    pagination.querySelectorAll('.news-dot').forEach(dot => {
        dot.addEventListener('click', function() {
            const index = parseInt(this.getAttribute('data-index'));
            if (index !== currentNewsIndex && !isNewsAnimating) {
                currentNewsIndex = index;
                isNewsAnimating = true;
                renderNews();
            }
        });
    });
}

function updateNewsPagination() {
    const dots = document.querySelectorAll('.news-dot');
    dots.forEach((dot, index) => {
        if (index === currentNewsIndex) {
            dot.classList.add('active');
        } else {
            dot.classList.remove('active');
        }
    });
}

function nextNews() {
    if (isNewsAnimating || newsItems.length === 0) return;
    
    isNewsAnimating = true;
    currentNewsIndex = (currentNewsIndex + 1) % newsItems.length;
    renderNews();
}

function leading0(n) {
    return n < 10 ? "0" + n : n;
}

function getRuWeekdayFull(d) {
    return ["воскресенье", "понедельник", "вторник", "среда",
        "четверг", "пятница", "суббота"][d.getDay()];
}

function capitalizeFirst(str) {
    return str.charAt(0).toUpperCase() + str.slice(1);
}

function formatRuDate(d) {
    const formatter = new Intl.DateTimeFormat("ru-RU", {
        day: "numeric",
        month: "long"
    });
    const parts = formatter.formatToParts(d);
    const dayPart = parts.find((p) => p.type === "day")?.value || "";
    const monthPart = parts.find((p) => p.type === "month")?.value || "";
    return `${dayPart} ${monthPart}`;
}

function updateDateTime() {
    const now = new Date();
    
    document.getElementById("time").textContent =
        `${leading0(now.getHours())}:${leading0(now.getMinutes())}`;
    
    const weekday = getRuWeekdayFull(now);
    document.getElementById("weekdayText").textContent =
        capitalizeFirst(weekday);
    document.getElementById("fulldate").textContent = formatRuDate(now);
    
    setTimeout(syncHeaderBlocks, 50);
}

const API_KEY = "........";
const CITY = "Moscow";

function getWeatherImage(weatherCode, isDay) {
    const weatherImages = {
        '01': isDay ? './img/weather/sunny.png' : './img/weather/clear-night.png',
        '02': isDay ? './img/weather/partly-cloudy.png' : './img/weather/cloudy-night.png',
        '03': './img/weather/cloudy.png',
        '04': './img/weather/overcast.png',
        '09': './img/weather/rain.png',
        '10': './img/weather/rainy-day.png',
        '11': './img/weather/thunderstorm.png',
        '13': './img/weather/snow.png',
        '50': './img/weather/fog.png'
    };
    
    return weatherImages[weatherCode] || './img/weather/default.png';
}

function fetchWeather() {
    fetch(
        `https://api.openweathermap.org/data/2.5/weather?q=${CITY}&units=metric&lang=ru&appid=${API_KEY}`
    )
        .then((r) => r.json())
        .then((data) => {
            const temp = data.main?.temp;
            const feel = data.main?.feels_like;
            const desc = data.weather?.[0]?.description || "";

            document.getElementById("weather-temp").textContent =
                data.main
                    ? (temp > 0 ? "+" : "") + Math.round(temp) + "°"
                    : "--";

            document.getElementById("weather-feel").textContent =
                data.main
                    ? `По ощущениям ${(feel > 0 ? "+" : "") + Math.round(feel)}°`
                    : "";

            document.getElementById("weather-desc").textContent =
                desc ? desc[0].toUpperCase() + desc.slice(1) : "";

            document.getElementById("weather-wind").textContent =
                data.wind ? `${data.wind.speed} м/с` : "--";

            document.getElementById("weather-pressure").textContent =
                data.main
                    ? `${Math.round(data.main.pressure * 0.750062)} мм рт. ст.`
                    : "--";

            document.getElementById("weather-humidity").textContent =
                data.main ? `${data.main.humidity}%` : "--";

            const formatTime = (ts) => {
                if (!ts) return "--:--";
                const d = new Date(ts * 1000);
                return leading0(d.getHours()) + ":" + leading0(d.getMinutes());
            };

            document.getElementById("sunrise").textContent =
                formatTime(data.sys?.sunrise);
            document.getElementById("sunset").textContent =
                formatTime(data.sys?.sunset);
            
            if (data.weather && data.weather[0]) {
                const weatherCode = data.weather[0].icon.substring(0, 2);
                const isDay = data.weather[0].icon.includes('d');
                const weatherIcon = document.getElementById('weather-icon');
                if (weatherIcon) {
                    const imageUrl = getWeatherImage(weatherCode, isDay);
                    weatherIcon.innerHTML = `<img src="${imageUrl}" alt="${data.weather[0].description}" />`;
                }
            }
        })
        .catch(() => {
            document.getElementById("weather-temp").textContent = "--";
            document.getElementById("weather-feel").textContent = "";
            document.getElementById("weather-desc").textContent = "";
            
            const weatherIcon = document.getElementById('weather-icon');
            if (weatherIcon) {
                weatherIcon.innerHTML = `<img src="./img/weather/default.png" alt="Погода" />`;
            }
        });
}

document.addEventListener('DOMContentLoaded', function() {
    // Инициализация всех компонентов
    initializeCatGif();
    
    // Создаем бегущую строку с цитатой
    createQuoteSlider();
    
    // Новости
    initializeNews();
    
    // Таймеры для обновления
    setInterval(updateNews, 60000);
    setInterval(nextNews, 10000);
    
    // Дата и время
    updateDateTime();
    setInterval(updateDateTime, 1000);
    
    // Погода
    fetchWeather();
    setInterval(fetchWeather, 600000);
    
    // Обновление гифки с котом
    setInterval(updateCatGif, 300000);
});
HTML
<!DOCTYPE html>
<html lang="ru">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=0.95">
        <title>Детский технопарк «Альтаир»</title>
        <link rel="stylesheet" href="style.css" />
    </head>
    <body>
        <div class="bg-words"></div>
        <div class="container">
            <div class="top-row">
                <div class="main-title-card" id="mainTitleCard">
                    <span class="main-title">Добро пожаловать в клуб крошка!</span>
                    <span class="main-subtitle">«что-то важное»</span>
                </div>
                <div> 
                    <img
                        src="./img/logo.png"
                        alt="Герб Альтаир"
                        class="main-logo"
                    />
                </div>
            </div>
            <div class="main-widgets">
                <div>
                    <div class="admission-news-block">
                        <div class="admission-news-title">Новости и желания</div>
                        <div class="important-block">
                            <div class="important-content"></div>
                        </div>
                    </div>

                    <div class="partners-block">
                        <div class="partners-title">Цитата для меня</div>
                        <div class="quote-slider-container">
                            <div class="quote-track" id="quoteTrack">
                                <!-- Текст будет добавлен JavaScript -->
                            </div>
                        </div>
                    </div>
                </div>
                
                <div class="info-block">
                    <div class="date-weather-row">
                        <div class="weather-time-wrapper">
                            <div class="time-card">
                                <div class="time-big" id="time">10:30</div>
                                <div class="time-date">
                                    <div class="time-weekday" id="weekdayText">Понедельник</div>
                                    <div class="time-fulldate" id="fulldate">24 ноября</div>
                                </div>
                            </div>

                            <div class="weather-card">
                                <div class="weather-main-row">
                                    <div class="weather-icon-large" id="weather-icon"></div>
                                    <div class="weather-main">
                                        <div class="weather-temp" id="weather-temp">--</div>
                                        <div class="weather-feel" id="weather-feel"></div>
                                        <div class="weather-desc" id="weather-desc">--</div>
                                    </div>
                                    <div class="sun-block">
                                        <div class="sun-row">
                                            Восход:
                                            <span class="sun-time" id="sunrise">--:--</span>
                                        </div>
                                        <div class="sun-row">
                                            Закат:
                                            <span class="sun-time" id="sunset">--:--</span>
                                        </div>
                                    </div>
                                </div>

                                <div class="weather-footer">
                                    <div class="weather-item">
                                        <span>Ветер</span><br />
                                        <b id="weather-wind">--</b>
                                    </div>
                                    <div class="weather-item">
                                        <span>Давление</span><br />
                                        <b id="weather-pressure">--</b>
                                    </div>
                                    <div class="weather-item">
                                        <span>Влажность</span><br />
                                        <b id="weather-humidity">--</b>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>

                    <div class="entertainment-block">
                        <div class="entertainment-vertical">
                            <div class="entertainment-item">
                                <div class="entertainment-wrapper">
                                    <img src="./img/dance.gif" alt="Развлечение" id="dance-gif" />
                                </div>
                                <div class="entertainment-text">
                                    <span class="first-line">Развлечение</span>
                                    <span class="second-line">Танцуем когда не знаем что делать</span>
                                </div>
                            </div>
                            <div class="entertainment-item">
                                <div class="entertainment-wrapper">
                                    <img src="./img/dance.gif" alt="Пауза" id="cat-gif" />
                                </div>
                                <div class="entertainment-text">
                                    <span class="first-line">Пауза</span>
                                    <span class="second-line">Смотрим на котиков во время перерыва</span>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>

        <div class="modal-bg" id="modal-bg">
            <div class="modal-window">
                <span class="modal-close" id="modal-close">&times;</span>
                <div class="modal-title" id="modal-title"></div>
                <div class="modal-text" id="modal-text"></div>
            </div>
        </div>

        <script src="script.js"></script>
    </body>
</html>

3. Итог: что получилось и какие выводы.

3.1 Что получилось:

  1. Живая погода — связь с реальным миром, которая обновляется каждые 10 минут.

    Погода.
    Погода.
  2. Случайные котики — доза радости и неожиданности, которые обновляются каждые 5 минут.

    Gif, которые радуют глаз.
    Gif, которые радуют глаз.
  3. Философская цитата — вместо рекламы партнёров, что-то для души)

  4. Новости — лента обновляется из Google Sheets.

3.2 Личные размышления:

А самое забавное, что по факту из всей доски меня греют только котики и погода. Всё остальное — так, фон. В этом есть своя ирония — самые простые вещи оказались самыми ценными. Все рабочие правки и изменения не идут ни в какое сравнение с внезапным появлением нового кота или проверкой, что сегодня на улице.

3.3 Заключение:

Создание такой доски — это больше, чем код. Это шанс сделать цифровое пространство вокруг чуть человечнее, чуть «своим». В мире корпоративных логотипов и шаблонных интерфейсов такие островки личного контента становятся оазисами.

Код проекта простой и открытый. Можете взять его за основу и сделать свою версию (только просьба поменять api ключи на свои, а то будет некрасиво) — с котиками или без, с цитатами или мемами, с анимацией или в минимализме.

А что можно добавить дальше? Например, привязать доску к календарю — чтобы она показывала ближайшие встречи или напоминала о днях рождения коллег. Можно подключить простой To-Do список на день, если хочется держать задачи перед глазами.

Важно — не перегружать доску, чтобы каждый блок оставался лёгким и не превращать доску в панель управления космическим кораблём.

Потому что иногда достаточно одного честно сделанного элемента — новой иконки, плавной анимации или просто удобного отображения времени — чтобы старый, казалось бы, знакомый проект снова заиграл и стал тем, на что приятно смотреть. Главное — чтобы это пространство стало вашим и его хотелось открывать.


Если у вас есть идеи по улучшению или вы обнаружили какие-либо недочёты, буду рад обратной связи. Лучшие предложения могут быть реализованы в следующих версиях проекта или стать идеей для следующей статьи.

Если кто-то хочет увидеть проект в жизни — Доска_V1 и Доска_V2.

Полный код проекта доступен в репозитории GitHub.

Как менялся проект от начала до конца:
Версия проекта 1.
Версия проекта 1.
Версия проекта 2.
Версия проекта 2.
Версия проекта 3.
Версия проекта 3.
Версия проекта 4.
Версия проекта 4.
Версия проекта 5.
Версия проекта 5.
Версия проекта 6.
Версия проекта 6.
Версия проекта 7.
Версия проекта 7.

© 2026 ООО «МТ ФИНАНС»