├── .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 |

BeerJS Moscow

118 |
119 | 120 | 121 | ` 122 | ); 123 | } 124 | -------------------------------------------------------------------------------- /assets/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 71 | 72 | 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 | [![Telegram](https://img.shields.io/badge/telegram-join%20chat-blue.svg?style=flat)](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 | --------------------------------------------------------------------------------