├── src ├── utils │ ├── keycodes.ts │ ├── firestore.ts │ ├── giphy.ts │ ├── firebase.ts │ ├── slack.ts │ ├── misc.ts │ ├── destination.ts │ ├── partyComments.ts │ ├── user.ts │ └── party.ts ├── pages │ ├── PageNotFoundPage.tsx │ ├── PartyDetailPage.tsx │ ├── PartyFormPage.tsx │ └── PartyListPage.tsx ├── App.test.js ├── styled-components.ts ├── stores │ ├── index.ts │ ├── DestinationsStore.ts │ ├── UserStore.ts │ └── PartyStore.ts ├── GoogleLoginButton.tsx ├── index.tsx ├── index.css ├── PartyTags.tsx ├── PartyStyledComponents.ts ├── PartyJoinButton.tsx ├── CommonStyledComponents.ts ├── DueCountDown.tsx ├── data │ └── categories.js ├── App.tsx ├── logo.svg ├── PartyForm.css ├── PartyComments.css ├── AuthenticateHeader.tsx ├── Categories.tsx ├── GifSearch.tsx ├── Footer.tsx ├── registerServiceWorker.js ├── PartyList.tsx ├── App.css ├── PartyDetail.tsx ├── PartyComments.tsx └── PartyForm.tsx ├── tsconfig.prod.json ├── types ├── images.d.ts ├── bluebird-shim.d.ts ├── stores.d.ts └── model.d.ts ├── public ├── favicon.png ├── manifest.json └── index.html ├── tsconfig.test.json ├── functions ├── index.js ├── .gitignore ├── tsconfig.json ├── package.json ├── src │ └── index.ts └── tslint.json ├── travis └── build-notify.sh ├── storage.rules ├── firestore.rules ├── firestore.indexes.json ├── config-overrides.js ├── tslint.json ├── .gitignore ├── .firebase └── hosting.YnVpbGQ.cache ├── tsconfig.json ├── .travis.yml ├── firebase.json ├── README.md └── package.json /src/utils/keycodes.ts: -------------------------------------------------------------------------------- 1 | export const ESC = 27 2 | -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json" 3 | } -------------------------------------------------------------------------------- /types/images.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' 2 | declare module '*.png' 3 | declare module '*.jpg' 4 | -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rotoshine/you-are-not-a-solitary-gourmet/HEAD/public/favicon.png -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "module": "commonjs" 5 | } 6 | } -------------------------------------------------------------------------------- /functions/index.js: -------------------------------------------------------------------------------- 1 | const functions = require('firebase-functions') 2 | 3 | const admin = require('firebase-admin') 4 | admin.initializeApp() -------------------------------------------------------------------------------- /functions/.gitignore: -------------------------------------------------------------------------------- 1 | ## Compiled JavaScript files 2 | **/*.js 3 | **/*.js.map 4 | 5 | # Typescript v1 declaration files 6 | typings/ 7 | 8 | node_modules/ -------------------------------------------------------------------------------- /travis/build-notify.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | curl -X POST -H 'Content-type: application/json' --data '{"text":"안고미 배포 완료!", "channel":"#angomi-dev"}' $REACT_APP_SLACK_HOOK -------------------------------------------------------------------------------- /storage.rules: -------------------------------------------------------------------------------- 1 | service firebase.storage { 2 | match /b/{bucket}/o { 3 | match /{allPaths=**} { 4 | allow read; 5 | allow write: if request.auth.uid != null; 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /firestore.rules: -------------------------------------------------------------------------------- 1 | service cloud.firestore { 2 | match /databases/{database}/documents { 3 | match /{document=**} { 4 | allow read; 5 | allow write: if request.auth.uid != null; 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/pages/PageNotFoundPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | const PageNotFoundPage = () => ( 4 |
5 | 페이지가 없습니다! 6 | 오다 노부나가 이 페이지를 만들어줘요! 7 |
8 | ) 9 | 10 | export default PageNotFoundPage 11 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/styled-components.ts: -------------------------------------------------------------------------------- 1 | // styled-components.ts 2 | import * as styledComponents from 'styled-components' 3 | 4 | const { 5 | default: styled, 6 | css, 7 | injectGlobal, 8 | keyframes, 9 | } = styledComponents 10 | 11 | export { css, injectGlobal, keyframes } 12 | export default styled 13 | -------------------------------------------------------------------------------- /firestore.indexes.json: -------------------------------------------------------------------------------- 1 | { 2 | // Example: 3 | // 4 | // "indexes": [ 5 | // { 6 | // "collectionId": "widgets", 7 | // "fields": [ 8 | // { "fieldPath": "foo", "mode": "ASCENDING" }, 9 | // { "fieldPath": "bar", "mode": "DESCENDING" } 10 | // ] 11 | // } 12 | // ] 13 | "indexes": [] 14 | } -------------------------------------------------------------------------------- /src/stores/index.ts: -------------------------------------------------------------------------------- 1 | import PartyStore from './PartyStore' 2 | import UserStore from './UserStore' 3 | import DestinationsStore from './DestinationsStore' 4 | 5 | const stores = { 6 | partyStore: new PartyStore(), 7 | userStore: new UserStore(), 8 | destinationsStore: new DestinationsStore(), 9 | } 10 | 11 | export default stores 12 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | const { injectBabelPlugin } = require('react-app-rewired') 2 | const rewireStyledComponents = require('react-app-rewire-styled-components') 3 | 4 | module.exports = function override(config, env) { 5 | config = injectBabelPlugin('transform-decorators-legacy', config) 6 | config = rewireStyledComponents(config, env) 7 | 8 | return config 9 | } -------------------------------------------------------------------------------- /types/bluebird-shim.d.ts: -------------------------------------------------------------------------------- 1 | import * as Bluebird from 'bluebird'; 2 | 3 | export interface DummyConstructor extends Bluebird { 4 | new(): Bluebird; 5 | all: any; 6 | race: any; 7 | } 8 | 9 | declare global { 10 | interface Promise extends Bluebird {} 11 | 12 | interface PromiseConstructor extends DummyConstructor {} 13 | } -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint-config-airbnb", 4 | "tslint-config-prettier" 5 | ], 6 | "linterOptions": { 7 | "exclude": [ 8 | "config/**/*.js", 9 | "node_modules/**/*.ts" 10 | ] 11 | }, 12 | "rules": { 13 | "semicolon": [true, "never"], 14 | "variable-name": [false], 15 | "import-name": [false] 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/GoogleLoginButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { signIn } from './utils/user' 3 | 4 | export default class GoogleLoginButton extends React.Component { 5 | render () { 6 | return ( 7 | 13 | ) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "yanasg", 3 | "name": "You are not a solitary gourmet", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#a5007d", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/stores/DestinationsStore.ts: -------------------------------------------------------------------------------- 1 | import { action, observable } from 'mobx' 2 | import { subscribeDestinations } from '../utils/destination' 3 | 4 | export default class DestinationsStore implements IDestinationsStore { 5 | @observable destinations: Destination[] = [] 6 | 7 | @action initializeDestinations() { 8 | subscribeDestinations((destinations: Destination[]) => { 9 | this.destinations = [...destinations] 10 | }) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/utils/firestore.ts: -------------------------------------------------------------------------------- 1 | import firebase from './firebase' 2 | 3 | const firestore = firebase.firestore() 4 | 5 | const settings = { 6 | timestampsInSnapshots: true, 7 | } 8 | 9 | firestore.settings(settings) 10 | 11 | export const createSubscribe = (query: any, callback: Function) => { 12 | return { 13 | subscibe: query.onSnapshot(callback), 14 | unsubscribe: query.onSnapshot(() => {}), 15 | } 16 | } 17 | 18 | export default firestore 19 | -------------------------------------------------------------------------------- /src/utils/giphy.ts: -------------------------------------------------------------------------------- 1 | import 'isomorphic-fetch' 2 | import { camelizeKeys } from 'humps' 3 | 4 | const API_KEY = process.env.REACT_APP_GIPHY_API_KEY 5 | 6 | const END_POINT = { 7 | SEARCH: '//api.giphy.com/v1/gifs/search', 8 | } 9 | 10 | export const search = async(keyword: string): Promise => { 11 | const res = await fetch(`${END_POINT.SEARCH}?api_key=${API_KEY}&q=${keyword}&limit=10`) 12 | return (camelizeKeys(await res.json()) as GiphySearchResult) 13 | } 14 | -------------------------------------------------------------------------------- /functions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "lib": ["es6"], 4 | "module": "commonjs", 5 | "noImplicitReturns": true, 6 | "outDir": "lib", 7 | "sourceMap": true, 8 | "target": "es6" 9 | }, 10 | "compileOnSave": true, 11 | "include": [ 12 | "src" 13 | ], 14 | "files": [ 15 | "../types/model.d.ts", 16 | "node_modules/typescript/lib/lib.es6.d.ts" 17 | ], 18 | "exclude": [ 19 | "../node_modules", 20 | "node_modules" 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /src/index.tsx: -------------------------------------------------------------------------------- 1 | import './index.css' 2 | import 'bootstrap/dist/css/bootstrap.min.css' 3 | 4 | import 'moment/locale/ko' 5 | 6 | import * as React from 'react' 7 | import * as ReactDOM from 'react-dom' 8 | import { Provider } from 'mobx-react' 9 | 10 | import App from './App' 11 | 12 | import stores from './stores' 13 | 14 | import { unregister } from './registerServiceWorker' 15 | 16 | ReactDOM.render( 17 | ( 18 | 19 | 20 | 21 | ), 22 | document.getElementById('root') as HTMLDivElement, 23 | ) 24 | unregister() 25 | -------------------------------------------------------------------------------- /src/stores/UserStore.ts: -------------------------------------------------------------------------------- 1 | import { action, observable, computed } from 'mobx' 2 | import { isEmpty } from 'lodash' 3 | 4 | import { loadCurrentUser, handleSignOut } from '../utils/user' 5 | 6 | export default class UserStore { 7 | @observable user: (User | null) = null 8 | 9 | @computed get isExistUser() { 10 | return !isEmpty(this.user) 11 | } 12 | 13 | @action async initializeUser() { 14 | const user = await loadCurrentUser() 15 | 16 | if (user) { 17 | this.user = user 18 | } 19 | } 20 | 21 | @action async signOut() { 22 | await handleSignOut() 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/utils/firebase.ts: -------------------------------------------------------------------------------- 1 | import * as firebase from 'firebase/app' 2 | import 'firebase/firestore/dist/index.cjs' 3 | import 'firebase/auth/dist/index.cjs' 4 | 5 | const { 6 | REACT_APP_FIREBASE_API_KEY, 7 | REACT_APP_FIREBASE_AUTH_DOMAIN, 8 | REACT_APP_FIREBASE_DATABASE_URL, 9 | REACT_APP_FIREBASE_PROJECT_ID, 10 | } = process.env 11 | 12 | firebase.initializeApp({ 13 | apiKey: REACT_APP_FIREBASE_API_KEY, 14 | authDomain: REACT_APP_FIREBASE_AUTH_DOMAIN, 15 | databaseURL: REACT_APP_FIREBASE_DATABASE_URL, 16 | projectId: REACT_APP_FIREBASE_PROJECT_ID, 17 | }) 18 | 19 | export default firebase 20 | -------------------------------------------------------------------------------- /types/stores.d.ts: -------------------------------------------------------------------------------- 1 | declare interface IAllStore { 2 | userStore: IUserStore, 3 | partyStore: IPartyStore, 4 | destinationsStore: IDestinationsStore 5 | } 6 | 7 | declare interface IPartyStore { 8 | initializedParty: boolean 9 | categories: Category[] | null 10 | parties: Party[] | null 11 | initializeParties(): void 12 | } 13 | 14 | declare interface IUserStore { 15 | user: User | null 16 | initializeUser(): void 17 | isExistUser(): boolean 18 | signOut(): void 19 | } 20 | 21 | declare interface IDestinationsStore { 22 | destinations: Destination[] | null 23 | initializeDestinations(): void 24 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | functions/node_modules 6 | 7 | # testing 8 | /coverage 9 | 10 | # production 11 | /build 12 | functions/build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # firebase 27 | .firebaserc 28 | 29 | # deploy to s3 30 | build-aws.sh 31 | 32 | # firebase functions 33 | functions/**/*.js 34 | functions/**/*.js.map 35 | functions/index.html 36 | -------------------------------------------------------------------------------- /src/stores/PartyStore.ts: -------------------------------------------------------------------------------- 1 | import { observable, action } from 'mobx' 2 | import { subscribeTodayParties } from '../utils/party' 3 | 4 | import categories from '../data/categories' 5 | 6 | export default class PartyStore implements IPartyStore { 7 | @observable initializeCategories: boolean = false 8 | @observable categories: (Category[]) = categories 9 | @observable parties: (Party[] | null) = null 10 | @observable initializedParty: boolean = false 11 | 12 | @action 13 | initializeParties = () => { 14 | subscribeTodayParties((parties: Party[]) => { 15 | this.parties = [...parties] 16 | this.initializedParty = true 17 | }) 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/utils/slack.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { isString } from 'lodash' 3 | const SLACK_HOOK = process.env.REACT_APP_SLACK_HOOK 4 | 5 | export const isValidSlackHook = () => ( 6 | isString(SLACK_HOOK) && 7 | SLACK_HOOK.indexOf('https://') === 0 8 | ) 9 | 10 | export const notifyToSlack = async (text: string) => { 11 | if (text && text.length > 0 && isValidSlackHook()) { 12 | const options = { 13 | text, 14 | } 15 | 16 | try { 17 | if (SLACK_HOOK) { 18 | await axios.post(SLACK_HOOK, JSON.stringify(options)) 19 | } 20 | return true 21 | } catch (e) { 22 | console.log(e) 23 | } 24 | } 25 | 26 | return false 27 | } 28 | -------------------------------------------------------------------------------- /src/utils/misc.ts: -------------------------------------------------------------------------------- 1 | import { flow } from 'lodash' 2 | 3 | export const go = (v: any, ...fns: any[]): any => flow(fns)(v) 4 | 5 | export async function asyncSetState(callback: () => void) { 6 | return new Promise(callback) 7 | } 8 | 9 | export function stripScripts(html: string) { 10 | const $div = document.createElement('div') 11 | $div.innerHTML = html 12 | const $scripts = $div.getElementsByTagName('script') 13 | let i = $scripts.length 14 | if ($scripts && i > 0) { 15 | while (i > 0) { 16 | const $script = $scripts[i] 17 | i = i - 1 18 | if ($script && $script.parentNode !== null){ 19 | $script.parentNode.removeChild($script) 20 | } 21 | } 22 | } 23 | 24 | return $div.innerHTML 25 | } -------------------------------------------------------------------------------- /functions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "functions", 3 | "scripts": { 4 | "lint": "tslint --project tsconfig.json", 5 | "build": "tsc", 6 | "serve": "npm run build && firebase serve --only functions", 7 | "shell": "npm run build && firebase functions:shell", 8 | "start": "npm run shell", 9 | "deploy": "firebase deploy --only functions", 10 | "logs": "firebase functions:log" 11 | }, 12 | "main": "lib/index.js", 13 | "dependencies": { 14 | "cors": "^2.8.4", 15 | "express": "^4.16.3", 16 | "firebase-admin": "~5.12.1", 17 | "firebase-functions": "^3.3.0", 18 | "hangul-js": "^0.2.4" 19 | }, 20 | "devDependencies": { 21 | "@types/express": "^4.17.2", 22 | "@types/node": "^13.7.4", 23 | "tslint": "^5.8.0", 24 | "typescript": "^3.7.5" 25 | }, 26 | "private": true, 27 | "engines": { 28 | "node": "8" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css?family=Gothic+A1:300,400,500'); 2 | 3 | html { 4 | box-sizing: border-box; 5 | font-size: 10px; 6 | } 7 | 8 | 9 | body { 10 | margin: 0; 11 | padding: 0; 12 | font-family: 'Gothic+A1', sans-serif !important; 13 | font-weight: 400 !important; 14 | font-size: 1rem; 15 | } 16 | 17 | ul, li { 18 | list-style: none; 19 | } 20 | 21 | button { 22 | cursor: pointer; 23 | } 24 | 25 | body.modal-open { 26 | overflow: hidden; 27 | } 28 | 29 | body.hidden { 30 | height: 100vh; 31 | overflow: hidden; 32 | } 33 | 34 | .form-control:disabled, .form-control[readonly] { 35 | background-color: white !important; 36 | } 37 | 38 | @keyframes fadeIn { 39 | from { 40 | opacity: 0; 41 | } 42 | to { 43 | opacity: 1; 44 | } 45 | } 46 | 47 | @media screen and (max-width: 560px) { 48 | html { 49 | font-size: 9px; 50 | } 51 | } -------------------------------------------------------------------------------- /.firebase/hosting.YnVpbGQ.cache: -------------------------------------------------------------------------------- 1 | asset-manifest.json,1582166303169,fc585f140a237ac7aea212d769f287a7c45771951e01c01fdee904f74e9e371c 2 | index.html,1582166303169,fe90480df9064537cc5056ce2785c113273b02ba72b159168c4a8d6936bac130 3 | favicon.png,1582166291505,dda3d75e5741519cb11b2563444a875e098557ad02047683a78dde7e6066b70e 4 | service-worker.js,1582166303342,75cae62a25e19a7083ad787406b8109abd9575c974526f1ff97cf45da08e32be 5 | manifest.json,1582166291506,e45bcb1c6a9bff53420926ec7b941e4b9c8e9d8c15366a6bb730281be0cb316d 6 | static/css/main.c7c2df19.css,1582166303223,b9e34e8c4a655015df0286acf3e4c74dce48e7afd647f4c90042df7d7cea04f3 7 | static/css/main.c7c2df19.css.map,1582166303203,d86e4ae054f1ad0ed0aa67230516fe362fa462a5ec5c59d72f1cd2598b84bdde 8 | static/js/main.2bb46481.js,1582166303178,7ac05c1f7feacd6b55fa0ac5100f0ff49dc20b901695d15586ea729677f865fb 9 | static/js/main.2bb46481.js.map,1582166303221,683ff865d783ad35daa133f5f9f164d51625848700fabc7fde63310ac54cfaed 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "outDir": "build/dist", 5 | "module": "esnext", 6 | "target": "es5", 7 | "lib": ["es2016", "dom", "es2015.promise"], 8 | "sourceMap": true, 9 | "allowJs": true, 10 | "jsx": "react", 11 | "moduleResolution": "node", 12 | "rootDir": "src", 13 | "forceConsistentCasingInFileNames": true, 14 | "noImplicitReturns": true, 15 | "noImplicitThis": true, 16 | "noImplicitAny": true, 17 | "strictNullChecks": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "noUnusedLocals": true, 20 | "experimentalDecorators": true 21 | }, 22 | "files": [ 23 | "./types/images.d.ts", 24 | "./types/bluebird-shim.d.ts", 25 | "./types/model.d.ts", 26 | "./types/stores.d.ts" 27 | ], 28 | "include": [ 29 | "src/**/*" 30 | ], 31 | "exclude": [ 32 | "node_modules", 33 | "build", 34 | "scripts", 35 | "acceptance-tests", 36 | "webpack", 37 | "jest", 38 | "src/setupTests.ts" 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | cache: 5 | directories: 6 | - node_modules 7 | script: 8 | - npm run build 9 | before_deploy: 10 | - cd functions && npm install && cd .. 11 | deploy: 12 | provider: firebase 13 | skip_cleanup: true 14 | project: angomi-9e9af 15 | on: 16 | branch: master 17 | token: 18 | secure: TEUSvPgs83+sUdOUk0WAvcZBdzLXqr2n/h4c3qkkAeb3pOiwPo3ZMLbU5c8AUT68L8REp3xqUyViyAtM/b2rXl2MneFggPc+Xlupiy02j1HuwsiUR/c3hQxztAFH9A4bQi5iYHBXK/dk08UC5ijhiilqPOnVWbUGBPNlTc1EDXbdG4bv/MzJNfHkaVUr276tIdv+xhV3ZPZuKf3iw4j+dfXMaSez544+7LxAV75YaZdn760ZotkL7DxzWbjxMyqcRAzbbSKYKNwEw0LfUvflzgjPl7o0CAvBKCd7hQHXP7X2R/MgD7rJimfoYFFrSRdbnUFS26WTm0uRLupLPhoUGegIDR7fPM34ZII1HYcueU2conynzk9vq4DxVv0mq6bkzH+5PatkCittFxXRHZXRXzFHyWMpM/sJz4nCjfugvnzVg56IdEJH/UN3fmaEQNN0Z5p+BSasSbekj/w7xdHe/yFmKtppO3b9caDWKgZK8WJwZsjHXlFw+q1Bmzf1/AXTlYXXoQUEgRk2OcEDTJytzz/i8hKhqRMbmP+BfwvFuLNMvNUYKJfDDhk+UstBDHkeMOEVpluwpdEDl0ZPL7Lhc9bPV0/qLDKJqdVnohQhT93522obS5inJxLY8tfz4eUphVg1srvItuBj3cTdkHpPIkEryB/Lw7tovlhn3vJSwZ8= 19 | after_deploy: 20 | - ./travis/build-notify.sh -------------------------------------------------------------------------------- /src/PartyTags.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { PartyTag, PartyTagLimit, PartyTagDelivery } from './PartyStyledComponents' 3 | 4 | interface Props { 5 | party: Party, 6 | } 7 | 8 | const renderMemberLimit = (party: Party) => { 9 | const { maxPartyMember, fetchedJoinners } = party 10 | 11 | if (!fetchedJoinners) { 12 | return null 13 | } 14 | 15 | if (maxPartyMember === 0) { 16 | return 무제한 멤버 17 | } 18 | 19 | const joinnedMemberCount = fetchedJoinners.length 20 | 21 | if (maxPartyMember > joinnedMemberCount) { 22 | return ( 23 | 총 {party.maxPartyMember}명 24 | ) 25 | } 26 | 27 | return 인원마감 28 | } 29 | 30 | const PartyTags: React.SFC = ({ party }) => { 31 | const { category } = party 32 | 33 | return ( 34 |
35 | {category.isRestaurant && 식사} 36 | {category.isDeliverable && 배달} 37 | {category.isPlaying && 놀이} 38 | {category.isTravel && 여행} 39 | {renderMemberLimit(party)} 40 |
41 | ) 42 | } 43 | 44 | export default PartyTags 45 | -------------------------------------------------------------------------------- /firebase.json: -------------------------------------------------------------------------------- 1 | { 2 | "firestore": { 3 | "rules": "firestore.rules", 4 | "indexes": "firestore.indexes.json" 5 | }, 6 | "hosting": { 7 | "public": "build", 8 | "ignore": [ 9 | "firebase.json", 10 | "**/.*", 11 | "**/node_modules/**" 12 | ], 13 | "rewrites": [ 14 | { 15 | "source": "/parties/**", 16 | "function": "partyDetail" 17 | }, 18 | { 19 | "source": "**", 20 | "destination": "/index.html" 21 | } 22 | ], 23 | "headers": [ 24 | { 25 | "source": "**/*.html", 26 | "headers": [ 27 | { 28 | "key": "Cache-Control", 29 | "value": "private, max-age=0, no-cache" 30 | } 31 | ] 32 | }, 33 | { 34 | "source": "**/*.@(css|js)", 35 | "headers": [ 36 | { 37 | "key": "Cache-Control", 38 | "value": "max-age=3500" 39 | } 40 | ] 41 | } 42 | ] 43 | }, 44 | "storage": { 45 | "rules": "storage.rules" 46 | }, 47 | "functions": { 48 | "predeploy": [ 49 | "npm --prefix \"$RESOURCE_DIR\" run lint", 50 | "npm --prefix \"$RESOURCE_DIR\" run build" 51 | ], 52 | "source": "functions" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/PartyStyledComponents.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const PartyTitle = styled.h2` 4 | margin-top: 2rem; 5 | font-size: 2.4rem; 6 | ` 7 | 8 | export const PartyJoinnerGroup = styled.span` 9 | width: 100%; 10 | height: 100%; 11 | em { 12 | display: 13 | } 14 | ` 15 | 16 | export const PartyTag = styled.span` 17 | width: 60px; 18 | background: orange; 19 | padding: 6px 10px; 20 | margin-right: 10px; 21 | line-height: 26px; 22 | font-size: 1.4rem; 23 | border-radius: 2px; 24 | ` 25 | 26 | export const PartyTagLimit = PartyTag.extend` 27 | background: #20c997; 28 | ` 29 | 30 | export const PartyTagDelivery = PartyTag.extend` 31 | background: #007bff; 32 | ` 33 | 34 | export const PartyTagSoldOut = PartyTag.extend` 35 | background: #ff0018; 36 | ` 37 | 38 | export const PartyJoinners = styled.div` 39 | margin: 0.6rem 0; 40 | display: flex; 41 | justify-content: space-between; 42 | align-items: center; 43 | ` 44 | 45 | export const PartyJoinnerPhoto = styled.div` 46 | display: flex; 47 | img { 48 | width: 40px; 49 | height: 40px; 50 | border-radius: 20px; 51 | border: 1px solid #a5a5a5; 52 | margin-right: -1.5rem; 53 | } 54 | ` 55 | 56 | export const PartyItemInfoText = styled.p` 57 | margin-bottom: 0.4rem; 58 | font-size: 1.6rem; 59 | ` 60 | -------------------------------------------------------------------------------- /src/utils/destination.ts: -------------------------------------------------------------------------------- 1 | import firestore from './firestore' 2 | 3 | const COLLECTION_NAME = 'destinations' 4 | 5 | const getPartyCreadtedCount = (querySnapshot: any): number => { 6 | if (querySnapshot && querySnapshot.exists && querySnapshot.data()) { 7 | return querySnapshot.data().partyCreatedCount + 1 8 | } 9 | 10 | return 1 11 | } 12 | 13 | export const saveDestination = async ( 14 | destinationName: string, 15 | meta: CategoryMeta) => { 16 | 17 | try { 18 | const { 19 | isRestaurant, 20 | isDeliverable, 21 | isTravel, 22 | isPlaying, 23 | } = meta 24 | 25 | const alreadyStoredDestination = await firestore 26 | .collection(COLLECTION_NAME) 27 | .doc(destinationName) 28 | .get() 29 | 30 | const partyCreatedCount = getPartyCreadtedCount(alreadyStoredDestination) 31 | 32 | await firestore.collection(COLLECTION_NAME).doc(destinationName).set({ 33 | partyCreatedCount, 34 | isRestaurant, 35 | isDeliverable, 36 | isTravel, 37 | isPlaying, 38 | }) 39 | } catch (e) { 40 | console.log(e) 41 | } 42 | } 43 | 44 | export const subscribeDestinations = (callback: Function): void => { 45 | firestore.collection(COLLECTION_NAME) 46 | .onSnapshot((querySnapshot: any) => { 47 | callback( 48 | querySnapshot.docs.map( 49 | (queryData: any) => ({ 50 | id: queryData.id, 51 | ...queryData.data(), 52 | }), 53 | ), 54 | ) 55 | }) 56 | } 57 | 58 | export const unsubscribeDestinations = () => { 59 | return firestore.collection(COLLECTION_NAME).onSnapshot(() => { }) 60 | } 61 | -------------------------------------------------------------------------------- /src/PartyJoinButton.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | 4 | type Props = { 5 | party: Party, 6 | user: User | null, 7 | onJoinParty: Function, 8 | onLeaveParty: Function, 9 | } 10 | 11 | const isAlreadyJoin = (party: Party, user: User | null) => 12 | user && 13 | party.fetchedJoinners && 14 | party.fetchedJoinners.find((joinner: User) => joinner.email === user.email) 15 | 16 | const isFinished = (party: Party) => { 17 | if (party.category.hasDueDateTime && party.dueDateTime) { 18 | return party.dueDateTime.toDate() < new Date() 19 | } 20 | 21 | return false 22 | } 23 | 24 | const PartyButton = styled.button` 25 | background: #FF7744; 26 | border: #FF7744; 27 | color: white; 28 | padding: 1.4rem 0 !important; 29 | box-shadow: 0px 2px 16px rgba(255, 119, 68, 0.5); 30 | font-size: 1.4rem !important; 31 | transition: all 0.4s ease; 32 | width: 100%; 33 | border-radius: 6px; 34 | 35 | &:hover { 36 | transform: scale(0.98); 37 | } 38 | ` 39 | 40 | const PartyJoinButton: React.SFC = ({ 41 | party, 42 | user, 43 | onJoinParty, 44 | onLeaveParty, 45 | }: Props) => { 46 | if (isAlreadyJoin(party, user)) { 47 | return ( 48 | onLeaveParty(party.id, user && user.email)}>참여 취소하기 51 | ) 52 | } 53 | 54 | if (isFinished(party)) { 55 | return 저런! 파티 마감시간이 지났네요 :( 56 | } 57 | 58 | return ( onJoinParty( 59 | party.id, user && user.email, 60 | )}>파티합류!) 61 | } 62 | 63 | export default PartyJoinButton 64 | -------------------------------------------------------------------------------- /src/CommonStyledComponents.ts: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const Overlay = styled.div` 4 | position: fixed; 5 | z-index: 10; 6 | background: rgba(0,0,0,0.8); 7 | display: flex; 8 | justify-content: center; 9 | align-items: center; 10 | width: 100%; 11 | height: 100%; 12 | top: 0; 13 | left: 0; 14 | overflow-y: scroll; 15 | animation: fadeIn 0.4s ease; 16 | ` 17 | 18 | export const CenterText = styled.div` 19 | width: 300px; 20 | background: white; 21 | padding: 4rem 3rem; 22 | position: relative; 23 | border-radius: 6px; 24 | height: 200px; 25 | display: flex; 26 | justify-content: center; 27 | align-items: center; 28 | text-align: center; 29 | ` 30 | 31 | export const CloseBtn = styled.div` 32 | position: fixed; 33 | top: 0; 34 | right: 0; 35 | z-index: 100; 36 | width: 3rem; 37 | height: 3rem; 38 | cursor: pointer; 39 | padding: 4rem 10rem; 40 | transition: transform 0.2s ease; 41 | &:before { 42 | content: ''; 43 | width: 3rem; 44 | height: 3px; 45 | background: white; 46 | position: fixed; 47 | transform: rotate(-45deg); 48 | @media screen and (max-width: 600px) { 49 | background: #484848; 50 | } 51 | } 52 | &:after { 53 | content: ''; 54 | width: 3rem; 55 | height: 3px; 56 | background: white; 57 | position: fixed; 58 | transform: rotate(45deg); 59 | @media screen and (max-width: 600px) { 60 | background: #484848; 61 | } 62 | } 63 | &:hover { 64 | transform: translate(-2px,2px); 65 | } 66 | @media screen and (max-width: 600px) { 67 | padding: 5rem 6rem 5rem 2rem; 68 | } 69 | ` 70 | -------------------------------------------------------------------------------- /src/DueCountDown.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import * as moment from 'moment' 4 | moment.locale('ko') 5 | 6 | type Props = { 7 | counterName: string, 8 | dueDateTime: Date, 9 | } 10 | 11 | type State = { 12 | dueCountDown: number, 13 | now: number, 14 | } 15 | 16 | const DueCountDownWrapper = styled.span` 17 | color: #ff2700; 18 | padding-left: 1rem; 19 | font-size: 1.6rem; 20 | ` 21 | 22 | const DueCountDownTime = styled.em` 23 | font-weight: 600; 24 | color: #ff2700; 25 | ` 26 | export default class DueCountDown extends React.Component { 27 | timer: number | undefined 28 | constructor(props: Props) { 29 | super(props) 30 | this.timer = undefined 31 | this.state = { 32 | dueCountDown: new Date(props.dueDateTime).getTime(), 33 | now: new Date().getTime(), 34 | } 35 | } 36 | 37 | componentDidMount() { 38 | this.timer = window.setInterval( 39 | () => { 40 | this.setState({ 41 | now: new Date().getTime(), 42 | }) 43 | }, 44 | 1000, 45 | ) 46 | } 47 | 48 | componentWillUnmount() { 49 | window.clearInterval(this.timer) 50 | } 51 | 52 | render() { 53 | const { dueCountDown, now } = this.state 54 | const { counterName } = this.props 55 | 56 | if (dueCountDown < now) { 57 | return ( 58 | 59 | {counterName} 지났군요😢 60 | 61 | ) 62 | } 63 | 64 | const durationTime = moment.duration(moment(dueCountDown).diff(moment(now))).humanize() 65 | 66 | return ( 67 | 68 | {counterName}까지 {durationTime} 남았습니다! 69 | 70 | ) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 안 고독한 미식가 [![Build Status](https://travis-ci.org/rotoshine/you-are-not-a-solitary-gourmet.svg?branch=master)](https://travis-ci.org/rotoshine/you-are-not-a-solitary-gourmet) 2 | 3 | 식사, 커피타임, 각종 문화생활을 함께 할 파티를 만들고 모집하는 웹 애플리케이션 4 | 5 | # 설치 및 배포 6 | 7 | ## node.js 8 | 9 | `create-react-app` 기반의 앱이므로 `node.js` 설치가 필요하다. 10 | 11 | ## npm install 12 | 13 | `git clone` 후 `npm install`로 필요 모듈들을 설치해주자. 14 | 15 | ## firebase project 생성 16 | 17 | `firebase`의 auth, firestore를 이용하여 구현되어있어서 firebase project 생성이 필요하다. 18 | 19 | https://console.firebase.google.com 에서 생성하자. 20 | 21 | ## .env 생성 22 | 23 | `.env`를 통해 프로젝트에 필요한 환경변수들을 주입 받도록 되어있다. 24 | 25 | 프로젝트 루트에 `.env`를 만들고 위에서 만든 firebase project의 API 키들을 해당하는 값들에 채워넣는다. 26 | 27 | 파티 생성 알람을 slack으로 받고 싶다면 slack hook url을 만들어서 해당 값에 넣는다. 28 | 29 | ``` 30 | # firebase setting 31 | REACT_APP_FIREBASE_API_KEY=apiKey 32 | REACT_APP_FIREBASE_AUTH_DOMAIN=authDomain 33 | REACT_APP_FIREBASE_DATABASE_URL=databaseUrl 34 | REACT_APP_FIREBASE_PROJECT_ID=projectId 35 | 36 | # slack setting 37 | REACT_APP_SLACK_HOOK=your slack hook url 38 | ``` 39 | 40 | ## .firebaserc 생성 41 | 42 | `firebase hoting`에 배포를 해서 쓸 경우 `.firebaserc` 파일을 프로젝트 루트에 생성한다. 43 | 44 | `default` 에 생성한 `firebase` 프로젝트 명을 넣는다. 45 | 46 | ```json 47 | { 48 | "projects": { 49 | "default": YOUR_FIREBASE_PROJECT_NAME 50 | } 51 | } 52 | ``` 53 | 54 | ## firebase functions 세팅 55 | 56 | firebase functions에 SSR을 위한 처리가 되어있다. 57 | 58 | 사용을 위해선 `firebase-tools`를 설치한다. 59 | 60 | ``` 61 | npm install -g firebase-tools 62 | ``` 63 | 64 | ### config 65 | 66 | 아래의 커맨드로 firebase functions에서 사용할 환경변수를 정의한다. 67 | 68 | ``` 69 | firebase functions:config:set angomi.domain=YOUR_SERVICE_URL 70 | ``` 71 | 72 | # 만드는 사람들 73 | 74 | - 로토 - https://github.com/rotoshine 75 | - 루카스 - https://github.com/stardustrain 76 | - 오다 - https://github.com/yogicat 77 | - 알마 - https://github.com/colus001 -------------------------------------------------------------------------------- /src/data/categories.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | id: 'MEAL_LUNCH', 4 | name: '점심식사', 5 | isDeliverable: false, 6 | isRestaurant: true, 7 | isPlaying: false, 8 | isTravel: false, 9 | hasDueDateTime: false, 10 | emoji: '🍱', 11 | }, 12 | { 13 | id: 'MEAL_LUNCH_DELIVERY', 14 | name: '점심식사 - 배달', 15 | isDeliverable: true, 16 | isRestaurant: true, 17 | isPlaying: false, 18 | isTravel: false, 19 | hasDueDateTime: true, 20 | emoji: '🏍🍱', 21 | }, 22 | { 23 | id: 'MEAL_DINNER', 24 | name: '저녁식사', 25 | isDeliverable: false, 26 | isRestaurant: true, 27 | isPlaying: false, 28 | isTravel: false, 29 | hasDueDateTime: false, 30 | emoji: '🥘', 31 | }, 32 | { 33 | id: 'MEAL_DINNER_DELIVERY', 34 | name: '저녁식사 - 배달', 35 | isDeliverable: true, 36 | isRestaurant: true, 37 | isPlaying: false, 38 | isTravel: false, 39 | hasDueDateTime: true, 40 | emoji: '🏍🥘', 41 | }, 42 | { 43 | id: 'SNACK', 44 | name: '간식', 45 | isDeliverable: false, 46 | isRestaurant: true, 47 | isPlaying: false, 48 | isTravel: false, 49 | hasDueDateTime: false, 50 | emoji: '☕', 51 | }, 52 | { 53 | id: 'MEAL_AFTER_GAME', 54 | name: '밥 시켜먹고 게임하기', 55 | isDeliverable: true, 56 | isRestaurant: true, 57 | isPlaying: true, 58 | isTravel: false, 59 | hasDueDateTime: true, 60 | emoji: '🏍🍱 ➡ 🎮', 61 | }, 62 | { 63 | id: 'TRAVEL', 64 | name: '여행', 65 | isDeliverable: false, 66 | isRestaurant: false, 67 | isPlaying: false, 68 | isTravel: true, 69 | hasDueDateTime: true, 70 | emoji: '🏖', 71 | }, 72 | { 73 | id: 'PLAY', 74 | name: '놀이', 75 | isDeliverable: false, 76 | isRestaurant: false, 77 | isPlaying: true, 78 | isTravel: false, 79 | hasDueDateTime: false, 80 | emoji: '🏄', 81 | }, 82 | ] 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "you-are-not-a-solitary-gourmet", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "^0.18.1", 7 | "bluebird": "^3.5.1", 8 | "bootstrap": "^4.4.1", 9 | "es6-promise": "^4.2.4", 10 | "firebase": "^5.1.0", 11 | "humps": "^2.0.1", 12 | "isomorphic-fetch": "^2.2.1", 13 | "lodash": "^4.17.15", 14 | "mobx": "^5.0.3", 15 | "mobx-react": "^5.2.3", 16 | "moment": "^2.22.2", 17 | "react": "^16.4.1", 18 | "react-app-rewire-styled-components": "^3.0.2", 19 | "react-autocomplete": "^1.8.1", 20 | "react-dom": "^16.12.0", 21 | "react-flatpickr": "^3.6.4", 22 | "react-router-dom": "^4.3.1", 23 | "react-scripts": "1.1.4", 24 | "react-scripts-ts": "2.16.0", 25 | "react-select": "^1.2.1", 26 | "reactstrap": "^6.3.0", 27 | "styled-components": "^3.3.3" 28 | }, 29 | "scripts": { 30 | "start": "react-app-rewired start --scripts-version react-scripts-ts", 31 | "build": "react-app-rewired build --scripts-version react-scripts-ts", 32 | "postbuild": "cp build/index.html functions/index.html", 33 | "deploy": "npm run build && firebase deploy --project=you-are-not-a-solitary-gourmet", 34 | "test": "react-scripts test --env=jsdom --scripts-version react-scripts-ts", 35 | "eject": "react-scripts eject" 36 | }, 37 | "devDependencies": { 38 | "@types/bluebird": "^3.5.21", 39 | "@types/humps": "^1.1.2", 40 | "@types/jest": "^23.1.5", 41 | "@types/lodash": "^4.14.111", 42 | "@types/moment": "^2.13.0", 43 | "@types/node": "^10.5.2", 44 | "@types/react": "^16.4.6", 45 | "@types/react-autocomplete": "^1.8.3", 46 | "@types/react-dom": "^16.0.6", 47 | "@types/react-flatpickr": "^3.2.5", 48 | "@types/react-router-dom": "^4.2.7", 49 | "@types/react-select": "^1.2.10", 50 | "@types/reactstrap": "^6.0.0", 51 | "babel-plugin-transform-decorators-legacy": "^1.3.5", 52 | "mobx-react-devtools": "^5.0.1", 53 | "react-app-rewired": "^1.5.2", 54 | "tslint": "^6.0.0", 55 | "tslint-config-airbnb": "^5.9.2", 56 | "tslint-config-prettier": "^1.14.0", 57 | "typescript": "^2.9.2" 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /types/model.d.ts: -------------------------------------------------------------------------------- 1 | declare interface User { 2 | displayName: string 3 | photoURL: string 4 | email: string 5 | } 6 | 7 | type FirestoreDateType = { 8 | toDate: () => Date, 9 | } 10 | 11 | type FirestoreTimestamp = { 12 | seconds: number, 13 | nanoseconds: number, 14 | } 15 | 16 | declare interface PartyCommonProps { 17 | id?: string 18 | category: Category 19 | title: string 20 | description: string 21 | destinationName: string 22 | playName: string 23 | isDelivery: boolean 24 | maxPartyMember: number 25 | joinners: string[] 26 | createdBy: string 27 | createdAt: FirestoreTimestamp 28 | } 29 | 30 | declare interface Party extends PartyCommonProps { 31 | dueDateTime: FirestoreDateType | null 32 | partyTime: FirestoreDateType 33 | fetchedJoinners?: User[] 34 | } 35 | 36 | declare interface PartyFormData extends PartyCommonProps { 37 | partyTimeDate: Date 38 | dueDateTimeDate: Date | null 39 | } 40 | 41 | declare interface CategoryMeta { 42 | isRestaurant: boolean 43 | isDeliverable: boolean 44 | isTravel: boolean 45 | isPlaying: boolean 46 | hasDueDateTime?: boolean 47 | } 48 | 49 | declare interface Destination extends CategoryMeta { 50 | id: string 51 | partyCreatedCount: number 52 | } 53 | 54 | declare interface PartyCommentFormData { 55 | id?: string 56 | partyId: string 57 | content: string 58 | user: User 59 | } 60 | 61 | declare interface PartyComment extends PartyCommentFormData { 62 | isDisplay: boolean 63 | isEdited: boolean 64 | createdAt: { 65 | toDate: Function 66 | } 67 | createdBy: string 68 | updatedAt?: { 69 | toDate: Function 70 | } 71 | } 72 | 73 | declare interface Category extends CategoryMeta { 74 | id: string, 75 | name: string, 76 | emoji: string, 77 | } 78 | 79 | declare interface GiphySearchResult { 80 | data: Giphy[] 81 | } 82 | 83 | declare interface Giphy { 84 | type: string 85 | id: string 86 | slug: string 87 | url: string 88 | bitlyUrl: string 89 | embedUrl: string 90 | username: string 91 | source: string 92 | rating: string 93 | sourcePostUrl: string 94 | images: { 95 | fixedHeight: { 96 | url: string 97 | width: number 98 | height: number 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { inject, observer } from 'mobx-react' 3 | import { Switch, Route, BrowserRouter as Router } from 'react-router-dom' 4 | 5 | import PartyListPage from './pages/PartyListPage' 6 | import PartyFormPage from './pages/PartyFormPage' 7 | import PartyDetailPage from './pages/PartyDetailPage' 8 | 9 | import AuthenticateHeader from './AuthenticateHeader' 10 | import Footer from './Footer' 11 | 12 | import { unsubscribeDestinations } from './utils/destination' 13 | 14 | import './App.css' 15 | 16 | interface Props { } 17 | interface InjectedProps extends Props { 18 | destinationsStore: IDestinationsStore, 19 | partyStore: IPartyStore, 20 | } 21 | 22 | @inject((allStores: IAllStore) => ({ 23 | destinationsStore: allStores.destinationsStore as IDestinationsStore, 24 | partyStore: allStores.partyStore as IPartyStore, 25 | })) 26 | @observer 27 | class App extends React.Component { 28 | componentDidMount() { 29 | const { destinationsStore } = this.props as InjectedProps 30 | 31 | destinationsStore.initializeDestinations() 32 | } 33 | 34 | componentWillUnmount() { 35 | unsubscribeDestinations() 36 | } 37 | 38 | render() { 39 | const { partyStore } = this.props as InjectedProps 40 | const { categories } = partyStore 41 | 42 | return ( 43 |
44 | 45 | 46 | {categories && 47 | 48 | 52 | 53 | 58 | 63 | 68 | 69 | 70 | } 71 | 72 |
73 |
74 | ) 75 | } 76 | } 77 | 78 | export default App 79 | -------------------------------------------------------------------------------- /src/utils/partyComments.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | import firestore from './firestore' 5 | 6 | const COLLECTION_NAME = 'comments' 7 | 8 | const querySnapshotToArray = (querySnapshot: any) => ( 9 | querySnapshot.docs 10 | .map((doc: any) => ({ 11 | ...doc.data(), 12 | id: doc.id, 13 | })) 14 | .filter((comment: PartyComment) => comment.isDisplay) 15 | .sort((a: PartyComment, b: PartyComment) => a.createdAt.toDate() > b.createdAt.toDate()) 16 | ) 17 | 18 | export const savePartyComment = async (partyCommentFormData: PartyCommentFormData) => { 19 | const { id, user } = partyCommentFormData 20 | 21 | if (id) { 22 | const alreadySavedComment = await firestore.collection(COLLECTION_NAME).doc(id).get() 23 | 24 | if (alreadySavedComment && alreadySavedComment.exists) { 25 | await firestore.collection(COLLECTION_NAME).doc(id).update({ 26 | ...partyCommentFormData, 27 | isEdited: true, 28 | updatedAt: new Date(), 29 | }) 30 | } 31 | 32 | } else { 33 | await firestore.collection(COLLECTION_NAME).add({ 34 | ...partyCommentFormData, 35 | isDisplay: true, 36 | createdBy: user.email, 37 | createdAt: new Date(), 38 | }) 39 | } 40 | } 41 | 42 | export const findCommentsByPartyId = async (partyId: string) => { 43 | const querySnapshot = await firestore 44 | .collection(COLLECTION_NAME) 45 | .where('partyId', '==', partyId) 46 | .get() 47 | 48 | if (querySnapshot) { 49 | return querySnapshotToArray(querySnapshot) 50 | } 51 | } 52 | 53 | export const removePartyComment = async (commentId: string) => { 54 | await firestore.collection(COLLECTION_NAME).doc(commentId).update({ 55 | isDisplay: false, 56 | }) 57 | } 58 | 59 | export const subscribeComments = (partyId: string, callback: Function) => { 60 | firestore.collection(COLLECTION_NAME) 61 | .where('partyId', '==', partyId) 62 | .where('isDisplay', '==', true) 63 | .onSnapshot((querySnapshot: any) => { 64 | return callback(querySnapshotToArray(querySnapshot)) 65 | }) 66 | } 67 | 68 | export const unsubscibeComments = () => { 69 | firestore.collection(COLLECTION_NAME).onSnapshot(() => { }) 70 | } 71 | 72 | export default { 73 | savePartyComment, 74 | findCommentsByPartyId, 75 | removePartyComment, 76 | subscribeComments, 77 | unsubscibeComments, 78 | } 79 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /src/PartyForm.css: -------------------------------------------------------------------------------- 1 | .PartyForm-overlay { 2 | position: fixed; 3 | z-index: 10; 4 | background: rgba(0,0,0,0.5); 5 | display: flex; 6 | justify-content: center; 7 | align-items: center; 8 | width: 100%; 9 | height: 100vh; 10 | top: 0; 11 | left: 0; 12 | } 13 | 14 | .PartyForm-title { 15 | display: flex; 16 | justify-content: space-between; 17 | margin-top: 1rem; 18 | margin-bottom: 3rem; 19 | align-items: flex-start; 20 | position: relative; 21 | } 22 | 23 | .PartyForm-title h3 { 24 | font-size: 3rem; 25 | } 26 | 27 | .PartyForm-group { 28 | width: 600px; 29 | background: white; 30 | padding: 4rem 3rem; 31 | position: relative; 32 | border-radius: 6px; 33 | } 34 | 35 | .PartyForm-form { 36 | display: flex; 37 | flex-direction: column; 38 | padding: 0 10px; 39 | font-size: 1.6rem; 40 | line-height: 2; 41 | } 42 | 43 | .PartyForm__inline { 44 | display: flex; 45 | justify-content: space-between; 46 | } 47 | 48 | .PartyForm__button { 49 | background: #FF7744; 50 | border: #FF7744; 51 | color: white; 52 | margin-top: 2rem; 53 | padding: 1.4rem 0 !important; 54 | box-shadow: 0px 2px 16px rgba(255, 119, 68, 0.5); 55 | font-size: 1.4rem !important; 56 | transition: all 0.4s ease; 57 | } 58 | 59 | .PartyForm__button:hover { 60 | transform: scale(0.98); 61 | } 62 | 63 | .PartyForm__form-control.form-control { 64 | padding-left: 1.6rem; 65 | font-size: 1.8rem; 66 | line-height: 1.5 !important; 67 | } 68 | 69 | .PartyForm__autocomplete { 70 | 71 | } 72 | 73 | .PartyForm__autocompleteItem { 74 | padding-left: 1.6rem; 75 | padding-right: 1.6.rem; 76 | font-size: 1.8rem; 77 | line-height: 1.5; 78 | cursor: pointer; 79 | } 80 | 81 | .PartyForm-form-delivery { 82 | margin-top: 38px; 83 | } 84 | 85 | .PartyForm-form-delivery .PartyForm__form-control { 86 | width: 16px !important; 87 | } 88 | 89 | textarea { 90 | height: 20rem !important; 91 | } 92 | 93 | .flatpickr-input { 94 | border: 0; 95 | width: 100%; 96 | } 97 | 98 | .showformobile { 99 | display: none; 100 | } 101 | 102 | @media screen and (max-width: 600px) { 103 | .PartyForm-group { 104 | border-radius: 0; 105 | width: 100%; 106 | height: 100%; 107 | overflow-y: scroll; 108 | } 109 | 110 | .Partyform__form-location { 111 | height: 70px; 112 | } 113 | 114 | .form-delivery label { 115 | width: 100%; 116 | } 117 | 118 | .form-delivery input { 119 | display: inline; 120 | width: 20px; 121 | margin-right: 0.4rem; 122 | } 123 | .showformobile { 124 | display: initial; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /src/PartyComments.css: -------------------------------------------------------------------------------- 1 | .PartyComments { 2 | flex: 1; 3 | border-top: 1px solid #bfbfbf; 4 | } 5 | 6 | .PartyComments__list { 7 | padding: 0px; 8 | font-size: 14px; 9 | } 10 | 11 | .PartyComments__comment { 12 | display: flex; 13 | flex-direction: row; 14 | margin: 1.6rem 0; 15 | font-size: 1.6rem; 16 | } 17 | 18 | .PartyComments__comment .PartyComments__userImage { 19 | width: 25px; 20 | height: 25px; 21 | border-radius: 13px; 22 | margin-right: 1rem; 23 | margin-top: 0.2rem; 24 | } 25 | 26 | .PartyComments__commentline { 27 | line-height: 1.5; 28 | font-size: 1.6rem; 29 | width: 85%; 30 | display: inline-block; 31 | word-break: keep-all; 32 | } 33 | 34 | .PartyComments__commentUser { 35 | margin-right: 3px; 36 | position: relative; 37 | } 38 | 39 | .PartyComments__commentUser span { 40 | text-transform: capitalize; 41 | font-weight: 500; 42 | font-size: 1.6rem; 43 | } 44 | 45 | .PartyComments__buttons { 46 | position: absolute; 47 | top: 0; 48 | right: 1rem; 49 | font-size: 1.4rem; 50 | } 51 | 52 | .PartyComments__commentContent { 53 | flex: 1; 54 | line-height: 1.5 !important; 55 | } 56 | 57 | .PartyComments__actions { 58 | width: 50px; 59 | padding-left: 5px; 60 | text-align: right; 61 | } 62 | 63 | .PartyComments__comment--input { 64 | /* border-top: 1px solid #999; */ 65 | /* border-bottom: 1px solid #99999970; */ 66 | padding-bottom: 1rem; 67 | } 68 | 69 | .PartyComments__comment--input form { 70 | flex: 1; 71 | } 72 | 73 | .PartyComments__comment--input form input { 74 | width: 100%; 75 | } 76 | 77 | .comment-text { 78 | line-height: 31px; 79 | font-size: 12px; 80 | } 81 | 82 | .PartyComments__group { 83 | width: 100%; 84 | } 85 | 86 | .partyComments__createdAt { 87 | color: gray; 88 | line-height: 1 !important; 89 | width: 15%; 90 | display: inline-block; 91 | text-align: right; 92 | } 93 | 94 | .PartyDetail__partyContent .PartyList__tags { 95 | position: relative; 96 | } 97 | 98 | .PartyComments__nocomment { 99 | font-size: 1.4rem; 100 | padding: 1rem 0; 101 | color: gray; 102 | text-align: center; 103 | font-weight: 300; 104 | font-style: italic; 105 | } 106 | 107 | .PartyComments__comment .MakeParty__form-control { 108 | font-size: 1.6rem; 109 | } 110 | 111 | .PartyComments__edited { 112 | font-weight: 600; 113 | font-size: 0.8rem; 114 | margin-right: 0.5rem; 115 | } 116 | 117 | @media screen and (max-width: 560px) { 118 | .PartyComments__commentline { 119 | width: 85%; 120 | } 121 | 122 | .partyComments__createdAt { 123 | font-size: 1rem; 124 | } 125 | } -------------------------------------------------------------------------------- /src/pages/PartyDetailPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { inject, observer } from 'mobx-react' 3 | import { RouteComponentProps } from 'react-router-dom' 4 | 5 | import { Overlay, CloseBtn, CenterText } from '../CommonStyledComponents' 6 | import PartyDetail from '../PartyDetail' 7 | 8 | import { ESC } from '../utils/keycodes' 9 | 10 | import { joinParty, leaveParty } from '../utils/party' 11 | 12 | type Props = RouteComponentProps & { 13 | userStore: IUserStore, 14 | partyStore: IPartyStore, 15 | } 16 | 17 | @inject((allStores: IAllStore) => ({ 18 | userStore: allStores.userStore as IUserStore, 19 | partyStore: allStores.partyStore as IPartyStore, 20 | })) 21 | @observer 22 | class PartyDetailPage extends React.Component { 23 | componentDidMount() { 24 | document.body.classList.add('modal-open') 25 | document.body.addEventListener('keyup', this.handleKeyUp) 26 | } 27 | 28 | componentWillUnmount() { 29 | document.body.classList.remove('modal-open') 30 | document.body.removeEventListener('keyup', this.handleKeyUp) 31 | } 32 | 33 | close() { 34 | this.props.history.push('/') 35 | } 36 | 37 | handleKeyUp = (event: KeyboardEvent) => { 38 | if (event.keyCode !== ESC) return 39 | this.close() 40 | } 41 | 42 | render() { 43 | const { partyStore, userStore, match } = this.props 44 | const { user } = userStore 45 | const { initializedParty } = partyStore 46 | 47 | if (!initializedParty) { 48 | return ( 49 | this.close()}> 50 | 51 | 52 |

Loading..

53 |
54 |
55 | ) 56 | } 57 | 58 | const { parties } = partyStore 59 | const { partyId } = match.params 60 | 61 | if (parties && parties.length > 0) { 62 | const party = parties.find((party: Party) => party.id === partyId) 63 | 64 | if (party) { 65 | return ( 66 | this.close()}> 67 | 68 | joinParty(partyId, email)} 72 | onLeaveParty={(partyId: string, email: string) => leaveParty(partyId, email)} 73 | /> 74 | 75 | ) 76 | } 77 | } 78 | 79 | return ( 80 | this.close()}> 81 | 82 | 83 |

