├── .gitignore
├── assets
├── og.png
├── favicon.ico
├── twitter.png
├── beerjs.svg.gz
├── favicon-16x16.png
├── favicon-32x32.png
├── robots.txt
├── apple-touch-icon.png
├── mstile-150x150.png
├── android-chrome-192x192.png
├── apple-touch-icon-60x60.png
├── apple-touch-icon-76x76.png
├── apple-touch-icon-120x120.png
├── apple-touch-icon-152x152.png
├── apple-touch-icon-180x180.png
├── sitemap.xml
├── manifest.json
├── browserconfig.xml
├── beerjs.svg
├── safari-pinned-tab.svg
└── bubbles.js
├── stickers
├── beerjs_moscow.ai
├── beerjs_moscow.pdf
├── beerjs_original.ai
└── beerjs_original.pdf
├── package.json
├── server.js
├── .github
└── workflows
│ └── sync-airtable.yml
├── chatrules.md
├── check-airtable-structure.js
├── templates
└── index.js
├── sync-from-airtable.js
└── README.md
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | .env
3 | .env.local
4 |
--------------------------------------------------------------------------------
/assets/og.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/assets/og.png
--------------------------------------------------------------------------------
/assets/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/assets/favicon.ico
--------------------------------------------------------------------------------
/assets/twitter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/assets/twitter.png
--------------------------------------------------------------------------------
/assets/beerjs.svg.gz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/assets/beerjs.svg.gz
--------------------------------------------------------------------------------
/assets/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/assets/favicon-16x16.png
--------------------------------------------------------------------------------
/assets/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/assets/favicon-32x32.png
--------------------------------------------------------------------------------
/assets/robots.txt:
--------------------------------------------------------------------------------
1 | Sitemap: https://beerjs.moscow/sitemap.xml
2 | User-agent: *
3 | Allow: /
4 |
--------------------------------------------------------------------------------
/assets/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/assets/apple-touch-icon.png
--------------------------------------------------------------------------------
/assets/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/assets/mstile-150x150.png
--------------------------------------------------------------------------------
/stickers/beerjs_moscow.ai:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/stickers/beerjs_moscow.ai
--------------------------------------------------------------------------------
/stickers/beerjs_moscow.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/stickers/beerjs_moscow.pdf
--------------------------------------------------------------------------------
/stickers/beerjs_original.ai:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/stickers/beerjs_original.ai
--------------------------------------------------------------------------------
/stickers/beerjs_original.pdf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/stickers/beerjs_original.pdf
--------------------------------------------------------------------------------
/assets/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/assets/android-chrome-192x192.png
--------------------------------------------------------------------------------
/assets/apple-touch-icon-60x60.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/assets/apple-touch-icon-60x60.png
--------------------------------------------------------------------------------
/assets/apple-touch-icon-76x76.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/assets/apple-touch-icon-76x76.png
--------------------------------------------------------------------------------
/assets/apple-touch-icon-120x120.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/assets/apple-touch-icon-120x120.png
--------------------------------------------------------------------------------
/assets/apple-touch-icon-152x152.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/assets/apple-touch-icon-152x152.png
--------------------------------------------------------------------------------
/assets/apple-touch-icon-180x180.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/beerjs/moscow/HEAD/assets/apple-touch-icon-180x180.png
--------------------------------------------------------------------------------
/assets/sitemap.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | https://beerjs.moscow/
5 |
6 |
7 |
--------------------------------------------------------------------------------
/assets/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "BeerJS Moscow",
3 | "icons": [
4 | {
5 | "src": "\/android-chrome-192x192.png",
6 | "sizes": "192x192",
7 | "type": "image\/png"
8 | }
9 | ],
10 | "theme_color": "#f3df49",
11 | "display": "standalone"
12 | }
13 |
--------------------------------------------------------------------------------
/assets/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #ffc40d
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "beerjs.moscow",
3 | "version": "1.0.0",
4 | "description": "BeerJS Moscow Website",
5 | "repository": {
6 | "type": "git",
7 | "url": "git+https://github.com/vslinko/beerjs.moscow.git"
8 | },
9 | "author": "Viacheslav Slinko ",
10 | "license": "MIT",
11 | "bugs": {
12 | "url": "https://github.com/vslinko/beerjs.moscow/issues"
13 | },
14 | "homepage": "https://beerjs.moscow",
15 | "dependencies": {
16 | "airtable": "^0.12.2",
17 | "express": "^4.14.0"
18 | },
19 | "scripts": {
20 | "sync": "node sync-from-airtable.js"
21 | },
22 | "devDependencies": {
23 | "dotenv": "^17.2.3"
24 | }
25 | }
26 |
--------------------------------------------------------------------------------
/server.js:
--------------------------------------------------------------------------------
1 | const express = require('express');
2 | const path = require('path');
3 | const templates = require('./templates');
4 | const crypto = require('crypto');
5 |
6 | const app = express();
7 |
8 | app.use(express.static(path.join(__dirname, 'assets')));
9 |
10 | app.get('/', (req, res) => {
11 | const csp = {
12 | 'default-src': `'self'`,
13 | 'script-src': `'self' https://www.google-analytics.com https://mc.yandex.ru`,
14 | 'style-src': `'self'`,
15 | 'img-src': `'self' https://www.google-analytics.com https://mc.yandex.ru`,
16 | 'connect-src': `'self' https://mc.yandex.ru`
17 | };
18 |
19 | const nonce = (type) => {
20 | const nonce = crypto.randomBytes(128).toString('base64');
21 | csp[type] += ` 'nonce-${nonce}'`;
22 | return nonce;
23 | };
24 |
25 | const html = templates.index({
26 | nonce,
27 | });
28 |
29 | res.set(
30 | 'Content-Security-Policy',
31 | `default-src ${csp['default-src']}; script-src ${csp['script-src']}; style-src ${csp['style-src']}; img-src ${csp['img-src']}; connect-src ${csp['connect-src']}`
32 | );
33 |
34 | res.send(html)
35 | });
36 |
37 | app.listen({
38 | host: '0.0.0.0',
39 | port: 3000,
40 | });
41 |
--------------------------------------------------------------------------------
/assets/beerjs.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.github/workflows/sync-airtable.yml:
--------------------------------------------------------------------------------
1 | name: Sync from Airtable
2 |
3 | on:
4 | schedule:
5 | - cron: '0 0 1 * *'
6 | workflow_dispatch:
7 |
8 | jobs:
9 | sync:
10 | runs-on: ubuntu-latest
11 | permissions:
12 | contents: write
13 | steps:
14 | - uses: actions/checkout@v4
15 | with:
16 | token: ${{ secrets.GITHUB_TOKEN }}
17 |
18 | - name: Setup Node.js
19 | uses: actions/setup-node@v4
20 | with:
21 | node-version: '20'
22 |
23 | - name: Install dependencies
24 | run: npm install
25 |
26 | - name: Sync from Airtable
27 | env:
28 | AIRTABLE_API_KEY: ${{ secrets.AIRTABLE_API_KEY }}
29 | AIRTABLE_TABLE_NAME: ${{ secrets.AIRTABLE_TABLE_NAME }}
30 | run: |
31 | if [ -z "$AIRTABLE_API_KEY" ]; then
32 | echo "ERROR: AIRTABLE_API_KEY is not set!"
33 | echo "Check that secret exists in: Settings → Secrets → Actions"
34 | exit 1
35 | fi
36 | echo "Token found (length: ${#AIRTABLE_API_KEY})"
37 | npm run sync
38 |
39 | - name: Commit and push
40 | run: |
41 | git config --global user.name 'github-actions[bot]'
42 | git config --global user.email 'github-actions[bot]@users.noreply.github.com'
43 | git add README.md
44 | git diff --staged --quiet || (git commit -m 'Auto-update README from Airtable' && git push)
45 |
46 |
--------------------------------------------------------------------------------
/chatrules.md:
--------------------------------------------------------------------------------
1 | # BeerJS Rules
2 |
3 | ### В чате разрешается:
4 |
5 | 1. Быть зайками
6 | 2. Пить пиво, чай и коктейли и всё остальное
7 | 3. Всё, что не запрещается
8 |
9 | ### В чате запрещается:
10 |
11 | 1. Удалять чат
12 | 2. Нарушать законы РФ (не стоит постить порно, призывы к суициду и так далее)
13 | 3. Оскорблять других участников чата и модераторов (в том числе посылать в известном направлении)
14 | 4. Выражать ненависть и призывать к насилию по отношению к людям или группам людей, основываясь на таких вещах как раса, религия, национальность, политические взгляды, место жительства, пол, сексуальная ориентация и так далее
15 | 5. Хантить (для этого есть @javascript_jobs)
16 | 6. Устраивать перепалки, срачи и прочие споры с переходами на личности и оскорблениями. Администраторы оставляют за собой право выдать РО всем участникам перепалки, независимо от того, кто первый начал
17 | 7. Обсуждать правила чата в чате (но можно в личке с админами или в [issues на github](https://github.com/beerjs/moscow/issues)). Пункт вступит в силу 7 ноября 2022 года
18 | 8. Публиковать рекламу и анонсы мероприятий без согласования с модераторами (можно обратиться к любому, список есть в самом низу)
19 |
20 | ### Про модерацию:
21 |
22 | 1. За нарушение правил выше может последовать наказание в следующих формах:
23 | 1. предупреждение (рекомендуем прислушаться)
24 | 2. перевод в режим readonly на срок от нескольких часов
25 | 3. полный бан
26 | 2. Сообщения, нарушающие правила, могут быть (и скорее всего будут) удалены
27 | 3. Администраторы чата являются равноправными участниками чата, и так же обязаны соблюдать все правила выше
28 |
29 | #### Список модераторов:
30 | 1. [@alonasi](https://t.me/alonasi)
31 | 2. [@arealit](https://t.me/arealit)
32 | 3. [@dimakorolev](https://t.me/dimakorolev)
33 | 4. [@juleari](https://t.me/juleari)
34 | 5. [@nmosolok](https://t.me/nmosolok)
35 | 6. [@shmakovdima](https://t.me/shmakovdima)
36 | 7. [@sirinity13](https://t.me/sirinity13)
37 | 8. [@StGeass](https://t.me/StGeass)
38 | 9. [@vadjs](https://t.me/vadjs)
39 | 10. [@vos3k](https://t.me/vos3k)
40 |
--------------------------------------------------------------------------------
/check-airtable-structure.js:
--------------------------------------------------------------------------------
1 | const Airtable = require('airtable');
2 |
3 | const AIRTABLE_BASE_ID = 'appcGU72VDA9lU29j';
4 | const AIRTABLE_API_KEY = process.env.AIRTABLE_API_KEY;
5 |
6 | if (!AIRTABLE_API_KEY) {
7 | console.error('Error: AIRTABLE_API_KEY is not set');
8 | console.error('Set it with: export AIRTABLE_API_KEY="your_api_key"');
9 | process.exit(1);
10 | }
11 |
12 | const base = new Airtable({ apiKey: AIRTABLE_API_KEY }).base(AIRTABLE_BASE_ID);
13 | const TABLE_NAME = process.env.AIRTABLE_TABLE_NAME || 'Releases';
14 |
15 | async function checkStructure() {
16 | try {
17 | console.log(`Checking table structure "${TABLE_NAME}"...\n`);
18 |
19 | const records = await base(TABLE_NAME).select({
20 | maxRecords: 5
21 | }).firstPage();
22 |
23 | if (records.length === 0) {
24 | console.log('Table is empty or not found');
25 | return;
26 | }
27 |
28 | console.log(`Found ${records.length} records\n`);
29 | console.log('Field structure (first record example):\n');
30 |
31 | const firstRecord = records[0];
32 | console.log('Record fields:');
33 | console.log(JSON.stringify(firstRecord.fields, null, 2));
34 |
35 | console.log('\n\nAll available fields:');
36 | const allFields = Object.keys(firstRecord.fields);
37 | allFields.forEach((field, index) => {
38 | const value = firstRecord.fields[field];
39 | const type = Array.isArray(value) ? 'array' : typeof value;
40 | console.log(`${index + 1}. "${field}" (${type}): ${JSON.stringify(value).substring(0, 50)}`);
41 | });
42 |
43 | console.log('\n\nAll records examples:');
44 | records.forEach((record, index) => {
45 | console.log(`\nRecord ${index + 1}:`);
46 | console.log(JSON.stringify(record.fields, null, 2));
47 | });
48 |
49 | } catch (error) {
50 | console.error('Error:', error.message);
51 |
52 | if (error.error) {
53 | console.error('Details:', error.error);
54 | }
55 |
56 | if (error.statusCode === 404) {
57 | console.error('\nTable not found. Try:');
58 | console.error('1. Check table name (AIRTABLE_TABLE_NAME)');
59 | console.error('2. Check BASE_ID');
60 | console.error('3. Ensure API key has access to the base');
61 | } else if (error.statusCode === 401) {
62 | console.error('\nAuthorization error. Check AIRTABLE_API_KEY');
63 | }
64 |
65 | process.exit(1);
66 | }
67 | }
68 |
69 | checkStructure();
70 |
71 |
--------------------------------------------------------------------------------
/templates/index.js:
--------------------------------------------------------------------------------
1 | function ga(context) {
2 | return (
3 | ``
20 | );
21 | }
22 |
23 | function ym(context) {
24 | return (
25 | ``
46 | );
47 | }
48 |
49 | module.exports.index = function index(context) {
50 | return (
51 | `
52 |
53 |
54 |
55 | BeerJS Moscow
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
98 | ${ym(context)}
99 | ${ga(context)}
100 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
108 |
109 |
110 |
111 |
112 |
113 |
114 |
115 |
116 |
117 | 
118 |
119 |
120 |
121 | `
122 | );
123 | }
124 |
--------------------------------------------------------------------------------
/assets/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
73 |
--------------------------------------------------------------------------------
/sync-from-airtable.js:
--------------------------------------------------------------------------------
1 | require('dotenv').config();
2 |
3 | const Airtable = require('airtable');
4 | const fs = require('fs');
5 | const path = require('path');
6 |
7 | const AIRTABLE_BASE_ID = 'appcGU72VDA9lU29j';
8 | const AIRTABLE_API_KEY = process.env.AIRTABLE_API_KEY;
9 |
10 | if (!AIRTABLE_API_KEY) {
11 | console.error('Error: AIRTABLE_API_KEY is not set');
12 | console.error('\nTo run locally, set the environment variable:');
13 | console.error(' export AIRTABLE_API_KEY="your_token_here"');
14 | console.error(' npm run sync');
15 | console.error('\nOr run in one line:');
16 | console.error(' AIRTABLE_API_KEY="your_token_here" npm run sync');
17 | console.error('\nTo get token: https://airtable.com/create/tokens');
18 | process.exit(1);
19 | }
20 |
21 | Airtable.configure({ apiKey: AIRTABLE_API_KEY });
22 | const base = Airtable.base(AIRTABLE_BASE_ID);
23 | const TABLE_NAME = process.env.AIRTABLE_TABLE_NAME || 'Releases';
24 |
25 | function formatDate(dateString) {
26 | if (!dateString) return '';
27 |
28 | if (typeof dateString === 'string' && dateString.includes('г.')) {
29 | return dateString;
30 | }
31 |
32 | const date = new Date(dateString);
33 | if (isNaN(date.getTime())) {
34 | return dateString;
35 | }
36 |
37 | const months = [
38 | 'Января', 'Февраля', 'Марта', 'Апреля', 'Мая', 'Июня',
39 | 'Июля', 'Августа', 'Сентября', 'Октября', 'Ноября', 'Декабря'
40 | ];
41 |
42 | const day = date.getDate();
43 | const month = months[date.getMonth()];
44 | const year = date.getFullYear();
45 |
46 | return `${day} ${month} ${year} г.`;
47 | }
48 |
49 |
50 | function escapeHtml(text) {
51 | if (!text) return '';
52 | return String(text)
53 | .replace(//g, '>');
55 | }
56 |
57 | function formatMarkdownTable(records) {
58 | const sortedRecords = records.sort((a, b) => {
59 | const numA = parseInt(a.fields.ID || 0);
60 | const numB = parseInt(b.fields.ID || 0);
61 | return numB - numA;
62 | });
63 |
64 | let maxNumberLength = 3;
65 | let maxDateLength = 20;
66 | let maxPlaceLength = 6;
67 | let maxCommentLength = 7;
68 | const processedRecords = [];
69 |
70 | for (const record of sortedRecords) {
71 | const fields = record.fields;
72 |
73 | if (!fields.date || !fields['Заголовок']) {
74 | continue;
75 | }
76 |
77 | const number = String(fields.ID || '');
78 | const date = formatDate(fields.date || '');
79 | const заголовок = fields['Заголовок'] || '';
80 | const place = заголовок.includes(',') ? заголовок.split(', ').slice(1).join(', ') : '';
81 | let comment = escapeHtml(fields.comment || '');
82 | comment = comment.replace(/\n/g, ' ').replace(/\r/g, '').trim();
83 |
84 | if (number.length > maxNumberLength) maxNumberLength = number.length;
85 | if (date.length > maxDateLength) maxDateLength = date.length;
86 | if (escapeHtml(place).length > maxPlaceLength) maxPlaceLength = escapeHtml(place).length;
87 | if (comment.length > maxCommentLength) maxCommentLength = comment.length;
88 |
89 | processedRecords.push({
90 | number: number,
91 | date: date,
92 | place: place,
93 | comment: comment
94 | });
95 | }
96 |
97 | const numberHeader = '# '.padEnd(maxNumberLength + 1);
98 | const dateHeader = 'Date'.padEnd(maxDateLength);
99 | const placeHeader = 'Place'.padEnd(maxPlaceLength);
100 | const commentHeader = 'Comment'.padEnd(maxCommentLength);
101 |
102 | let table = `| ${numberHeader}| ${dateHeader} | ${placeHeader} | ${commentHeader} |\n`;
103 | table += `|${'-'.repeat(maxNumberLength + 2)}|${'-'.repeat(maxDateLength + 2)}|${'-'.repeat(maxPlaceLength + 2)}|${'-'.repeat(maxCommentLength + 2)}|\n`;
104 |
105 | for (const record of processedRecords) {
106 | const numberStr = (record.number || '').padEnd(maxNumberLength + 1);
107 | const dateStr = (record.date || '').padEnd(maxDateLength);
108 | const placeStr = (escapeHtml(record.place) || '').padEnd(maxPlaceLength);
109 | const commentStr = (record.comment || '').padEnd(maxCommentLength);
110 |
111 | table += `| ${numberStr}| ${dateStr} | ${placeStr} | ${commentStr} |\n`;
112 | }
113 |
114 | return table;
115 | }
116 |
117 | async function syncFromAirtable() {
118 | try {
119 | console.log(`Fetching data from table "${TABLE_NAME}"...`);
120 |
121 | const records = [];
122 |
123 | await base(TABLE_NAME).select({
124 | sort: [{ field: 'ID', direction: 'desc' }]
125 | }).eachPage((pageRecords, fetchNextPage) => {
126 | records.push(...pageRecords);
127 | fetchNextPage();
128 | });
129 |
130 | console.log(`Fetched ${records.length} records`);
131 |
132 | if (records.length === 0) {
133 | console.warn('Warning: no records found');
134 | console.warn('Check:');
135 | console.warn(`1. AIRTABLE_TABLE_NAME is correct (current: "${TABLE_NAME}")`);
136 | console.warn('2. Table exists in the base');
137 | console.warn('3. Table has records');
138 | return;
139 | }
140 |
141 | const markdownTable = formatMarkdownTable(records);
142 |
143 | const readmePath = path.join(__dirname, 'README.md');
144 | let readmeContent = fs.readFileSync(readmePath, 'utf8');
145 |
146 | const releasesStart = readmeContent.indexOf('## Releases');
147 | if (releasesStart === -1) {
148 | console.error('Error: "## Releases" section not found in README.md');
149 | return;
150 | }
151 |
152 | const beforeReleases = readmeContent.substring(0, releasesStart);
153 | const newContent = beforeReleases + '## Releases\n\n' + markdownTable + '\n';
154 |
155 | fs.writeFileSync(readmePath, newContent, 'utf8');
156 |
157 | console.log('✓ README.md updated successfully');
158 | console.log(`✓ Updated ${records.length} records`);
159 |
160 | } catch (error) {
161 | console.error('Sync error:', error.message);
162 |
163 | if (error.error) {
164 | console.error('Error details:', JSON.stringify(error.error, null, 2));
165 | }
166 |
167 | if (error.statusCode === 404) {
168 | console.error('\nPossible causes:');
169 | console.error('1. Wrong table name. Try setting AIRTABLE_TABLE_NAME');
170 | console.error('2. Table does not exist in the base');
171 | console.error('3. Wrong BASE_ID');
172 | } else if (error.statusCode === 401 || error.statusCode === 403) {
173 | console.error('\nAuthorization error. Possible causes:');
174 | console.error('1. Token does not have scope: data.records:read');
175 | console.error(`2. Token does not have access to base: ${AIRTABLE_BASE_ID}`);
176 | console.error('3. Token is invalid or expired');
177 | console.error('\nTo fix:');
178 | console.error('1. Go to https://airtable.com/create/tokens');
179 | console.error('2. Create token with scope: data.records:read');
180 | console.error(`3. Grant access to base: ${AIRTABLE_BASE_ID}`);
181 | console.error('4. Update AIRTABLE_API_KEY secret in GitHub');
182 | }
183 |
184 | process.exit(1);
185 | }
186 | }
187 |
188 | syncFromAirtable();
189 |
190 |
--------------------------------------------------------------------------------
/assets/bubbles.js:
--------------------------------------------------------------------------------
1 | (function() {
2 | function readQueryString() {
3 | return window.location.search
4 | .slice(1)
5 | .split('&')
6 | .reduce(function(acc, param) {
7 | var pieces = param.split('=', 2);
8 | var key = decodeURIComponent(pieces[0]);
9 | var value = decodeURIComponent(pieces[1] || '');
10 | acc[key] = value;
11 | return acc;
12 | }, {});
13 | }
14 |
15 | function random(from, to) {
16 | var diff = to - from;
17 | return Math.round(Math.random() * diff) + from;
18 | }
19 |
20 | var directions = {
21 | UP: 0,
22 | DOWN: 1,
23 | };
24 |
25 | var configs = {
26 | lager: {
27 | direction: directions.UP,
28 | backgroundColor: '#fbde34',
29 | bubbleColor: '#fbf2b1',
30 | width: function() {
31 | return random(3, 7);
32 | },
33 | radius: function() {
34 | return random(10, 20)
35 | },
36 | speed: function() {
37 | return random(150, 200);
38 | },
39 | newBubbles: function() {
40 | return Math.random() > 0.9 ? 1 : 0;
41 | },
42 | alpha: function() {
43 | return 1;
44 | },
45 | amplitude: function() {
46 | return random(-10, 10);
47 | },
48 | },
49 | stout: {
50 | direction: directions.DOWN,
51 | backgroundColor: '#0F0503',
52 | bubbleColor: '#DFA97D',
53 | width: function() {
54 | return random(1, 2);
55 | },
56 | radius: function() {
57 | return random(2, 5);
58 | },
59 | speed: function() {
60 | return random(200, 300);
61 | },
62 | newBubbles: function() {
63 | return random(0, 25);
64 | },
65 | alpha: function(bubble, canvas, diff) {
66 | var breakpoint = canvas.height / 2 - 100;
67 | if (bubble.y < breakpoint) {
68 | return 1;
69 | }
70 | var alpha = 1 - (bubble.y - breakpoint) / 200;
71 | return alpha > 0 ? alpha : 0;
72 | },
73 | amplitude: function() {
74 | return random(-50, 50);
75 | },
76 | },
77 | };
78 | var config = configs['lager'];
79 |
80 | function createCanvas() {
81 | var canvas = document.createElement('canvas');
82 | canvas.width = document.body.clientWidth;
83 | canvas.height = document.body.clientHeight;
84 | canvas.style.position = 'absolute';
85 | canvas.style.top = '0';
86 | canvas.style.left = '0';
87 | canvas.style.zIndex = '1';
88 | document.body.appendChild(canvas);
89 | return canvas;
90 | }
91 |
92 | function runBubbles() {
93 | var canvas = createCanvas();
94 | var ctx = canvas.getContext('2d');
95 | var xMod = 0;
96 | var bubbles = null;
97 | var lastBubble = null;
98 |
99 | function _renderMenu() {
100 | var ul = document.createElement('ul');
101 | ul.style.position = 'absolute';
102 | ul.style.top = '15px';
103 | ul.style.right = '15px';
104 | ul.style.listStyleType = 'none';
105 | ul.style.margin = '0';
106 | ul.style.padding = '0';
107 | ul.style.zIndex = '2';
108 | Object.keys(configs).forEach(function(configName) {
109 | var li = document.createElement('li');
110 | li.style.marginBottom = '10px';
111 | var button = document.createElement('button');
112 | button.style.borderWidth = '2px';
113 | button.style.borderStyle = 'solid';
114 | button.style.borderColor = configs[configName].bubbleColor;
115 | button.style.borderRadius = '25px';
116 | button.style.backgroundColor = configs[configName].backgroundColor;
117 | button.style.width = '25px';
118 | button.style.height = '25px';
119 | button.style.outline = 'none';
120 | button.style.cursor = 'pointer';
121 | button.addEventListener('click', function() {
122 | _changeConfig(configs[configName]);
123 | history.pushState(configName, document.title, '?beer=' + configName);
124 | });
125 | li.appendChild(button);
126 | ul.appendChild(li);
127 | });
128 | document.body.appendChild(ul);
129 | }
130 |
131 | function _changeConfig(newConfig) {
132 | var html = document.querySelector('html');
133 | if (html.animate) {
134 | var player = html.animate([
135 | {backgroundColor: html.style.backgroundColor || '#fbde34'},
136 | {backgroundColor: newConfig.backgroundColor},
137 | ], 500);
138 | player.onfinish = function() {
139 | html.style.backgroundColor = newConfig.backgroundColor;
140 | };
141 | } else {
142 | html.style.backgroundColor = newConfig.backgroundColor;
143 | }
144 | bubbles = null;
145 | lastBubble = null;
146 | config = newConfig;
147 | }
148 |
149 | function _isBubbleGone(bubble) {
150 | if (bubble.alpha === 0) {
151 | return true;
152 | }
153 |
154 | if (config.direction === directions.UP) {
155 | return bubble.y < 0 - bubble.width - bubble.radius;
156 | } else {
157 | return bubble.y > canvas.height + bubble.width + bubble.radius;
158 | }
159 | }
160 |
161 | function _createBubble(timestamp) {
162 | var width = config.width();
163 | var radius = config.radius();
164 | var speed = config.speed();
165 | var amplitude = config.amplitude();
166 | var color = config.bubbleColor;
167 | var origX = random(canvas.width * -0.5, canvas.width * 1.5);
168 | var origY = config.direction === directions.UP
169 | ? canvas.height + width + radius
170 | : -(width + radius);
171 |
172 | return {
173 | origX: origX,
174 | origY: origY,
175 | x: origX,
176 | y: origY,
177 | alpha: 1,
178 | width: width,
179 | radius: radius,
180 | speed: speed,
181 | next: null,
182 | timestamp: timestamp,
183 | color: color,
184 | amplitude: amplitude,
185 | xMod: 0,
186 | };
187 | }
188 |
189 | function _moveBubble(bubble, newTimestamp) {
190 | var delta = newTimestamp - bubble.timestamp;
191 | var yDiff = Math.round(delta / 1000 * bubble.speed);
192 | bubble.alpha = config.alpha(bubble, canvas, delta);
193 | bubble.xMod += config.direction === directions.UP ? xMod : -xMod;
194 | bubble.y = config.direction === directions.UP ? bubble.origY - yDiff : bubble.origY + yDiff;
195 | bubble.x = bubble.origX + Math.round(Math.sin(delta / 1000) * bubble.amplitude + bubble.xMod);
196 | }
197 |
198 | function _cleanup() {
199 | var current = bubbles;
200 | var previous;
201 | while (current) {
202 | if (_isBubbleGone(current)) {
203 | var isFirst = !previous;
204 | var isLast = current === lastBubble;
205 |
206 | if (isFirst && isLast) {
207 | bubbles = null;
208 | lastBubble = null;
209 | } else if (isFirst) {
210 | bubbles = current.next;
211 | } else if (isLast) {
212 | previous.next = current.next;
213 | lastBubble = previous;
214 | } else {
215 | previous.next = current.next;
216 | }
217 | }
218 |
219 | previous = current;
220 | current = current.next;
221 | }
222 | }
223 |
224 | function _move(timestamp) {
225 | var current = bubbles;
226 | while (current) {
227 | _moveBubble(current, timestamp);
228 | current = current.next;
229 | }
230 | }
231 |
232 | function _create(timestamp) {
233 | var newBubblesLength = config.newBubbles();
234 | for (var i = 0; i < newBubblesLength; i++) {
235 | var newBubble = _createBubble(timestamp);
236 | lastBubble.next = newBubble;
237 | lastBubble = newBubble;
238 | }
239 | }
240 |
241 | function _recalculate(timestamp) {
242 | _cleanup();
243 | _move(timestamp);
244 | _create(timestamp);
245 | }
246 |
247 | function _redraw() {
248 | ctx.clearRect(0, 0, canvas.width, canvas.height);
249 |
250 | var current = bubbles;
251 | while (current) {
252 | ctx.beginPath();
253 | ctx.arc(current.x, current.y, current.radius, 0, 2 * Math.PI);
254 | ctx.lineWidth = current.width;
255 | ctx.strokeStyle = current.color;
256 | ctx.globalAlpha = current.alpha;
257 | ctx.stroke();
258 | current = current.next;
259 | }
260 | }
261 |
262 | function _tick(timestamp) {
263 | if (!bubbles) {
264 | bubbles = _createBubble(canvas, timestamp);
265 | lastBubble = bubbles;
266 | }
267 | _recalculate(timestamp);
268 | _redraw();
269 | window.requestAnimationFrame(_tick);
270 | }
271 |
272 | (function(){
273 | var beer = readQueryString()['beer'];
274 |
275 | if (beer && beer in configs) {
276 | _changeConfig(configs[beer]);
277 | }
278 |
279 | _renderMenu();
280 | })();
281 |
282 | window.requestAnimationFrame(_tick);
283 | window.addEventListener('popstate', function(event) {
284 | _changeConfig(configs[history.state || 'lager']);
285 | });
286 | window.addEventListener('deviceorientation', function(event) {
287 | var orientation = window.orientation || 0;
288 | var positive;
289 | var mul;
290 |
291 | if (orientation === 90) {
292 | xMod = event.gamma > 0 ? -(event.beta + 180) : event.beta;
293 | positive = event.beta < 0;
294 | mul = 0.1;
295 |
296 | } else if (orientation === -90) {
297 | xMod = event.gamma > 0 ? event.beta : 180 - Math.abs(event.beta);
298 | positive = event.beta > 0;
299 | mul = 0.1;
300 |
301 | } else {
302 | xMod = Math.abs(event.beta) > 90 ? -event.gamma : event.gamma;
303 | positive = xMod < 0;
304 | mul = 0.033;
305 | }
306 |
307 | xMod = Math.sqrt(Math.abs(xMod) * mul);
308 | xMod = positive ? xMod : -xMod;
309 | }, true);
310 | window.addEventListener('resize', function() {
311 | canvas.width = document.body.clientWidth;
312 | canvas.height = document.body.clientHeight;
313 | });
314 | }
315 |
316 | if (document.readyState === 'complete') {
317 | runBubbles();
318 | } else {
319 | window.addEventListener('load', runBubbles);
320 | }
321 | })();
322 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # BeerJS Moscow
2 |
3 | [](https://telegram.me/beerjs_moscow)
4 |
5 | **Usual time**: Thursday at 8 PM. :beers:
6 |
7 | ## Releases
8 |
9 | | # | Date | Place | Comment |
10 | |-----|----------------------|------------------------|--------------------------------------|
11 | | 187 | 23 Октября 2025 г. | Пивотека 465 | Афтепати MoscowJS 69 |
12 | | 186 | 9 Октября 2025 г. | Axiom | |
13 | | 185 | 2 Октября 2025 г. | Støy! | |
14 | | 184 | 21 Августа 2025 г. | Axiom | Moscow Drinkup #8 |
15 | | 183 | 7 Августа 2025 г. | Støy! | |
16 | | 182 | 31 Июля 2025 г. | Støy! | |
17 | | 181 | 3 Июля 2025 г. | Rusty pub | Афтепати MoscowJS 66 |
18 | | 180 | 26 Июня 2025 г. | Axiom | Moscow Drinkup #7 |
19 | | 179 | 5 Июня 2025 г. | Bad Bro Bar | Афтепати MoscowJS 65 |
20 | | 178 | 29 Мая 2025 г. | Støy! | Афтепати MSK VUE.JS |
21 | | 177 | 22 Мая 2025 г. | Косой Маркс | |
22 | | 176 | 15 Мая 2025 г. | Støy! | Афтепати MoscowJS 64 |
23 | | 175 | 8 Мая 2025 г. | Støy! | Ребята собрались и никому не сказали |
24 | | 174 | 24 Апреля 2025 г. | Косой Маркс | |
25 | | 173 | 17 Апреля 2025 г. | Эрик Рыжий | |
26 | | 172 | 3 Апреля 2025 г. | Støy! | |
27 | | 171 | 27 Марта 2025 г. | The Backyard Pub | Афтепати MoscowJS 63 |
28 | | 170 | 20 Марта 2025 г. | Støy! | |
29 | | 169 | 6 Марта 2025 г. | Axiom | BeerJS Drinkup #6 |
30 | | 168 | 27 Февраля 2025 г. | Rule taproom | |
31 | | 167 | 20 Февраля 2025 г. | 4brewers Pub | |
32 | | 166 | 13 Февраля 2025 г. | ГлавПивМаг | |
33 | | 165 | 6 Февраля 2025 г. | Слоны и мамонты | |
34 | | 164 | 30 Января 2025 г. | Axiom | |
35 | | 163 | 23 Января 2025 г. | Støy! | |
36 | | 162 | 16 Января 2025 г. | Арма craft | |
37 | | 161 | 9 Января 2025 г. | Støy! | |
38 | | 160 | 19 Декабря 2024 г. | Støy! | BeerJS Drinkup #5 |
39 | | 159 | 21 Ноября 2024 г. | Støy! | Афтепати MoscowJS 62 |
40 | | 158 | 31 Октября 2024 г. | Støy! | |
41 | | 157 | 24 Октября 2024 г. | Støy! | BeerJS Drinkup #4 |
42 | | 156 | 17 Октября 2024 г. | Støy! | |
43 | | 155 | 3 Октября 2024 г. | Варка | |
44 | | 154 | 19 Сентября 2024 г. | Støy! | |
45 | | 153 | 15 Августа 2024 г. | Right Hops | Афтепати MoscowJS 61 |
46 | | 152 | 18 Июля 2024 г. | The Backyard Pub | BeerJS Drinkup #3 |
47 | | 151 | 20 Июня 2024 г. | БИРкрафт Белорусская | |
48 | | 150 | 6 Июня 2024 г. | Прогресс | Афтепати MoscowJS 59 |
49 | | 149 | 25 Апреля 2024 г. | Freedom Bar | BeerJS Drinkup #2 |
50 | | 148 | 21 Марта 2024 г. | Freedom Bar | BeerJS Drinkup #1 |
51 | | 147 | 14 Марта 2024 г. | Howard Loves Craft | Афтепати MoscowJS 58 |
52 | | 146 | 29 Февраля 2024 г. | Støy! | Полуофициальный |
53 | | 145 | 8 Февраля 2024 г. | Pivbar | Афтепати MoscowJS 57 |
54 | | 144 | 8 Февраля 2024 г. | Вариант | Афтепати MoscowJS 57 |
55 | | 143 | 25 Января 2024 г. | "Хиросима, Моя любовь" | |
56 | | 142 | 21 Декабря 2023 г. | Howard Loves Craft | |
57 | | 141 | 19 Октября 2023 г. | Støy! | |
58 | | 140 | 5 Октября 2023 г. | Howard Loves Craft | |
59 | | 139 | 28 Сентября 2023 г. | Beermood | Афтепати MoscowCSS |
60 | | 138 | 21 Сентября 2023 г. | Støy! | |
61 | | 137 | 14 Сентября 2023 г. | Белфаст | |
62 | | 136 | 31 Августа 2023 г. | Bijou | CocktailJS |
63 | | 135 | 10 Августа 2023 г. | Howard Loves Craft | |
64 | | 134 | 24 Августа 2023 г. | Beersenev Bar | Афтепати MoscowJS 54 |
65 | | 133 | 27 Июля 2023 г. | Støy! | |
66 | | 132 | 13 Июля 2023 г. | Method Beer & Munchies | |
67 | | 131 | 22 Июня 2023 г. | Method Beer & Munchies | |
68 | | 130 | 18 Мая 2023 г. | Кит&Планктон | Афтепати MoscowJS 52 |
69 | | 129 | 1 Июня 2023 г. | Beermood | |
70 | | 128 | 20 Апреля 2023 г. | Axiom | |
71 | | 127 | 23 Марта 2023 г. | Парка | |
72 | | 126 | 26 Января 2023 г. | Слёзы Берёзы | |
73 | | 125 | 12 Января 2023 г. | Støy! | |
74 | | 124 | 8 Декабря 2022 г. | Белфаст | |
75 | | 123 | 17 Ноября 2022 г. | Пестики-Тычинки | |
76 | | 122 | 10 Ноября 2022 г. | Пестики-Тычинки | |
77 | | 121 | 27 Октября 2022 г. | Пестики-Тычинки | |
78 | | 120 | 22 Сентября 2022 г. | Beermood | Афтерпати MoscowCSS |
79 | | 119 | 15 Сентября 2022 г. | Beermood | Внеплановый |
80 | | 118 | 8 Сентября 2022 г. | Axiom | |
81 | | 117 | 8 Сентября 2022 г. | Слёзы Берёзы | |
82 | | 116 | 4 Августа 2022 г. | Støy! | |
83 | | 115 | 27 Июля 2022 г. | Axiom | |
84 | | 114 | 30 Июня 2022 г. | Фракция | |
85 | | 113 | 2 Июня 2022 г. | Method Beer & Munchies | |
86 | | 112 | 7 Апреля 2022 г. | Эрик Рыжий | |
87 | | 111 | 31 Марта 2022 г. | Los Bandidos | |
88 | | 110 | 24 Февраля 2022 г. | Method Beer & Munchies | |
89 | | 109 | 17 Февраля 2022 г. | Эрик Рыжий | |
90 | | 108 | 10 Февраля 2022 г. | Парка | |
91 | | 107 | 6 Января 2022 г. | Los Bandidos | |
92 | | 106 | 23 Декабря 2021 г. | Pivbar | |
93 | | 105 | 2 Декабря 2021 г. | One More Pub | |
94 | | 104 | 18 Ноября 2021 г. | Pivbar | |
95 | | 103 | 21 Октября 2021 г. | Craft Republic | |
96 | | 102 | 23 Сентября 2021 г. | Крапива | |
97 | | 101 | 9 Сентября 2021 г. | Эрик Рыжий | |
98 | | 100 | 19 Августа 2021 г. | Beermood | |
99 | | 99 | 3 Июня 2021 г. | Bô | |
100 | | 98 | 20 Мая 2021 г. | Craft Republic | |
101 | | 97 | 29 Апреля 2021 г. | На кранах | |
102 | | 96 | 25 Марта 2021 г. | Парка | |
103 | | 95 | 11 Марта 2021 г. | Птица-Синица | |
104 | | 94 | 4 Марта 2021 г. | Barcraft | |
105 | | 93 | 3 Февраля 2021 г. | Эрик Рыжий | |
106 | | 92 | 20 Августа 2020 г. | Дом в Котором Pub | |
107 | | 91 | 3 Июля 2020 г. | Howard Loves Craft | |
108 | | 90 | 5 Марта 2020 г. | Парка | |
109 | | 89 | 27 Февраля 2020 г. | Фракция | |
110 | | 88 | 30 Января 2020 г. | Beersenev Bar | |
111 | | 87 | 23 Января 2020 г. | Craft and Draft | |
112 | | 86 | 9 Января 2020 г. | Птица-Синица | |
113 | | 85 | 5 Декабря 2019 г. | БИРкрафт Менделеевская | |
114 | | 84 | 28 Ноября 2019 г. | Фракция | |
115 | | 83 | 7 Ноября 2019 г. | Эрик Рыжий | |
116 | | 82 | 17 Октября 2019 г. | Punk Fiction | |
117 | | 81 | 3 Октября 2019 г. | Rule taproom | |
118 | | 80 | 22 Августа 2019 г. | Птица-Синица | |
119 | | 79 | 20 Июня 2019 г. | Los Bandidos | |
120 | | 78 | 6 Июня 2019 г. | Fcking Craft Pub | |
121 | | 77 | 20 Марта 2019 г. | Barcraft | |
122 | | 76 | 6 Марта 2019 г. | Barcraft | |
123 | | 75 | 10 Января 2019 г. | МореПиva | |
124 | | 74 | 10 Января 2019 г. | One More Pub | |
125 | | 73 | 22 Ноября 2018 г. | Эрик Рыжий | |
126 | | 72 | 8 Ноября 2018 г. | Птица-Синица | |
127 | | 71 | 25 Октября 2018 г. | Fcking Craft Pub | |
128 | | 70 | 11 Октября 2018 г. | Фракция | |
129 | | 69 | 4 Октября 2018 г. | Эрик Рыжий | |
130 | | 68 | 27 Сентября 2018 г. | Гастропаб 31 | |
131 | | 67 | 6 Сентября 2018 г. | БИРкрафт Менделеевская | |
132 | | 66 | 23 Августа 2018 г. | Питчер | |
133 | | 65 | 2 Августа 2018 г. | Ян Примус | |
134 | | 64 | 26 Июля 2018 г. | Rule taproom | |
135 | | 63 | 12 Июля 2018 г. | Парка | |
136 | | 62 | 5 Июля 2018 г. | White Eagles Pub | |
137 | | 61 | 21 Июня 2018 г. | Парка | |
138 | | 60 | 14 Июня 2018 г. | Эрик Рыжий | |
139 | | 59 | 31 Мая 2018 г. | Джон Донн | |
140 | | 58 | 24 Мая 2018 г. | Птица-Синица | |
141 | | 57 | 19 Апреля 2018 г. | White Eagles Pub | |
142 | | 56 | 22 Марта 2018 г. | Craft Station | |
143 | | 55 | 15 Марта 2018 г. | Крапива | |
144 | | 54 | 1 Марта 2018 г. | One More Pub | |
145 | | 53 | 15 Февраля 2018 г. | Гастропаб 31 | |
146 | | 52 | 25 Января 2018 г. | Крафтъ 22 | |
147 | | 51 | 18 Января 2018 г. | Ветка | |
148 | | 50 | 28 Декабря 2017 г. | Craft Station | |
149 | | 49 | 21 Декабря 2017 г. | Эрик Рыжий | |
150 | | 48 | 30 Ноября 2017 г. | White Eagles Pub | |
151 | | 47 | 23 Ноября 2017 г. | White Eagles Pub | |
152 | | 46 | 9 Ноября 2017 г. | Ветка | |
153 | | 45 | 19 Октября 2017 г. | Птица-Синица | |
154 | | 44 | 12 Октября 2017 г. | Kitchen Burger Bar | |
155 | | 43 | 21 Сентября 2017 г. | Barcraft | |
156 | | 42 | 14 Сентября 2017 г. | Паб-ресторан Cuba | |
157 | | 41 | 24 Августа 2017 г. | Dark Patricks Pub | |
158 | | 40 | 10 Августа 2017 г. | Fcking Craft Pub | |
159 | | 39 | 27 Июля 2017 г. | Парка | |
160 | | 38 | 13 Июля 2017 г. | Птица-Синица | |
161 | | 37 | 6 Июля 2017 г. | Птица-Синица | |
162 | | 36 | 29 Июня 2017 г. | Los Bandidos | |
163 | | 35 | 8 Июня 2017 г. | Парка | |
164 | | 34 | 1 Июня 2017 г. | Dark Patricks Pub | |
165 | | 33 | 11 Мая 2017 г. | Эрик Рыжий | |
166 | | 32 | 27 Апреля 2017 г. | Пес Борода | |
167 | | 31 | 20 Апреля 2017 г. | Эрик Рыжий | |
168 | | 30 | 13 Апреля 2017 г. | Гастропаб 31 | |
169 | | 29 | 30 Марта 2017 г. | Fcking Craft Pub | |
170 | | 28 | 23 Марта 2017 г. | Прогресс | |
171 | | 27 | 2 Марта 2017 г. | Fcking Craft Pub | |
172 | | 26 | 16 Февраля 2017 г. | Падди'с | |
173 | | 25 | 9 Февраля 2017 г. | White Eagles Pub | |
174 | | 24 | 26 Января 2017 г. | Pivbar | |
175 | | 23 | 12 Января 2017 г. | Barcraft | |
176 | | 22 | 22 Декабря 2016 г. | Fcking Craft Pub | |
177 | | 21 | 24 Ноября 2016 г. | One More Pub | |
178 | | 20 | 17 Ноября 2016 г. | Beertep Craft Pub | |
179 | | 19 | 10 Ноября 2016 г. | Эрик Рыжий | |
180 | | 18 | 20 Октября 2016 г. | Эрик Рыжий | |
181 | | 17 | 13 Октября 2016 г. | Борода | |
182 | | 16 | 1 Сентября 2016 г. | Эрик Рыжий | |
183 | | 15 | 21 Июля 2016 г. | Beer Happens | |
184 | | 14 | 13 Июля 2016 г. | Джон Донн | |
185 | | 13 | 23 Июня 2016 г. | Craft Republic | |
186 | | 12 | 6 Июня 2016 г. | Варка | |
187 | | 11 | 26 Мая 2016 г. | Эрик Рыжий | |
188 | | 10 | 5 Мая 2016 г. | Питчер | |
189 | | 9 | 17 Марта 2016 г. | Piligrim | |
190 | | 8 | 24 Декабря 2015 г. | Piligrim | |
191 | | 7 | 19 Ноября 2015 г. | Piligrim | |
192 | | 6 | 3 Сентября 2015 г. | HopHead | |
193 | | 5 | 20 Августа 2015 г. | Piligrim | |
194 | | 4 | 6 Августа 2015 г. | Pivbar | |
195 | | 3 | 2 Июля 2015 г. | Pivbar | |
196 | | 2 | 18 Июня 2015 г. | Pivbar | |
197 | | 1 | 25 Декабря 2014 г. | Гастропаб 31 | |
198 |
199 |
--------------------------------------------------------------------------------