파티가 존재하지 않거나 이미 끝난 파티입니다.

84 |
85 |
86 | ) 87 | } 88 | } 89 | 90 | export default PartyDetailPage 91 | -------------------------------------------------------------------------------- /src/AuthenticateHeader.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { inject, observer } from 'mobx-react' 3 | 4 | import GoogleLoginButton from './GoogleLoginButton' 5 | 6 | import { asyncSetState } from './utils/misc' 7 | 8 | interface Props { } 9 | interface InjectedProps extends Props { 10 | userStore: IUserStore, 11 | } 12 | 13 | @inject((allStores: IAllStore) => ({ 14 | userStore: allStores.userStore as IUserStore, 15 | })) 16 | @observer 17 | class AuthenticateHeader extends React.Component { 18 | state = { 19 | userInitialized: false, 20 | } 21 | 22 | componentDidMount() { 23 | this.initializeUser() 24 | } 25 | 26 | async initializeUser() { 27 | const { userStore } = this.props as InjectedProps 28 | 29 | await userStore.initializeUser() 30 | 31 | await asyncSetState(() => { 32 | this.setState({ 33 | userInitialized: true, 34 | }) 35 | }) 36 | } 37 | 38 | render() { 39 | const { userInitialized } = this.state 40 | const { userStore } = this.props as InjectedProps 41 | const { isExistUser, signOut } = userStore 42 | 43 | return ( 44 |
45 | {!userInitialized && ( 46 |
47 |
48 |
49 |

50 | '안 고독한 미식가🔥' 51 |

52 |

안고미 클라우드에서 데이터를 긁어오는중 삐리리~

53 |
54 |
55 |
56 | )} 57 | {userInitialized && ( 58 |
59 |
60 | { 61 | isExistUser ? ( 62 |
63 | 69 |
70 | ) : 71 | } 72 |
73 |
74 |
75 | 안 고독한 미식가 76 |

오늘도 혼자인가요?

77 |

더이상 혼자 먹지 마세요.

78 |

원하는 파티가 없다구요? 직접 파티를 만들어보세요.

79 |
80 |
81 |
82 | )} 83 |
84 | ) 85 | } 86 | } 87 | 88 | export default AuthenticateHeader 89 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 31 | 32 | 33 | 42 | 안 고독한 미식가 43 | 44 | 45 | 46 | 49 |
50 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /src/Categories.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | 4 | import categories from './data/categories' 5 | 6 | interface Props { 7 | selectedCategory: Category | null, 8 | onSelect: (selectedCategory: Category | null) => void, 9 | } 10 | 11 | const CategoriesWrapper = styled.ul` 12 | display: flex; 13 | flex-wrap: wrap; 14 | justify-content: flex-start; 15 | padding: 0; 16 | margin: 3rem 0; 17 | ` 18 | 19 | const CategoryItem = styled.li` 20 | position: relative; 21 | width: 180px; 22 | height: 150px; 23 | margin: 0 14px 14px 0; 24 | box-shadow: 0 4px 16px #00000020; 25 | padding: 20px; 26 | border-radius: 6px; 27 | display: flex; 28 | flex-direction: column; 29 | justify-content: flex-end; 30 | cursor: pointer; 31 | text-align: center; 32 | 33 | &:hover { 34 | box-shadow: 0 10px 20px 3px #00000020; 35 | transform: translateY(-5px); 36 | } 37 | 38 | @media screen and (max-width: 560px) { 39 | width: 27%; 40 | height: 80px; 41 | padding: 5px; 42 | } 43 | ` 44 | 45 | const SelectedCategory = styled.div` 46 | position: absolute; 47 | top: 10px; 48 | right: 10px; 49 | 50 | @media screen and (max-width: 560px) { 51 | display: none; 52 | } 53 | ` 54 | 55 | const CategoryEmoji = styled.p` 56 | font-size: 3rem; 57 | line-height: 3rem; 58 | margin-bottom: 2px; 59 | display: inline-block; 60 | 61 | @media screen and (max-width: 560px) { 62 | display: none; 63 | } 64 | ` 65 | 66 | const CategoryName = styled.p` 67 | font-weight: 300; 68 | font-size: 2rem; 69 | letter-spacing: .1rem; 70 | word-break: keep-all; 71 | height: 3rem; 72 | 73 | @media screen and (max-width: 560px) { 74 | font-size: 1.5rem; 75 | height: 100%; 76 | display: flex; 77 | justify-content: center; 78 | align-items: center; 79 | } 80 | ` 81 | 82 | const Categories: React.SFC = ({ selectedCategory, onSelect }) => ( 83 | 84 | onSelect(null)}> 85 | {!selectedCategory && 86 | 87 | } 88 | 🎮🍱🏖🏄 89 | 전체 90 | 91 | {categories.map((category: Category) => ( 92 | onSelect(category)}> 95 | {selectedCategory && 96 | selectedCategory.id === category.id && 97 | 98 | } 99 | {category.emoji} 100 | {category.name} 101 | 102 | ))} 103 | 104 | ) 105 | 106 | export default Categories 107 | -------------------------------------------------------------------------------- /src/pages/PartyFormPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { inject, observer } from 'mobx-react' 3 | import { RouteComponentProps } from 'react-router-dom' 4 | 5 | import { Overlay, CenterText } from '../CommonStyledComponents' 6 | import PartyForm from '../PartyForm' 7 | 8 | import { asyncSetState } from '../utils/misc' 9 | import { findById, saveParty } from '../utils/party' 10 | 11 | type Props = & RouteComponentProps & { 12 | userStore: IUserStore, 13 | destinationsStore: IDestinationsStore, 14 | partyStore: IPartyStore, 15 | onClose: () => void, 16 | } 17 | 18 | type State = { 19 | isNowFetching: boolean, 20 | party: Party | null, 21 | } 22 | 23 | @inject((allStores: IAllStore) => ({ 24 | userStore: allStores.userStore as IUserStore, 25 | partyStore: allStores.partyStore as IPartyStore, 26 | destinationsStore: allStores.destinationsStore as IDestinationsStore, 27 | })) 28 | @observer 29 | class PartyFormPage extends React.Component { 30 | constructor(props: Props) { 31 | super(props) 32 | 33 | const { match } = props 34 | const { partyId } = match.params 35 | 36 | this.state = { 37 | isNowFetching: partyId ? true : false, 38 | party: null, 39 | } 40 | } 41 | 42 | async componentDidMount() { 43 | const { match } = this.props 44 | const { partyId } = match.params 45 | 46 | if (partyId) { 47 | const party = await findById(partyId) 48 | 49 | if (party) { 50 | await asyncSetState(() => this.setState({ 51 | party, 52 | isNowFetching: false, 53 | })) 54 | } 55 | } 56 | } 57 | 58 | handleSaveParty = async (partyFormData: PartyFormData) => { 59 | const { user } = this.props.userStore! 60 | const { categories } = this.props.partyStore 61 | 62 | if (!user || !categories) return 63 | 64 | partyFormData.joinners = [ 65 | user.email, 66 | ] 67 | 68 | await saveParty(partyFormData, user) 69 | 70 | } 71 | 72 | handleClose = () => { 73 | this.props.history.push('/') 74 | } 75 | 76 | render() { 77 | const { isNowFetching, party } = this.state 78 | const { destinationsStore, partyStore } = this.props 79 | const { destinations } = destinationsStore 80 | const { categories } = partyStore 81 | 82 | if (isNowFetching || !categories) { 83 | return ( 84 | 85 | 86 |

파티 내용을
불러오는 중입니다..

87 |
88 |
89 | ) 90 | } 91 | 92 | return ( 93 | 100 | ) 101 | } 102 | } 103 | 104 | export default PartyFormPage 105 | -------------------------------------------------------------------------------- /src/utils/user.ts: -------------------------------------------------------------------------------- 1 | import * as Bluebird from 'bluebird' 2 | import firebase from './firebase' 3 | import firestore from './firestore' 4 | 5 | export const addUser = async (user: User) => { 6 | const addedUser = await firestore.collection('users').add(user) 7 | return addedUser 8 | } 9 | 10 | export const addUserIfNotExist = async (user: User) => { 11 | const alreadyAddedUser = await findByEmail(user.email) 12 | 13 | if (!alreadyAddedUser) { 14 | return addUser(user) 15 | } 16 | 17 | return null 18 | } 19 | 20 | export const findOneById = async (id: string): Promise => { 21 | const querySnapshot: any = await firestore.collection('users').doc(id).get() 22 | 23 | if (querySnapshot) { 24 | return querySnapshot.data() 25 | } 26 | 27 | return null 28 | } 29 | 30 | export const findByIds = async (ids: string[]): Promise => { 31 | if (!ids || ids.length === 0) { 32 | return new Promise((resolve) => { 33 | resolve([]) 34 | }) 35 | } 36 | return Bluebird.map(ids, async (id: string) => await findOneById(id)) 37 | } 38 | 39 | export const findByEmails = async (emails: string[]) => { 40 | return Bluebird.map(emails, async (email: string) => await findByEmail(email)) 41 | } 42 | 43 | export const findByEmail = async (email: string): Promise => { 44 | const querySnapshot = await firestore.collection('users').where('email', '==', email).get() 45 | 46 | if (querySnapshot && querySnapshot.size > 0) { 47 | return querySnapshot.docs[0].data() 48 | } 49 | 50 | return null 51 | } 52 | 53 | export const signIn = () => { 54 | const { auth } = firebase 55 | const { Auth, GoogleAuthProvider } = auth 56 | const provider = new GoogleAuthProvider() 57 | 58 | auth().setPersistence(Auth.Persistence.LOCAL) 59 | auth().signInWithRedirect(provider) 60 | } 61 | 62 | export const loadCurrentUser = async (): Promise => { 63 | try { 64 | const result = await firebase.auth().getRedirectResult() 65 | 66 | let user: User 67 | if (result && result.credential) { 68 | user = result.user 69 | } else { 70 | user = firebase.auth().currentUser 71 | } 72 | 73 | if (user) { 74 | const { displayName, email, photoURL } = user 75 | 76 | await addUserIfNotExist({ 77 | email, 78 | displayName, 79 | photoURL, 80 | }) 81 | 82 | return { 83 | displayName, 84 | email, 85 | photoURL, 86 | } 87 | } 88 | } catch (e) { 89 | console.log(e) 90 | } 91 | 92 | return null 93 | } 94 | 95 | export const handleSignOut = async () => { 96 | await firebase.auth().signOut() 97 | } 98 | 99 | export default { 100 | addUser, 101 | addUserIfNotExist, 102 | findOneById, 103 | findByIds, 104 | findByEmail, 105 | signIn, 106 | loadCurrentUser, 107 | handleSignOut, 108 | } 109 | -------------------------------------------------------------------------------- /src/pages/PartyListPage.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { inject, observer } from 'mobx-react' 3 | import { Link } from 'react-router-dom' 4 | 5 | import Categories from '../Categories' 6 | import PartyList from '../PartyList' 7 | 8 | import { unsubscribeTodayParties } from '../utils/party' 9 | 10 | interface Props { 11 | partyStore?: IPartyStore 12 | userStore?: IUserStore 13 | } 14 | 15 | interface State { 16 | selectedCategory: Category | null 17 | } 18 | 19 | @inject((allStores: IAllStore) => ({ 20 | userStore: allStores.userStore as IUserStore, 21 | partyStore: allStores.partyStore as IPartyStore, 22 | })) 23 | @observer 24 | class PartyListPage extends React.Component { 25 | state = { 26 | selectedCategory: null, 27 | } 28 | 29 | componentDidMount() { 30 | this.initializeParties() 31 | } 32 | 33 | componentWillUnmount() { 34 | unsubscribeTodayParties() 35 | } 36 | 37 | async initializeParties() { 38 | const { initializeParties } = this.props.partyStore! 39 | 40 | initializeParties() 41 | } 42 | 43 | filteredParties = (selectedCategory: Category, parties: Party[]): Party[] => ( 44 | parties.filter((party: Party) => ( 45 | selectedCategory.id === party.category.id 46 | )) 47 | ) 48 | 49 | handleCategorySelect = (selectedCategory: Category) => { 50 | this.setState({ 51 | selectedCategory: this.state.selectedCategory !== selectedCategory ? selectedCategory : null, 52 | }) 53 | } 54 | render() { 55 | const { selectedCategory } = this.state 56 | const { user } = this.props.userStore! 57 | const { initializedParty, parties } = this.props.partyStore! 58 | 59 | return ( 60 |
61 | { 62 | !initializedParty && ( 63 |

파티를 불러오는 중

64 | ) 65 | } 66 | { 67 | initializedParty && parties && ( 68 | 69 |

70 | 어떤파티를 찾나요? 🎉 71 | 73 |

74 |

75 | 다가오는 파티 👀 76 |

77 |
78 | ) 79 | } 80 | 81 | {initializedParty && parties && ( 82 | 83 | 87 | 88 | 89 | 90 | 91 | )} 92 |
93 | ) 94 | } 95 | } 96 | 97 | export default PartyListPage 98 | -------------------------------------------------------------------------------- /src/GifSearch.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | 4 | import { search } from './utils/giphy' 5 | 6 | interface Props { 7 | onClose: () => void 8 | onSelect: (gif: Giphy) => void 9 | } 10 | 11 | interface State { 12 | keyword: string 13 | gifs: Giphy[] 14 | } 15 | 16 | const GifSearchWrapper = styled.div` 17 | position: fixed; 18 | background-color: #fff; 19 | top: 50%; 20 | left: 50%; 21 | transform: translate(-50%, -50%); 22 | overflow-y: scroll; 23 | width: 500px; 24 | height: 400px; 25 | border-radius: 6px; 26 | padding: 10px; 27 | box-shadow: 0px 4px 16px 2px #6666663b; 28 | ` 29 | const GifSearchHeader = styled.div` 30 | display: flex; 31 | flex-direction: row; 32 | justify-content: space-between; 33 | margin-bottom: 5px; 34 | ` 35 | 36 | const KeywordWrapper = styled.div` 37 | font-size: 2rem; 38 | ` 39 | const SearchList = styled.ul` 40 | 41 | ` 42 | 43 | const SeachListItem = styled.li` 44 | display: inline-block; 45 | margin: 3px; 46 | 47 | cursor: pointer; 48 | &:hover { 49 | background-color: rgba(0, 0, 0, 0.7); 50 | } 51 | ` 52 | 53 | const CloseButton = styled.button` 54 | position: absolute; 55 | right: 10px; 56 | top: 10px; 57 | ` 58 | 59 | export default class GifSearch extends React.Component { 60 | $keyword: HTMLInputElement | null = null 61 | 62 | state = { 63 | keyword: '', 64 | gifs: [] 65 | } 66 | 67 | async fetchGif() { 68 | const { keyword } = this.state 69 | 70 | if (keyword.length > 2) { 71 | const result = await search(keyword) 72 | this.setState({ 73 | gifs: result.data 74 | }) 75 | } 76 | } 77 | 78 | componentDidMount() { 79 | if (this.$keyword) { 80 | this.$keyword.focus() 81 | this.$keyword = null 82 | } 83 | } 84 | 85 | handleSearch = async (e: React.FormEvent) => { 86 | e.preventDefault() 87 | await this.fetchGif() 88 | } 89 | 90 | handleSelect = (gif: Giphy) => { 91 | this.props.onSelect(gif) 92 | } 93 | 94 | render() { 95 | const { keyword, gifs } = this.state 96 | const { onClose } = this.props 97 | 98 | return ( 99 | 100 | 101 |

GIF 검색

102 | x 105 |
106 |
107 | 108 | this.$keyword = $input} 110 | className="form-control" 111 | type="text" 112 | placeholder="어떤 키워드로 gif를 검색할까요?" 113 | value={keyword} 114 | onChange={(e: React.FormEvent) => 115 | this.setState({ 116 | keyword: e.currentTarget.value, 117 | })} 118 | /> 119 | 120 | 121 | {gifs.map((gif: Giphy) => ( 122 | this.handleSelect(gif)}> 123 | 127 | 128 | ))} 129 | 130 |
131 |
132 | ) 133 | } 134 | } -------------------------------------------------------------------------------- /src/Footer.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | 3 | import styled from './styled-components' 4 | 5 | const FooterContainer = styled.footer` 6 | overflow: hidden; 7 | width: 100%; 8 | height: 100%; 9 | position: relative; 10 | ` 11 | const Background = styled.div` 12 | margin-top: 22rem; 13 | background: linear-gradient(60deg, #ff8d00, #a5007d, #26186f); 14 | transform: skewY(-11deg); 15 | transform-origin: 0; 16 | position: absolute; 17 | top: 0; 18 | bottom: 0; 19 | right: 0; 20 | left: 0; 21 | width: 100%; 22 | height: 100%; 23 | 24 | @media (min-width: 1020px) { 25 | margin-top: 28rem; 26 | transform: skewY(-6deg); 27 | } 28 | ` 29 | 30 | const FooterContent = styled.div` 31 | flex-wrap: wrap; 32 | display: flex; 33 | font-size: 1.3rem; 34 | color: #ffffffa6; 35 | justify-content: center; 36 | margin-top: 25rem; 37 | 38 | h2 { 39 | font-size: 1.6rem; 40 | font-weight: 300; 41 | letter-spacing: 0.1rem; 42 | } 43 | 44 | h3 { 45 | margin: 2rem 0 1rem 0; 46 | letter-spacing: 0.1rem; 47 | font-size: 1.3rem; 48 | text-transform: uppercase; 49 | } 50 | 51 | .group { 52 | p { 53 | line-height: 2; 54 | letter-spacing: 0.05rem; 55 | word-break: keep-all; 56 | } 57 | 58 | a { 59 | text-decoration: none !important; 60 | display: block; 61 | color: inherit; 62 | letter-spacing: 0.08rem; 63 | } 64 | 65 | a:link { 66 | text-decoration: none !important; 67 | } 68 | } 69 | 70 | @media (min-width: 1020px) { 71 | margin-top: 32rem; 72 | } 73 | ` 74 | 75 | const SecretMSG = styled.div` 76 | text-align: right; 77 | font-weight: 100; 78 | font-size: 1rem; 79 | margin: 2rem 0; 80 | opacity: 0.3; 81 | ` 82 | 83 | const Footer = () => ( 84 | 85 | 86 |
87 | 88 |

ANGOMI:안 고독한 미식가

89 |
90 |

links

91 | 94 | github 95 | 96 |
97 |
98 |

contributors

99 | Alma🏄 102 | 103 | Lucas❤️ 106 | 107 | Ohda🌞 110 | 111 | Roto🐱 114 | 115 |
116 |
117 |

about

118 |

안고미는 사내 그룹이나 동호회 등에서 소규모 파티 모임을 119 | 활성화해, 늘 어울리는 사람이 아닌 다양한 사람들과 함께 맛있는 120 | 것을 먹자는 취지에서 시작된 사이드 프로젝트입니다. 안고미는 121 | 오픈소스이며 여러분의 다양한 기여를 언제나 환영합니다. 버그를 발견하거나, 개선사항이 있으시면 깃허브에 언제든 이슈 122 | 남겨주세요.

123 |
124 | 125 | for our lonely friend, kingkong / 이 프로젝트를 우리의 고독한 킹콩에게 바칩니다. 126 | 127 |
128 |
129 |
130 | ) 131 | 132 | export default Footer 133 | -------------------------------------------------------------------------------- /functions/src/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import express = require('express') 3 | import * as functions from 'firebase-functions' 4 | import { DocumentSnapshot } from 'firebase-functions/lib/providers/firestore' 5 | import * as admin from 'firebase-admin' 6 | import * as cors from 'cors' 7 | import * as hangul from 'hangul-js' 8 | 9 | admin.initializeApp(); 10 | 11 | const app = express() 12 | app.use(cors({ origin: true })); 13 | 14 | const createDescription = (party: Party) => { 15 | const { category, destinationName, playName } = party 16 | 17 | if (category.isTravel) { 18 | return `${destinationName}${hangul.endsWithConsonant(destinationName) ? '로' : '으로'} 떠나는 여행에 참여하세요!` 19 | } else if (category.isRestaurant && category.isPlaying) { 20 | return `${destinationName} 먹고 ${playName} 할 사람 모여요!` 21 | } else if (!category.isRestaurant && category.isPlaying) { 22 | return `${playName} 하고 놀 사람 모여요!` 23 | } 24 | 25 | return `${destinationName}에서 벌어지는 파티에 참여하세요!` 26 | } 27 | 28 | // // Start writing Firebase Functions 29 | // // https://firebase.google.com/docs/functions/typescript 30 | // 31 | // export const helloWorld = functions.https.onRequest((request, response) => { 32 | // response.send("Hello from Firebase!"); 33 | // }); 34 | app.get('/parties/:partyId', async (req, res): Promise => { 35 | const { partyId } = req.params 36 | 37 | fs.readFile('./index.html', 'utf8', (err, htmlString) => { 38 | if (err) { 39 | console.error(err.message) 40 | res.redirect('/') 41 | } else { 42 | admin 43 | .firestore() 44 | .collection('parties') 45 | .doc(partyId) 46 | .get() 47 | .then((query: DocumentSnapshot) => { 48 | if (query.exists) { 49 | const party = query.data() 50 | res.set('Content-Type', 'text/html') 51 | 52 | const title = `안 고독한 미식가 - ${party.title}` 53 | const description = createDescription(party) 54 | 55 | const url = `https://${functions.config().angomi.domain}/${partyId}` 56 | 57 | let replacedHTML = htmlString.replace( 58 | /(.*?)<\/title>/, 59 | `<title>${title}` 60 | ) 61 | replacedHTML = replacedHTML.replace( 62 | //, 63 | `` 64 | ) 65 | replacedHTML = replacedHTML.replace( 66 | //, 67 | `` 68 | ) 69 | replacedHTML = replacedHTML.replace( 70 | //, 71 | `` 72 | ) 73 | replacedHTML = replacedHTML.replace( 74 | //, 75 | `` 76 | ) 77 | replacedHTML = replacedHTML.replace( 78 | //, 79 | `` 80 | ) 81 | replacedHTML = replacedHTML.replace( 82 | //, 83 | `` 84 | ) 85 | 86 | console.log(`${title} visited.`) 87 | res.send(new Buffer(replacedHTML)) 88 | } else { 89 | res.redirect('/') 90 | } 91 | }) 92 | .catch((e: Error) => { 93 | console.error(e.message) 94 | res.redirect('/') 95 | }) 96 | } 97 | }) 98 | }) 99 | 100 | export const partyDetail = functions.https.onRequest(app) 101 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log('Content is cached for offline use.'); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error('Error during service worker registration:', error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get('content-type').indexOf('javascript') === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | 'No internet connection found. App is running in offline mode.' 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ('serviceWorker' in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/PartyList.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Link } from 'react-router-dom' 3 | import * as moment from 'moment' 4 | 5 | import DueCountDown from './DueCountDown' 6 | import styled from './styled-components' 7 | import PartyTags from './PartyTags' 8 | 9 | import { PartyTitle, PartyItemInfoText } from './PartyStyledComponents' 10 | 11 | type Props = { 12 | history?: { 13 | push: Function, 14 | }, 15 | parties: Party[], 16 | user: User | null, 17 | } 18 | 19 | const PartyItem = styled.div` 20 | margin: 3rem 0; 21 | border-radius: 6px; 22 | min-height: 200px; 23 | box-shadow: 0px 4px 16px 2px #6666663b; 24 | cursor: pointer; 25 | transition: all 0.2s ease; 26 | &:hover { 27 | transform: scale(0.98); 28 | box-shadow: 0px 4px 16px 2px #6666663b; 29 | } 30 | ` 31 | 32 | const PartyItemInfo = styled.div` 33 | margin-top: 2rem; 34 | display: flex; 35 | flex-wrap: wrap; 36 | justify-content: space-between; 37 | ` 38 | 39 | const PartyItemContents = styled.div` 40 | display: flex; 41 | flex-direction: column; 42 | justify-content: space-between; 43 | padding: 2.6rem; 44 | flex: 1 1 auto; 45 | ` 46 | 47 | const PartyJoinners = styled.div` 48 | margin: 0.6rem 0; 49 | display: flex; 50 | justify-content: space-between; 51 | align-items: center; 52 | width: 100%; 53 | ` 54 | 55 | const PartyJoinnerPhoto = styled.div` 56 | display: flex; 57 | img { 58 | width: 40px; 59 | height: 40px; 60 | border-radius: 20px; 61 | border: 1px solid #ccc; 62 | margin-right: -1.5rem; 63 | } 64 | ` 65 | 66 | const PartyJoinnerGroup = styled.span` 67 | width: 100%; 68 | height: 100%; 69 | ` 70 | 71 | export default class PartyList extends React.Component { 72 | handleClose = (e: EventTarget) => { 73 | this.props.history && 74 | this.props.history.push('/') 75 | } 76 | 77 | alreadyJoin(party: Party) { 78 | const { user } = this.props 79 | 80 | if (user) { 81 | return party.fetchedJoinners && 82 | party.fetchedJoinners.find(joinner => joinner.email === user.email) 83 | } 84 | return false 85 | } 86 | 87 | alreadyDeadline = (party: Party) => { 88 | return party.dueDateTime && party.dueDateTime.toDate() < new Date() 89 | } 90 | 91 | renderMemberLimit(party: Party) { 92 | const { maxPartyMember, fetchedJoinners } = party 93 | 94 | if (!fetchedJoinners) { 95 | return null 96 | } 97 | 98 | if (maxPartyMember === 0) { 99 | return 무제한 멤버 100 | } 101 | 102 | const joinnedMemberCount = fetchedJoinners.length 103 | 104 | if (maxPartyMember > joinnedMemberCount) { 105 | return ( 106 | 총 {party.maxPartyMember}명 107 | ) 108 | } 109 | 110 | return 인원마감 111 | } 112 | 113 | render() { 114 | const { parties } = this.props 115 | 116 | return ( 117 |
118 | { 119 | parties && 120 | parties.length === 0 && ( 121 |

저런! 아무런 파티가 없군요. 파티를 직접 만들어보시는 건 어떨까요?

122 | ) 123 | } 124 | {parties.map(party => ( 125 | 126 | 129 | 130 | 131 |
132 | {party.title} 133 | 134 |
135 | {party.destinationName} 136 | {party.category.isPlaying && 137 | {party.playName} 138 | } 139 | 140 | {moment(party.partyTime.toDate()).format('YYYY.MM.DD HH:mm')} 141 | { 142 | party.category.hasDueDateTime && party.dueDateTime && 143 | 147 | } 148 | 149 |
150 | 151 | 152 | {party.fetchedJoinners && party.fetchedJoinners.map((joinner, i) => ( 153 | 154 | {joinner.displayName} 155 | {joinner.displayName} 156 | 157 | ))} 158 | 159 | 160 |
161 |
162 |
163 |
164 | 165 | ))} 166 |
167 | ) 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | 2 | .App__constraint { 3 | width: 100vw; 4 | height: 100vh; 5 | overflow: hidden; 6 | position: relative; 7 | } 8 | 9 | .App__intro { 10 | height: 130vh; 11 | overflow: hidden; 12 | padding: 160px 0; 13 | margin-bottom: -10rem; 14 | background: linear-gradient(90deg, #ff8d00, #a5007d, #26186f); 15 | transform: skewY(-8deg); 16 | transform-origin: 0; 17 | transition: all 0.2s ease; 18 | } 19 | 20 | .App__intro-member { 21 | height: 760px; 22 | transform: skewY(-8deg) translateY(-160px); 23 | } 24 | 25 | small { 26 | color: white; 27 | font-size: 1.6rem !important; 28 | position: relative; 29 | margin-left: 20rem; 30 | } 31 | 32 | small::after { 33 | content: ''; 34 | width: 100vw; 35 | height: 30px; 36 | background: rgba(255, 193, 7, 0.55); 37 | position: absolute; 38 | top: -5px; 39 | left: -20px; 40 | z-index: -1; 41 | border-radius: 16px; 42 | } 43 | 44 | .App__header { 45 | display: flex; 46 | justify-content: flex-end; 47 | position: fixed; 48 | top: 2rem; 49 | z-index: 1; 50 | width: 100%; 51 | padding: 0 4rem; 52 | } 53 | 54 | .App__button { 55 | border-radius: 6px; 56 | border: 0 !important; 57 | box-shadow: 0px 4px 20px #00000021; 58 | width: 160px; 59 | height: 42px; 60 | font-size: 1.6rem; 61 | cursor: pointer; 62 | transition: all 0.2s ease; 63 | margin: 0 1rem; 64 | background: white; 65 | } 66 | 67 | .App__button:hover { 68 | transform: scale(0.9); 69 | box-shadow: 0px 2px 20px #00000050; 70 | } 71 | 72 | .App__button-user:hover { 73 | transform: scale(0.9); 74 | box-shadow: 0px 2px 20px #00000050; 75 | } 76 | 77 | .App__button.make { 78 | position: fixed; 79 | left: 2rem; 80 | top: 2rem; 81 | z-index: 2; 82 | } 83 | 84 | .App__button-user { 85 | border: 1px solid white; 86 | border-radius: 50%; 87 | width: 42px; 88 | height: 42px; 89 | overflow: hidden; 90 | box-shadow: 0px 4px 10px #0a0a0a61; 91 | transition: all 0.2s ease; 92 | cursor: pointer; 93 | } 94 | 95 | .App__button-user img { 96 | width: 42px; 97 | height: 42px; 98 | } 99 | 100 | .App__container { 101 | margin-top: 30rem; 102 | padding-left: 6rem !important; 103 | } 104 | 105 | .App__container-header { 106 | font-size: 4.6rem; 107 | color: white; 108 | margin: 1.5rem 0; 109 | position: relative; 110 | } 111 | 112 | .App__container-header::before { 113 | content: ''; 114 | width: 100vw; 115 | position: absolute; 116 | height: 65px; 117 | background: #e22b7c78; 118 | left: -30px; 119 | border-radius: 50px; 120 | z-index: -1; 121 | } 122 | 123 | .App__container-header:nth-child(2)::before { 124 | background: #6b2be278; 125 | } 126 | 127 | .App-nav { 128 | width: 100%; 129 | padding: 20px 0; 130 | margin: 0; 131 | position: fixed; 132 | top: 0; 133 | left: 0; 134 | } 135 | 136 | .App__contents-title { 137 | font-size: 1.2rem; 138 | font-weight: 400; 139 | margin: 20px 0; 140 | } 141 | 142 | .App__categories { 143 | display: flex; 144 | flex-wrap: wrap; 145 | justify-content: flex-start; 146 | padding: 0; 147 | margin: 3rem 0; 148 | } 149 | 150 | .App__category { 151 | width: 200px; 152 | height: 200px; 153 | margin: 0 14px 14px 0; 154 | box-shadow: 0px 4px 16px #00000020; 155 | padding: 20px; 156 | border-radius: 6px; 157 | display: flex; 158 | flex-direction: column; 159 | justify-content: flex-end; 160 | cursor: pointer; 161 | } 162 | 163 | .App__category:hover { 164 | box-shadow: 0px 10px 20px 3px #00000020; 165 | transition: all 0.2s ease; 166 | transform: translateY(-3px); 167 | } 168 | 169 | .App__text { 170 | color: white; 171 | font-size: 1.6rem; 172 | margin: 2rem 0; 173 | } 174 | 175 | .App__text-black { 176 | color: black; 177 | font-size: 2.4rem; 178 | } 179 | 180 | .category-emoji { 181 | font-size: 3rem; 182 | line-height: 3rem; 183 | margin-bottom: 2px; 184 | } 185 | 186 | .category-title { 187 | color: white; 188 | font-weight: 300; 189 | font-size: 2rem; 190 | letter-spacing: 0.1rem; 191 | } 192 | 193 | em { 194 | font-style: normal; 195 | position: relative; 196 | color: black; 197 | } 198 | 199 | @media screen and (max-width: 560px) { 200 | .App__container-header { 201 | font-size: 3rem; 202 | } 203 | 204 | .App__header { 205 | padding: 0 2rem; 206 | } 207 | 208 | .App__button { 209 | width: 86px; 210 | margin: 0 0.4rem; 211 | font-size: 1.2rem; 212 | } 213 | 214 | .App__category { 215 | width: 45%; 216 | height: 150px; 217 | } 218 | 219 | small { 220 | margin-left: 0; 221 | } 222 | 223 | .App__container-header::before { 224 | height: 4rem; 225 | } 226 | 227 | .App__text { 228 | font-size: 1.4rem; 229 | } 230 | } 231 | 232 | .tooltip-joinner { 233 | position: relative; 234 | } 235 | 236 | .tooltiptext-joinner { 237 | visibility: hidden; 238 | background-color: rgb(255, 193, 7); 239 | color: #131313; 240 | text-align: center; 241 | min-width: 90px; 242 | font-size: 1.2rem; 243 | font-weight: 400; 244 | border-radius: 2px; 245 | padding: 4px 10px; 246 | position: absolute; 247 | z-index: 1; 248 | top: 110%; 249 | margin-left: -125%; 250 | } 251 | 252 | .tooltip-joinner:hover .tooltiptext-joinner { 253 | visibility: visible; 254 | } 255 | 256 | a.Link { 257 | color: inherit !important; 258 | } 259 | 260 | a.Link:hover { 261 | text-decoration: none; 262 | } 263 | -------------------------------------------------------------------------------- /functions/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | // -- Strict errors -- 4 | // These lint rules are likely always a good idea. 5 | 6 | // Force function overloads to be declared together. This ensures readers understand APIs. 7 | "adjacent-overload-signatures": true, 8 | 9 | // Do not allow the subtle/obscure comma operator. 10 | "ban-comma-operator": true, 11 | 12 | // Do not allow internal modules or namespaces . These are deprecated in favor of ES6 modules. 13 | "no-namespace": true, 14 | 15 | // Do not allow parameters to be reassigned. To avoid bugs, developers should instead assign new values to new vars. 16 | "no-parameter-reassignment": true, 17 | 18 | // Force the use of ES6-style imports instead of /// imports. 19 | "no-reference": true, 20 | 21 | // Do not allow type assertions that do nothing. This is a big warning that the developer may not understand the 22 | // code currently being edited (they may be incorrectly handling a different type case that does not exist). 23 | "no-unnecessary-type-assertion": true, 24 | 25 | // Disallow nonsensical label usage. 26 | "label-position": true, 27 | 28 | // Disallows the (often typo) syntax if (var1 = var2). Replace with if (var2) { var1 = var2 }. 29 | "no-conditional-assignment": true, 30 | 31 | // Disallows constructors for primitive types (e.g. new Number('123'), though Number('123') is still allowed). 32 | "no-construct": true, 33 | 34 | // Do not allow super() to be called twice in a constructor. 35 | "no-duplicate-super": true, 36 | 37 | // Do not allow the same case to appear more than once in a switch block. 38 | "no-duplicate-switch-case": true, 39 | 40 | // Do not allow a variable to be declared more than once in the same block. Consider function parameters in this 41 | // rule. 42 | "no-duplicate-variable": [true, "check-parameters"], 43 | 44 | // Disallows a variable definition in an inner scope from shadowing a variable in an outer scope. Developers should 45 | // instead use a separate variable name. 46 | "no-shadowed-variable": true, 47 | 48 | // Empty blocks are almost never needed. Allow the one general exception: empty catch blocks. 49 | "no-empty": [true, "allow-empty-catch"], 50 | 51 | // Functions must either be handled directly (e.g. with a catch() handler) or returned to another function. 52 | // This is a major source of errors in Cloud Functions and the team strongly recommends leaving this rule on. 53 | "no-floating-promises": true, 54 | 55 | // Do not allow any imports for modules that are not in package.json. These will almost certainly fail when 56 | // deployed. 57 | "no-implicit-dependencies": true, 58 | 59 | // The 'this' keyword can only be used inside of classes. 60 | "no-invalid-this": true, 61 | 62 | // Do not allow strings to be thrown because they will not include stack traces. Throw Errors instead. 63 | "no-string-throw": true, 64 | 65 | // Disallow control flow statements, such as return, continue, break, and throw in finally blocks. 66 | "no-unsafe-finally": true, 67 | 68 | // Do not allow variables to be used before they are declared. 69 | "no-use-before-declare": true, 70 | 71 | // Expressions must always return a value. Avoids common errors like const myValue = functionReturningVoid(); 72 | "no-void-expression": [true, "ignore-arrow-function-shorthand"], 73 | 74 | // Disallow duplicate imports in the same file. 75 | "no-duplicate-imports": true, 76 | 77 | 78 | // -- Strong Warnings -- 79 | // These rules should almost never be needed, but may be included due to legacy code. 80 | // They are left as a warning to avoid frustration with blocked deploys when the developer 81 | // understand the warning and wants to deploy anyway. 82 | 83 | // Warn when an empty interface is defined. These are generally not useful. 84 | "no-empty-interface": {"severity": "warning"}, 85 | 86 | // Warn when an import will have side effects. 87 | "no-import-side-effect": {"severity": "warning"}, 88 | 89 | // Warn when variables are defined with var. Var has subtle meaning that can lead to bugs. Strongly prefer const for 90 | // most values and let for values that will change. 91 | "no-var-keyword": {"severity": "warning"}, 92 | 93 | // Prefer === and !== over == and !=. The latter operators support overloads that are often accidental. 94 | "triple-equals": {"severity": "warning"}, 95 | 96 | // Warn when using deprecated APIs. 97 | "deprecation": {"severity": "warning"}, 98 | 99 | // -- Light Warnigns -- 100 | // These rules are intended to help developers use better style. Simpler code has fewer bugs. These would be "info" 101 | // if TSLint supported such a level. 102 | 103 | // prefer for( ... of ... ) to an index loop when the index is only used to fetch an object from an array. 104 | // (Even better: check out utils like .map if transforming an array!) 105 | "prefer-for-of": {"severity": "warning"}, 106 | 107 | // Warns if function overloads could be unified into a single function with optional or rest parameters. 108 | "unified-signatures": {"severity": "warning"}, 109 | 110 | // Warns if code has an import or variable that is unused. 111 | "no-unused-variable": {"severity": "warning"}, 112 | 113 | // Prefer const for values that will not change. This better documents code. 114 | "prefer-const": {"severity": "warning"}, 115 | 116 | // Multi-line object liiterals and function calls should have a trailing comma. This helps avoid merge conflicts. 117 | "trailing-comma": {"severity": "warning"} 118 | }, 119 | 120 | "defaultSeverity": "error" 121 | } 122 | -------------------------------------------------------------------------------- /src/utils/party.ts: -------------------------------------------------------------------------------- 1 | import * as Bluebird from 'bluebird' 2 | 3 | import firestore from './firestore' 4 | import { findByEmails } from './user' 5 | import { isValidSlackHook, notifyToSlack } from './slack' 6 | import { saveDestination } from './destination' 7 | 8 | const COLLECTION_NAME = 'parties' 9 | 10 | const sendSlackNotifyMessage = async ( 11 | category: Category, 12 | isNew: boolean, 13 | partyTitle: string, 14 | partyId: string) => { 15 | if (isValidSlackHook()) { 16 | const texts = [ 17 | `\`[${category.name}]\` \`${partyTitle}\` 파티가 ${isNew ? '만들어졌어요!' : '수정되었어요!'}`, 18 | `${window.location.origin}/parties/${partyId}`, 19 | ] 20 | 21 | await notifyToSlack(texts.join('\n')) 22 | } 23 | } 24 | 25 | const querySnapshotToArray = async (querySnapshot: any) => { 26 | const parties = querySnapshot.docs.map((doc: any) => ({ 27 | ...doc.data(), 28 | id: doc.id, 29 | })) 30 | 31 | return await Bluebird.map(parties, async (party: Party) => { 32 | const fetchedJoinners = await findByEmails(party.joinners) 33 | 34 | return { 35 | ...party, 36 | fetchedJoinners, 37 | } 38 | }) 39 | } 40 | 41 | export const findById = async (partyId: string): Promise => { 42 | const querySnapshot = await firestore.collection(COLLECTION_NAME).doc(partyId).get() 43 | return { 44 | ...querySnapshot.data(), 45 | id: querySnapshot.id, 46 | } 47 | } 48 | 49 | export const saveParty = async (partyForm: PartyFormData, user: User) => { 50 | const { 51 | category, 52 | title, 53 | destinationName, 54 | partyTimeDate, 55 | dueDateTimeDate, 56 | description, 57 | playName, 58 | maxPartyMember = 0, 59 | joinners = [], 60 | } = partyForm 61 | 62 | const saveOrUpdatePartyForm = { 63 | description, 64 | maxPartyMember, 65 | category, 66 | title, 67 | destinationName, 68 | playName, 69 | partyTime: new Date(partyTimeDate), 70 | dueDateTime: dueDateTimeDate ? new Date(dueDateTimeDate) : null, 71 | createdBy: user.email, 72 | } 73 | 74 | if (partyForm.id) { 75 | const alreadySavedParty = await firestore.collection(COLLECTION_NAME).doc(partyForm.id).get() 76 | 77 | if (alreadySavedParty && alreadySavedParty.exists) { 78 | await firestore.collection(COLLECTION_NAME).doc(partyForm.id).update({ 79 | ...saveOrUpdatePartyForm, 80 | updatedAt: new Date(), 81 | }) 82 | 83 | // await sendSlackNotifyMessage(category, false, title, partyForm.id) 84 | } 85 | 86 | } else { 87 | const newPartyRef = await firestore.collection(COLLECTION_NAME).add({ 88 | ...saveOrUpdatePartyForm, 89 | joinners, 90 | createdAt: new Date(), 91 | }) 92 | 93 | await sendSlackNotifyMessage(category, true, title, newPartyRef.id) 94 | await saveDestination(destinationName, { 95 | isDeliverable: category.isDeliverable, 96 | isRestaurant: category.isRestaurant, 97 | isPlaying: category.isPlaying, 98 | isTravel: category.isTravel, 99 | hasDueDateTime: category.hasDueDateTime, 100 | }) 101 | 102 | if (category.isPlaying && playName && playName.length > 0) { 103 | await saveDestination(playName, { 104 | isDeliverable: false, 105 | isRestaurant: false, 106 | isTravel: false, 107 | isPlaying: true, 108 | }) 109 | } 110 | } 111 | } 112 | 113 | const createTodayPartiesQuery = () => firestore 114 | .collection('parties') 115 | .where('partyTime', '>', new Date()) 116 | .orderBy('partyTime', 'asc') 117 | 118 | const retrieveTime = (now = new Date().getTime()) => (party: any): number => { 119 | if (party.dueDateTime.seconds * 1000 < now) return -Infinity 120 | return Math.abs(now - party.partyTime.seconds) 121 | } 122 | 123 | export const subscribeTodayParties = (callback: Function) => { 124 | createTodayPartiesQuery() 125 | .onSnapshot(async (querySnapshot: any) => { 126 | const data = await querySnapshotToArray(querySnapshot) 127 | const retrieve = retrieveTime() 128 | const sorted = data.sort((a: any, b: any) => retrieve(b) - retrieve(a)) 129 | callback(sorted) 130 | }) 131 | } 132 | 133 | export const unsubscribeTodayParties = () => { 134 | return firestore.collection('parties').onSnapshot(() => { }) 135 | } 136 | 137 | export const findTodayParties = async () => { 138 | const querySnapshot = await createTodayPartiesQuery() 139 | .get() 140 | 141 | if (querySnapshot) { 142 | return await querySnapshotToArray(querySnapshot) 143 | } 144 | 145 | return [] 146 | } 147 | 148 | export const joinParty = async (partyId: string, email: string) => { 149 | const party = await findById(partyId) 150 | 151 | if (party) { 152 | let updateParty = null 153 | if (!party.joinners || party.joinners.length === 0) { 154 | updateParty = { 155 | joinners: [email], 156 | } 157 | } else if (!party.joinners.includes(email)) { 158 | updateParty = { 159 | joinners: [ 160 | ...party.joinners, 161 | email, 162 | ], 163 | } 164 | } 165 | 166 | if (updateParty) { 167 | await firestore.collection('parties').doc(partyId).update(updateParty) 168 | } 169 | } 170 | } 171 | 172 | export const leaveParty = async (partyId: string, email: string) => { 173 | const party = await findById(partyId) 174 | 175 | if (party) { 176 | const { joinners } = party 177 | 178 | if (joinners && joinners.length > 0 && joinners.includes(email)) { 179 | joinners.splice(joinners.indexOf(email), 1) 180 | 181 | await firestore.collection('parties').doc(partyId).set(party) 182 | } 183 | } 184 | } 185 | 186 | export default { 187 | saveParty, 188 | joinParty, 189 | leaveParty, 190 | findTodayParties, 191 | subscribeTodayParties, 192 | unsubscribeTodayParties, 193 | } 194 | -------------------------------------------------------------------------------- /src/PartyDetail.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import styled from 'styled-components' 3 | import { Link } from 'react-router-dom' 4 | import * as moment from 'moment' 5 | 6 | import DueCountDown from './DueCountDown' 7 | import PartyComments from './PartyComments' 8 | import PartyJoinButton from './PartyJoinButton' 9 | import PartyTags from './PartyTags' 10 | 11 | import { 12 | PartyTitle, 13 | PartyJoinners, 14 | PartyJoinnerPhoto, 15 | PartyJoinnerGroup, 16 | PartyItemInfoText, 17 | } from './PartyStyledComponents' 18 | 19 | type Props = { 20 | party: Party, 21 | user: User | null, 22 | onJoinParty: (partyId: string, email:string) => void, 23 | onLeaveParty: (partyId: string, email:string) => void, 24 | } 25 | 26 | const PartyDetailGroup = styled.div` 27 | width: 600px; 28 | background: white; 29 | padding: 4rem 3rem; 30 | position: absolute; 31 | top: 10rem; 32 | border-radius: 6px; 33 | margin-bottom: 4rem; 34 | @media screen and (max-width: 600px) { 35 | border-radius: 0; 36 | width: 100%; 37 | top: 0; 38 | margin-bottom: 0; 39 | } 40 | ` 41 | const PartyDetailHeader = styled.div` 42 | display: flex; 43 | flex-direction: row; 44 | justify-content: space-between; 45 | ` 46 | 47 | const PartyDetailContents = styled.div` 48 | margin-top: 2rem; 49 | display: flex; 50 | flex-wrap: wrap; 51 | justify-content: space-between; 52 | flex-direction: column; 53 | ` 54 | 55 | const Block = styled.div` 56 | margin: 1rem 0; 57 | ` 58 | 59 | const HeaderButton = styled.button` 60 | right: 1rem; 61 | font-size: 1.4rem !important; 62 | ` 63 | 64 | const PartyJoinButtonWrapper = styled.div` 65 | margin: 20px 0; 66 | ` 67 | 68 | export default class PartyDetail extends React.Component { 69 | renderMemberLimit(party: Party) { 70 | const { maxPartyMember, fetchedJoinners } = party 71 | 72 | if (!fetchedJoinners) { 73 | return false 74 | } 75 | 76 | if (maxPartyMember === 0) { 77 | return 무제한 멤버 78 | } 79 | 80 | const joinnedMemberCount = fetchedJoinners.length 81 | 82 | if (maxPartyMember > joinnedMemberCount) { 83 | return ( 84 | 총 {party.maxPartyMember}명 85 | ) 86 | } 87 | 88 | return 인원마감 89 | } 90 | 91 | render() { 92 | const { 93 | party, 94 | user, 95 | onJoinParty, 96 | onLeaveParty, 97 | } = this.props 98 | 99 | const isCreator = (user && user.email === party.createdBy) 100 | 101 | if (party.id) { 102 | return ( 103 | evt.stopPropagation()}> 104 | 105 | 106 |
107 | {isCreator && ( 108 | 109 | 수정 112 | 113 | )} 114 |
115 |
116 |
117 | {party.title} 118 | 119 | { 123 | if (!user) { 124 | alert('로그인 후 참여할 수 있습니다.') 125 | } else { 126 | onJoinParty(partyId, email) 127 | } 128 | }} 129 | onLeaveParty={onLeaveParty} /> 130 | 131 | { 132 | party.category.hasDueDateTime && party.dueDateTime && 133 | 134 | } 135 | 136 | 137 |
파티 상세 정보
138 | { 139 | party.category.isRestaurant && 140 | 141 | 식당 | 142 | {party.destinationName} 143 | 144 | } 145 | { 146 | party.category.isTravel && 147 | 148 | 여행지 | 149 | {party.destinationName} 150 | 151 | } 152 | { 153 | party.category.isPlaying && 154 | 155 | 하고 놀 것 | 156 | {party.playName} 157 | 158 | } 159 | 160 | 일시 | 161 | {moment(party.partyTime.toDate()).format('YYYY.MM.DD ddd HH:mm')} 162 | 163 | 164 | { 165 | party.maxPartyMember === 0 && 166 | 167 | 파티원 수 제한이 없는 파티입니다. 168 | 169 | } 170 | { 171 | party.maxPartyMember > 0 && 172 | ( 173 | 174 | {party.maxPartyMember} 175 | 명 중 176 | {party.joinners.length} 명이 모였습니다. 177 | 178 | ) 179 | } 180 | 181 |
182 | 183 |
파티 설명
184 | 185 | {party.description} 186 | 187 |
188 | 189 |
누가 오나요?
190 | 191 | 192 | { 193 | party.fetchedJoinners && 194 | party.fetchedJoinners.map((fetchedJoinner: User, i: number) => ( 195 | 196 | {fetchedJoinner.displayName} 197 | {fetchedJoinner.displayName} 198 | 199 | )) 200 | } 201 | 202 | 203 |
204 |
205 | 206 |
207 |
208 | ) 209 | } 210 | return null 211 | } 212 | } 213 | -------------------------------------------------------------------------------- /src/PartyComments.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as moment from 'moment' 3 | import styled from 'styled-components' 4 | 5 | import { 6 | subscribeComments, 7 | unsubscibeComments, 8 | savePartyComment, 9 | removePartyComment, 10 | } from './utils/partyComments' 11 | 12 | import GifSearch from './GifSearch' 13 | 14 | import { stripScripts } from './utils/misc' 15 | 16 | import './PartyComments.css' 17 | 18 | type Props = { 19 | partyId: string, 20 | user: User | null, 21 | } 22 | 23 | type State = { 24 | visibleGifSearch: boolean, 25 | insertedComment: string, 26 | editComment: string, 27 | partyComments: PartyComment[], 28 | editCommentIndex: number, 29 | nowEditCommentUpdating: boolean, 30 | } 31 | 32 | const CommentActions = styled.div` 33 | display: flex; 34 | flex-direction: row; 35 | justify-content: flex-end; 36 | ` 37 | 38 | export default class PartyComments extends React.Component { 39 | $editInput: HTMLInputElement | null = null 40 | 41 | state = { 42 | visibleGifSearch: false, 43 | insertedComment: '', 44 | editComment: '', 45 | partyComments: [], 46 | editCommentIndex: -1, 47 | nowEditCommentUpdating: false, 48 | } 49 | 50 | componentDidMount() { 51 | subscribeComments(this.props.partyId, (partyComments: PartyComment[]) => { 52 | this.setState({ partyComments }) 53 | }) 54 | } 55 | 56 | componentDidUpdate() { 57 | if (this.$editInput) { 58 | this.$editInput.focus() 59 | this.$editInput = null 60 | } 61 | } 62 | 63 | componentWillUnmount() { 64 | unsubscibeComments() 65 | } 66 | 67 | async asyncSetState(state: any) { 68 | return new Promise((resolve: any) => this.setState(state, resolve)) 69 | } 70 | 71 | handleAddComment = async (e: React.FormEvent) => { 72 | e.preventDefault() 73 | 74 | // 낙관적 업데이트 75 | const { partyComments, insertedComment } = this.state 76 | const { partyId, user } = this.props 77 | 78 | if (user) { 79 | const insertComment = { 80 | partyId, 81 | user, 82 | content: insertedComment, 83 | } 84 | 85 | await this.asyncSetState({ 86 | partyComments: partyComments ? [...partyComments, insertComment] : [insertComment], 87 | insertedComment: '', 88 | }) 89 | await savePartyComment(insertComment) 90 | } 91 | } 92 | 93 | handleCommentEditClick = (index: number) => { 94 | const { partyComments } = this.state 95 | 96 | if (partyComments && partyComments[index]) { 97 | const editTargetComment = partyComments[index] as PartyComment 98 | this.setState({ 99 | editComment: editTargetComment.content, 100 | editCommentIndex: index, 101 | }) 102 | } 103 | } 104 | 105 | handleCommentEditSubmit = async (e: React.FormEvent) => { 106 | e.preventDefault() 107 | 108 | await this.asyncSetState({ 109 | nowEditCommentUpdating: true, 110 | }) 111 | 112 | const { partyComments, editCommentIndex, editComment } = this.state 113 | const updateTargetComment = partyComments[editCommentIndex] as PartyComment 114 | 115 | if (updateTargetComment) { 116 | const { id, partyId, user } = updateTargetComment 117 | const updateComment = { 118 | id, 119 | partyId, 120 | user, 121 | content: editComment, 122 | } 123 | 124 | await savePartyComment(updateComment) 125 | } 126 | 127 | await this.asyncSetState({ 128 | nowEditCommentUpdating: false, 129 | editComment: '', 130 | editCommentIndex: -1, 131 | }) 132 | } 133 | 134 | handleCommentRemoveClick = async (commentId: string | undefined, index: number) => { 135 | if (commentId && this.state.partyComments) { 136 | // 낙관적 업데이트 137 | const partyComments = [...this.state.partyComments] 138 | partyComments.splice(index, 1) 139 | await this.asyncSetState({ partyComments }) 140 | await removePartyComment(commentId) 141 | } 142 | } 143 | 144 | handleGifClick = () => { 145 | this.setState({ 146 | visibleGifSearch: !this.state.visibleGifSearch 147 | }) 148 | } 149 | 150 | handleAddGifComment = async (gif: Giphy) => { 151 | await this.asyncSetState({ 152 | visibleGifSearch: false 153 | }) 154 | 155 | // 낙관적 업데이트 156 | const { partyId, user } = this.props 157 | 158 | if (user) { 159 | const { images } = gif 160 | const { fixedHeight } = images 161 | const { url, width, height } = fixedHeight 162 | 163 | const content = `` 164 | const gifComment = { 165 | partyId, 166 | user, 167 | content 168 | } 169 | 170 | const { partyComments } = this.state 171 | 172 | await this.asyncSetState({ 173 | partyComments: partyComments ? [...partyComments, gifComment] : [gifComment] 174 | }) 175 | await savePartyComment(gifComment) 176 | } 177 | 178 | } 179 | renderMoment(comment: PartyComment) { 180 | const momentTarget = comment.isEdited ? comment.updatedAt : comment.createdAt 181 | 182 | if (momentTarget) { 183 | return moment(momentTarget.toDate()).fromNow() 184 | } 185 | 186 | return null 187 | } 188 | 189 | render() { 190 | const { 191 | visibleGifSearch, 192 | partyComments, 193 | insertedComment, 194 | editCommentIndex, 195 | editComment, 196 | nowEditCommentUpdating, 197 | } = this.state 198 | const { user } = this.props 199 | 200 | return ( 201 |
202 |
    203 | {partyComments !== null && partyComments.length === 0 && ( 204 |
  • 205 | 😢 이 파티에 대한 댓글이 하나도 없네요.
  • 206 | )} 207 | {partyComments !== null && partyComments.length > 0 && ( 208 |
  • {partyComments.length}개의 댓글이 있습니다.
  • 209 | )} 210 | {partyComments && partyComments.map((comment: PartyComment, i: number) => ( 211 |
  • 212 | {`${comment.user.displayName} 217 |
    218 |
    219 | {comment.user.displayName} 220 | {user && 221 | comment.createdBy === user.email && 222 |
    223 | 226 | 229 |
    230 | } 231 |
    232 |
    233 | { 234 | editCommentIndex === i ? 235 | ( 236 |
    237 | this.$editInput = input} 239 | className="PartyComments__commentline" 240 | type="text" 241 | value={editComment} 242 | disabled={nowEditCommentUpdating} 243 | onChange={(e: React.FormEvent) => 244 | this.setState({ 245 | editComment: e.currentTarget.value, 246 | })} 247 | onBlur={this.handleCommentEditSubmit} 248 | /> 249 | 250 | ) : ( 251 | 252 |

    253 | {comment.isEdited ? ( 254 | (edited) 255 | ) : ''} 256 |

    257 |

    258 |
    259 | {this.renderMoment(comment)} 260 |
    261 | 262 | ) 263 | } 264 |
    265 |
    266 |
  • 267 | ))} 268 | 269 |
  • 270 | {user && 271 | {`${user.displayName} 272 | } 273 |
    274 |
    275 | ) => 282 | this.setState({ 283 | insertedComment: e.currentTarget.value, 284 | })} 285 | /> 286 |
    287 |
    288 |
  • 289 | {user && 290 | 291 | 295 | 296 | } 297 | {visibleGifSearch && 298 | this.setState({ visibleGifSearch: false })} 301 | /> 302 | } 303 |
304 |
305 | ) 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /src/PartyForm.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { FormGroup, Label } from 'reactstrap' 3 | import * as AutoComplete from 'react-autocomplete' 4 | import ReactSelect from 'react-select' 5 | import flatpickr from 'flatpickr' 6 | import Flatpickr from 'react-flatpickr' 7 | 8 | import { ESC } from './utils/keycodes' 9 | import { Overlay, CloseBtn } from './CommonStyledComponents' 10 | 11 | import './PartyForm.css' 12 | import 'flatpickr/dist/themes/light.css' 13 | import 'react-select/dist/react-select.css' 14 | 15 | interface Props { 16 | party: Party | null 17 | categories: Category[] 18 | destinations: Destination[] | null 19 | onSave: Function 20 | onClose: Function 21 | } 22 | 23 | interface State { 24 | form: { 25 | isChanged: boolean, 26 | id?: string, 27 | category: Category, 28 | title: string, 29 | destinationName: string, 30 | playName: string, 31 | partyTimeString: string, 32 | dueDateTimeString: string | null, 33 | description: string, 34 | maxPartyMember: number, 35 | }, 36 | selectedDueDateTime: DueDateTimeOption, 37 | } 38 | 39 | interface DueDateTimeOption { 40 | label: string, 41 | value: number, 42 | } 43 | 44 | const ONE_MINUTE = 1000 * 60 45 | const DATE_FORMAT = 'Y-m-d H:i' 46 | const { parseDate, formatDate } = flatpickr 47 | 48 | const dueDateTimeOptions = [ 49 | { 50 | label: '5분 전', 51 | value: ONE_MINUTE * 5, 52 | }, 53 | { 54 | label: '15분 전', 55 | value: ONE_MINUTE * 10, 56 | }, 57 | { 58 | label: '삼십분 전', 59 | value: ONE_MINUTE * 30, 60 | }, 61 | { 62 | label: '한 시간 전', 63 | value: ONE_MINUTE * 60, 64 | }, 65 | { 66 | label: '하루 전', 67 | value: ONE_MINUTE * 60 * 24, 68 | }, 69 | ] 70 | 71 | export default class PartyForm extends React.Component { 72 | constructor(props: Props) { 73 | super(props) 74 | 75 | const { party, categories } = props 76 | 77 | if (party) { 78 | const { 79 | id, 80 | category, 81 | title, 82 | destinationName, 83 | playName, 84 | partyTime, 85 | dueDateTime, 86 | description, 87 | maxPartyMember, 88 | } = party 89 | 90 | this.state = { 91 | form: { 92 | id, 93 | category, 94 | title, 95 | destinationName, 96 | playName, 97 | description, 98 | maxPartyMember, 99 | partyTimeString: formatDate(partyTime.toDate(), DATE_FORMAT), 100 | dueDateTimeString: dueDateTime ? formatDate(dueDateTime.toDate(), DATE_FORMAT) : null, 101 | isChanged: false, 102 | }, 103 | selectedDueDateTime: dueDateTimeOptions[0], 104 | } 105 | } else { 106 | this.state = { 107 | form: { 108 | category: categories[0], 109 | title: '', 110 | destinationName: '', 111 | playName: '', 112 | partyTimeString: formatDate(new Date(), DATE_FORMAT), 113 | dueDateTimeString: formatDate(new Date(), DATE_FORMAT), 114 | description: '', 115 | maxPartyMember: 0, 116 | isChanged: false, 117 | }, 118 | selectedDueDateTime: dueDateTimeOptions[0], 119 | } 120 | } 121 | } 122 | 123 | handleFormChange = (field: string, value: string | boolean | Date) => { 124 | const { form } = this.state 125 | this.setState({ 126 | form: { 127 | ...form, 128 | [field]: value, 129 | isChanged: true, 130 | }, 131 | }) 132 | } 133 | 134 | handleSubmit = async (e: React.FormEvent) => { 135 | e.preventDefault() 136 | 137 | const { form } = this.state 138 | const { onSave, onClose } = this.props 139 | 140 | const { 141 | id, 142 | category, 143 | title, 144 | description, 145 | destinationName, 146 | playName, 147 | maxPartyMember, 148 | partyTimeString, 149 | dueDateTimeString, 150 | } = form 151 | 152 | await onSave({ 153 | id, 154 | category, 155 | title, 156 | description, 157 | destinationName, 158 | playName, 159 | maxPartyMember, 160 | partyTimeDate: parseDate(partyTimeString, DATE_FORMAT), 161 | dueDateTimeDate: dueDateTimeString ? parseDate(dueDateTimeString, DATE_FORMAT) : null, 162 | }) 163 | 164 | onClose() 165 | } 166 | 167 | componentDidMount() { 168 | document.body.classList.add('modal-open') 169 | document.body.addEventListener('keyup', this.handleKeyPress) 170 | } 171 | 172 | componentWillUnmount() { 173 | document.body.classList.remove('modal-open') 174 | document.body.removeEventListener('keyup', this.handleKeyPress) 175 | } 176 | 177 | getPartyText(category: Category) { 178 | if (category.isTravel) { 179 | return '여행 출발 날짜가 언제인가요?' 180 | } 181 | 182 | if (category.isRestaurant) { 183 | return '언제 먹나요?' 184 | } 185 | 186 | return '언제 모이나요?' 187 | } 188 | 189 | destinationsToAutoComplete(filter: (destination: Destination) => boolean): any[] { 190 | const { destinations } = this.props 191 | 192 | if (!destinations) { 193 | return [] 194 | } 195 | 196 | return destinations 197 | .filter(filter) 198 | .map((destination: Destination) => ({ 199 | label: destination.id, 200 | })) 201 | } 202 | 203 | destinationsToFilteredAutocomplete(): any[] { 204 | const { category } = this.state.form 205 | 206 | const filter = (() => { 207 | if (category.isTravel) { 208 | return (destination: Destination) => destination.isTravel 209 | } 210 | 211 | if (category.isRestaurant) { 212 | return category.isDeliverable ? 213 | (destination: Destination) => destination.isRestaurant && destination.isDeliverable : 214 | (destination: Destination) => destination.isRestaurant 215 | } 216 | 217 | return () => true 218 | })() 219 | 220 | return this.destinationsToAutoComplete(filter) 221 | } 222 | 223 | destinationsToPlayNameAutocomplete(): any[] { 224 | return this.destinationsToAutoComplete((destination: Destination) => destination.isPlaying) 225 | } 226 | 227 | calculateDueDateTime = (partyTimeString: string, duration: number): string | null => { 228 | const partyTime = parseDate(partyTimeString, DATE_FORMAT) 229 | 230 | if (partyTime) { 231 | return formatDate( 232 | new Date(partyTime.getTime() - duration), 233 | DATE_FORMAT, 234 | ) 235 | } 236 | 237 | return null 238 | } 239 | 240 | handleKeyPress = (evt: KeyboardEvent) => { 241 | if (evt.keyCode !== ESC) return 242 | this.handleClose() 243 | } 244 | 245 | handleClose = () => { 246 | if (this.state.form.isChanged) { 247 | if (confirm('작성 중인 내용이 날라가도 괜찮습니까?')) { 248 | this.props.onClose() 249 | } 250 | } else { 251 | this.props.onClose() 252 | } 253 | 254 | } 255 | 256 | handlePartyTimeChange = (date: any, str: string) => { 257 | this.handleFormChange('partyTimeString', str) 258 | 259 | if (this.state.form.category.hasDueDateTime) { 260 | this.handleDueDateTimeChange(this.state.selectedDueDateTime) 261 | } 262 | } 263 | 264 | handleDueDateTimeChange = (selectedOption: DueDateTimeOption) => { 265 | if (selectedOption) { 266 | const { form } = this.state 267 | const { partyTimeString } = form 268 | 269 | this.setState({ 270 | form: { 271 | ...form, 272 | dueDateTimeString: this.calculateDueDateTime( 273 | partyTimeString, selectedOption.value, 274 | ), 275 | }, 276 | selectedDueDateTime: selectedOption, 277 | }) 278 | } 279 | } 280 | 281 | render() { 282 | const { form, selectedDueDateTime } = this.state 283 | const { category } = form 284 | const { categories } = this.props 285 | 286 | return ( 287 | 288 | 289 |
evt.stopPropagation()} 291 | > 292 |
293 |

🎉 파티 {form.id ? '수정하기' : '만들기'}

294 |
295 |
298 |
299 | 300 | 301 | { 312 | if (selectedOption) { 313 | this.handleFormChange('category', selectedOption) 314 | } 315 | }} 316 | /> 317 | 318 |
319 |
320 | 321 | 325 | ) => 334 | this.handleFormChange('title', e.currentTarget.value) 335 | } 336 | /> 337 | 338 |
339 | 340 |
341 | {category.isRestaurant ? ( 342 | 343 | 349 | item.label} 361 | items={this.destinationsToFilteredAutocomplete()} 362 | renderItem={(item, isHighlighted: boolean) => ( 363 |
367 | {item.label} 368 |
369 | )} 370 | value={form.destinationName} 371 | onChange={ 372 | (e: React.ChangeEvent) => 373 | this.handleFormChange('destinationName', e.currentTarget.value)} 374 | onSelect={(value: string) => this.handleFormChange('destinationName', value)} 375 | /> 376 |
377 | ) : ( 378 | 379 | ) 380 | } 381 | 382 | 383 | ) => 390 | this.handleFormChange('maxPartyMember', e.currentTarget.value) 391 | } 392 | /> 393 | 394 |
395 | 396 | {category.isPlaying && 397 |
398 | 399 | 400 | item.label} 412 | items={this.destinationsToPlayNameAutocomplete()} 413 | renderItem={(item, isHighlighted: boolean) => ( 414 |
418 | {item.label} 419 |
420 | )} 421 | value={form.playName} 422 | onChange={ 423 | (e: React.ChangeEvent) => 424 | this.handleFormChange('playName', e.currentTarget.value)} 425 | onSelect={(value: string) => this.handleFormChange('playName', value)} 426 | /> 427 |
428 |
429 | } 430 |
431 | 432 | 434 |
435 | 440 |
441 |
442 | { 443 | category.hasDueDateTime && 444 | 445 | 446 |
447 | 456 |
457 |
458 | } 459 |
460 |
461 | 462 |