From b3f42e62af698a67c2250533c437569559f1fdf9 Mon Sep 17 00:00:00 2001 From: syuilo Date: Thu, 29 Dec 2016 07:49:51 +0900 Subject: [PATCH] Initial commit :four_leaf_clover: --- .ci-files/config.yml | 26 + .gitattributes | 3 + .gitignore | 5 + .travis.yml | 8 + LICENSE | 21 + README.md | 44 + elasticsearch/README.md | 6 + elasticsearch/mappings.json | 65 ++ gulpfile.js | 1 + gulpfile.ts | 568 ++++++++++++ init.js | 182 ++++ jsconfig.json | 14 + package.json | 135 +++ resources/apple-touch-icon.png | Bin 0 -> 1203 bytes resources/favicon.ico | Bin 0 -> 360414 bytes resources/favicon/128.png | Bin 0 -> 1423 bytes resources/favicon/16.png | Bin 0 -> 323 bytes resources/favicon/256.png | Bin 0 -> 2753 bytes resources/favicon/32.png | Bin 0 -> 532 bytes resources/favicon/64.png | Bin 0 -> 930 bytes resources/icon.ai | Bin 0 -> 262881 bytes resources/icon.png | Bin 0 -> 2753 bytes resources/icon.svg | Bin 0 -> 843 bytes resources/logo.svg | Bin 0 -> 628 bytes src/api/api-handler.ts | 55 ++ src/api/authenticate.ts | 61 ++ src/api/common/add-file-to-drive.ts | 149 ++++ src/api/common/get-friends.ts | 25 + src/api/common/notify.ts | 32 + src/api/endpoints.ts | 101 +++ src/api/endpoints/aggregation/posts/like.js | 83 ++ src/api/endpoints/aggregation/posts/likes.js | 76 ++ src/api/endpoints/aggregation/posts/reply.js | 82 ++ src/api/endpoints/aggregation/posts/repost.js | 82 ++ .../endpoints/aggregation/users/followers.js | 77 ++ .../endpoints/aggregation/users/following.js | 76 ++ src/api/endpoints/aggregation/users/like.js | 83 ++ src/api/endpoints/aggregation/users/post.js | 113 +++ src/api/endpoints/app/create.js | 75 ++ src/api/endpoints/app/name_id/available.js | 40 + src/api/endpoints/app/show.js | 51 ++ src/api/endpoints/auth/accept.js | 64 ++ src/api/endpoints/auth/session/generate.js | 51 ++ src/api/endpoints/auth/session/show.js | 36 + src/api/endpoints/auth/session/userkey.js | 74 ++ src/api/endpoints/drive.js | 33 + src/api/endpoints/drive/files.js | 82 ++ src/api/endpoints/drive/files/create.js | 59 ++ src/api/endpoints/drive/files/find.js | 48 + src/api/endpoints/drive/files/show.js | 40 + src/api/endpoints/drive/files/update.js | 89 ++ src/api/endpoints/drive/folders.js | 82 ++ src/api/endpoints/drive/folders/create.js | 79 ++ src/api/endpoints/drive/folders/find.js | 46 + src/api/endpoints/drive/folders/show.js | 41 + src/api/endpoints/drive/folders/update.js | 114 +++ src/api/endpoints/drive/stream.js | 85 ++ src/api/endpoints/following/create.js | 86 ++ src/api/endpoints/following/delete.js | 83 ++ src/api/endpoints/i.js | 25 + src/api/endpoints/i/appdata/get.js | 53 ++ src/api/endpoints/i/appdata/set.js | 55 ++ src/api/endpoints/i/favorites.js | 60 ++ src/api/endpoints/i/notifications.js | 120 +++ src/api/endpoints/i/signin_history.js | 71 ++ src/api/endpoints/i/update.js | 95 ++ src/api/endpoints/messaging/history.js | 48 + src/api/endpoints/messaging/messages.js | 139 +++ .../endpoints/messaging/messages/create.js | 152 ++++ src/api/endpoints/messaging/unread.js | 27 + src/api/endpoints/meta.js | 24 + src/api/endpoints/my/apps.js | 59 ++ .../endpoints/notifications/mark_as_read.js | 54 ++ src/api/endpoints/posts.js | 65 ++ src/api/endpoints/posts/context.js | 83 ++ src/api/endpoints/posts/create.js | 345 +++++++ src/api/endpoints/posts/favorites/create.js | 56 ++ src/api/endpoints/posts/favorites/delete.js | 52 ++ src/api/endpoints/posts/likes.js | 77 ++ src/api/endpoints/posts/likes/create.js | 93 ++ src/api/endpoints/posts/likes/delete.js | 80 ++ src/api/endpoints/posts/mentions.js | 85 ++ src/api/endpoints/posts/replies.js | 73 ++ src/api/endpoints/posts/reposts.js | 85 ++ src/api/endpoints/posts/search.js | 138 +++ src/api/endpoints/posts/show.js | 40 + src/api/endpoints/posts/timeline.js | 78 ++ src/api/endpoints/username/available.js | 41 + src/api/endpoints/users.js | 67 ++ src/api/endpoints/users/followers.js | 102 +++ src/api/endpoints/users/following.js | 102 +++ src/api/endpoints/users/posts.js | 114 +++ src/api/endpoints/users/recommendation.js | 61 ++ src/api/endpoints/users/search.js | 116 +++ src/api/endpoints/users/search_by_username.js | 65 ++ src/api/endpoints/users/show.js | 49 + src/api/event.ts | 36 + src/api/limitter.ts | 69 ++ src/api/models/app.ts | 7 + src/api/models/appdata.ts | 1 + src/api/models/auth-session.ts | 1 + src/api/models/drive-file.ts | 11 + src/api/models/drive-folder.ts | 8 + src/api/models/drive-tag.ts | 1 + src/api/models/favorite.ts | 1 + src/api/models/following.ts | 1 + src/api/models/like.ts | 1 + src/api/models/messaging-history.ts | 1 + src/api/models/messaging-message.ts | 1 + src/api/models/notification.ts | 1 + src/api/models/post.ts | 1 + src/api/models/signin.ts | 1 + src/api/models/user.ts | 10 + src/api/models/userkey.ts | 5 + src/api/private/signin.ts | 57 ++ src/api/private/signup.ts | 94 ++ src/api/reply.ts | 13 + src/api/serializers/app.ts | 85 ++ src/api/serializers/auth-session.ts | 42 + src/api/serializers/drive-file.ts | 63 ++ src/api/serializers/drive-folder.ts | 52 ++ src/api/serializers/drive-tag.ts | 37 + src/api/serializers/messaging-message.ts | 64 ++ src/api/serializers/notification.ts | 66 ++ src/api/serializers/post.ts | 103 +++ src/api/serializers/signin.ts | 25 + src/api/serializers/user.ts | 138 +++ src/api/server.ts | 52 ++ src/api/stream/home.ts | 10 + src/api/stream/messaging.ts | 60 ++ src/api/streaming.ts | 69 ++ src/common/text/elements/bold.js | 17 + src/common/text/elements/hashtag.js | 23 + src/common/text/elements/mention.js | 17 + src/common/text/elements/url.js | 16 + src/common/text/index.js | 67 ++ src/config.ts | 95 ++ src/db/elasticsearch.ts | 21 + src/db/mongodb.ts | 8 + src/db/redis.ts | 9 + src/file/resources/avatar.jpg | Bin 0 -> 1322 bytes src/file/resources/bad-egg.png | Bin 0 -> 4783 bytes src/file/resources/dummy.png | Bin 0 -> 6285 bytes src/file/server.ts | 115 +++ src/himasaku/resources/himasaku.png | Bin 0 -> 144018 bytes src/himasaku/resources/index.html | 35 + src/himasaku/server.ts | 23 + src/index.d.ts | 11 + src/index.ts | 223 +++++ src/server.ts | 49 + src/utils/check-dependencies.ts | 23 + src/utils/cli/indicator.ts | 35 + src/utils/cli/progressbar.ts | 87 ++ src/web/about/base.pug | 39 + src/web/about/pages/api/entities/post.pug | 149 ++++ src/web/about/pages/api/entities/user.pug | 118 +++ src/web/about/pages/api/getting-started.pug | 74 ++ src/web/about/pages/api/library.pug | 14 + src/web/about/pages/license.pug | 9 + src/web/about/resources/style.css | 199 +++++ src/web/app/auth/resources/logo.svg | Bin 0 -> 646 bytes src/web/app/auth/script.js | 19 + src/web/app/auth/style.styl | 14 + src/web/app/auth/tags.ls | 2 + src/web/app/auth/tags/form.tag | 126 +++ src/web/app/auth/tags/index.tag | 129 +++ src/web/app/auth/view.pug | 6 + src/web/app/base.pug | 23 + src/web/app/base.styl | 118 +++ src/web/app/boot.ls | 154 ++++ src/web/app/client/script.js | 40 + src/web/app/client/view.pug | 5 + src/web/app/common/mixins.ls | 40 + src/web/app/common/pages/about/base.pug | 13 + .../app/common/pages/about/pages/staff.pug | 13 + src/web/app/common/scripts/api.ls | 67 ++ src/web/app/common/scripts/bytes-to-size.js | 6 + .../app/common/scripts/check-for-update.ls | 9 + src/web/app/common/scripts/date-stringify.ls | 14 + .../scripts/generate-default-userdata.ls | 27 + .../app/common/scripts/get-post-summary.ls | 26 + src/web/app/common/scripts/i.ls | 16 + src/web/app/common/scripts/is-promise.ls | 1 + src/web/app/common/scripts/loading.ls | 16 + src/web/app/common/scripts/log.ls | 18 + .../app/common/scripts/messaging-stream.ls | 34 + src/web/app/common/scripts/signout.ls | 4 + src/web/app/common/scripts/stream.ls | 42 + src/web/app/common/scripts/text-compiler.js | 30 + src/web/app/common/scripts/uuid.js | 12 + src/web/app/common/tags.ls | 16 + src/web/app/common/tags/copyright.tag | 5 + src/web/app/common/tags/core-error.tag | 63 ++ src/web/app/common/tags/ellipsis.tag | 25 + src/web/app/common/tags/file-type-icon.tag | 9 + src/web/app/common/tags/forkit.tag | 37 + src/web/app/common/tags/introduction.tag | 22 + src/web/app/common/tags/number.tag | 15 + src/web/app/common/tags/raw.tag | 7 + src/web/app/common/tags/ripple-string.tag | 24 + src/web/app/common/tags/signin.tag | 136 +++ src/web/app/common/tags/signup.tag | 352 ++++++++ src/web/app/common/tags/special-message.tag | 24 + src/web/app/common/tags/time.tag | 43 + src/web/app/common/tags/uploader.tag | 201 +++++ src/web/app/common/tags/url-preview.tag | 105 +++ src/web/app/common/tags/url.tag | 50 ++ src/web/app/desktop/mixins.ls | 47 + src/web/app/desktop/resources/header-logo.svg | Bin 0 -> 646 bytes src/web/app/desktop/resources/remove.png | Bin 0 -> 3115 bytes src/web/app/desktop/router.ls | 77 ++ src/web/app/desktop/script.js | 42 + src/web/app/desktop/scripts/autocomplete.ls | 108 +++ src/web/app/desktop/scripts/dialog.ls | 17 + src/web/app/desktop/scripts/follow-scroll.ls | 56 ++ src/web/app/desktop/scripts/fuck-ad-block.ls | 19 + src/web/app/desktop/scripts/input-dialog.ls | 13 + src/web/app/desktop/scripts/notify.ls | 6 + src/web/app/desktop/scripts/open-window.ls | 8 + src/web/app/desktop/scripts/stream.ls | 38 + src/web/app/desktop/scripts/update-avatar.ls | 81 ++ src/web/app/desktop/scripts/update-banner.ls | 81 ++ .../app/desktop/scripts/update-wallpaper.ls | 35 + src/web/app/desktop/scripts/user-preview.ls | 74 ++ src/web/app/desktop/style.styl | 114 +++ src/web/app/desktop/tags.ls | 103 +++ src/web/app/desktop/tags/analog-clock.tag | 102 +++ .../desktop/tags/autocomplete-suggestion.tag | 182 ++++ .../app/desktop/tags/big-follow-button.tag | 134 +++ src/web/app/desktop/tags/contextmenu.tag | 138 +++ src/web/app/desktop/tags/crop-window.tag | 189 ++++ src/web/app/desktop/tags/debugger.tag | 87 ++ ...detect-slow-internet-connection-notice.tag | 56 ++ src/web/app/desktop/tags/dialog.tag | 141 +++ src/web/app/desktop/tags/donation.tag | 63 ++ .../desktop/tags/drive/base-contextmenu.tag | 28 + .../app/desktop/tags/drive/browser-window.tag | 29 + src/web/app/desktop/tags/drive/browser.tag | 634 +++++++++++++ .../desktop/tags/drive/file-contextmenu.tag | 97 ++ src/web/app/desktop/tags/drive/file.tag | 207 +++++ .../desktop/tags/drive/folder-contextmenu.tag | 62 ++ src/web/app/desktop/tags/drive/folder.tag | 183 ++++ src/web/app/desktop/tags/drive/nav-folder.tag | 96 ++ src/web/app/desktop/tags/ellipsis-icon.tag | 34 + src/web/app/desktop/tags/follow-button.tag | 127 +++ .../app/desktop/tags/following-setuper.tag | 163 ++++ src/web/app/desktop/tags/go-top.tag | 15 + .../desktop/tags/home-widgets/broadcast.tag | 75 ++ .../desktop/tags/home-widgets/calendar.tag | 147 +++ .../desktop/tags/home-widgets/donation.tag | 37 + .../desktop/tags/home-widgets/mentions.tag | 117 +++ src/web/app/desktop/tags/home-widgets/nav.tag | 23 + .../tags/home-widgets/notifications.tag | 49 + .../tags/home-widgets/photo-stream.tag | 86 ++ .../app/desktop/tags/home-widgets/profile.tag | 55 ++ .../desktop/tags/home-widgets/rss-reader.tag | 94 ++ .../desktop/tags/home-widgets/timeline.tag | 113 +++ .../app/desktop/tags/home-widgets/tips.tag | 70 ++ .../tags/home-widgets/user-recommendation.tag | 154 ++++ src/web/app/desktop/tags/home.tag | 86 ++ src/web/app/desktop/tags/image-dialog.tag | 73 ++ src/web/app/desktop/tags/images-viewer.tag | 43 + src/web/app/desktop/tags/input-dialog.tag | 156 ++++ src/web/app/desktop/tags/list-user.tag | 100 +++ src/web/app/desktop/tags/log-window.tag | 20 + src/web/app/desktop/tags/log.tag | 62 ++ src/web/app/desktop/tags/messaging/form.tag | 162 ++++ src/web/app/desktop/tags/messaging/index.tag | 302 +++++++ .../app/desktop/tags/messaging/message.tag | 227 +++++ .../desktop/tags/messaging/room-window.tag | 26 + src/web/app/desktop/tags/messaging/room.tag | 227 +++++ src/web/app/desktop/tags/messaging/window.tag | 29 + src/web/app/desktop/tags/notifications.tag | 226 +++++ src/web/app/desktop/tags/pages/entrance.tag | 77 ++ .../desktop/tags/pages/entrance/signin.tag | 128 +++ .../desktop/tags/pages/entrance/signup.tag | 44 + src/web/app/desktop/tags/pages/home.tag | 51 ++ src/web/app/desktop/tags/pages/not-found.tag | 46 + src/web/app/desktop/tags/pages/post.tag | 25 + src/web/app/desktop/tags/pages/search.tag | 14 + src/web/app/desktop/tags/pages/user.tag | 20 + src/web/app/desktop/tags/post-detail-sub.tag | 141 +++ src/web/app/desktop/tags/post-detail.tag | 415 +++++++++ src/web/app/desktop/tags/post-form-window.tag | 60 ++ src/web/app/desktop/tags/post-form.tag | 430 +++++++++ src/web/app/desktop/tags/post-preview.tag | 94 ++ .../app/desktop/tags/post-status-graph.tag | 72 ++ src/web/app/desktop/tags/progress-dialog.tag | 92 ++ .../app/desktop/tags/repost-form-window.tag | 38 + src/web/app/desktop/tags/repost-form.tag | 140 +++ src/web/app/desktop/tags/search-posts.tag | 88 ++ src/web/app/desktop/tags/search.tag | 28 + .../tags/select-file-from-drive-window.tag | 160 ++++ .../desktop/tags/set-avatar-suggestion.tag | 44 + .../desktop/tags/set-banner-suggestion.tag | 44 + src/web/app/desktop/tags/settings-window.tag | 26 + src/web/app/desktop/tags/settings.tag | 255 ++++++ src/web/app/desktop/tags/signin-history.tag | 73 ++ src/web/app/desktop/tags/stream-indicator.tag | 59 ++ src/web/app/desktop/tags/sub-post-content.tag | 37 + .../app/desktop/tags/timeline-post-sub.tag | 95 ++ src/web/app/desktop/tags/timeline-post.tag | 376 ++++++++ src/web/app/desktop/tags/timeline.tag | 86 ++ .../app/desktop/tags/ui-header-account.tag | 219 +++++ src/web/app/desktop/tags/ui-header-clock.tag | 82 ++ src/web/app/desktop/tags/ui-header-nav.tag | 113 +++ .../desktop/tags/ui-header-notifications.tag | 111 +++ .../desktop/tags/ui-header-post-button.tag | 39 + src/web/app/desktop/tags/ui-header-search.tag | 37 + src/web/app/desktop/tags/ui-header.tag | 85 ++ src/web/app/desktop/tags/ui-notification.tag | 41 + src/web/app/desktop/tags/ui.tag | 37 + .../desktop/tags/user-followers-window.tag | 22 + src/web/app/desktop/tags/user-followers.tag | 19 + .../desktop/tags/user-following-window.tag | 22 + src/web/app/desktop/tags/user-following.tag | 19 + .../app/desktop/tags/user-friends-graph.tag | 64 ++ src/web/app/desktop/tags/user-graphs.tag | 36 + src/web/app/desktop/tags/user-header.tag | 143 +++ src/web/app/desktop/tags/user-home.tag | 40 + src/web/app/desktop/tags/user-likes-graph.tag | 39 + src/web/app/desktop/tags/user-photos.tag | 85 ++ src/web/app/desktop/tags/user-posts-graph.tag | 68 ++ src/web/app/desktop/tags/user-preview.tag | 143 +++ src/web/app/desktop/tags/user-profile.tag | 72 ++ src/web/app/desktop/tags/user-timeline.tag | 142 +++ src/web/app/desktop/tags/user.tag | 45 + src/web/app/desktop/tags/users-list.tag | 139 +++ src/web/app/desktop/tags/window.tag | 515 +++++++++++ src/web/app/dev/router.ls | 51 ++ src/web/app/dev/script.js | 15 + src/web/app/dev/style.styl | 10 + src/web/app/dev/tags.ls | 5 + src/web/app/dev/tags/new-app-form.tag | 260 ++++++ src/web/app/dev/tags/pages/app.tag | 24 + src/web/app/dev/tags/pages/apps.tag | 26 + src/web/app/dev/tags/pages/index.tag | 5 + src/web/app/dev/tags/pages/new-app.tag | 33 + src/web/app/dev/view.pug | 5 + src/web/app/init.styl | 56 ++ src/web/app/mobile/mixins.ls | 19 + src/web/app/mobile/router.ls | 110 +++ src/web/app/mobile/script.js | 20 + src/web/app/mobile/scripts/sp-slidemenu.js | 839 ++++++++++++++++++ src/web/app/mobile/scripts/stream.ls | 13 + src/web/app/mobile/scripts/ui.ls | 6 + src/web/app/mobile/style.styl | 12 + src/web/app/mobile/tags.ls | 44 + src/web/app/mobile/tags/drive-selector.tag | 75 ++ src/web/app/mobile/tags/drive.tag | 338 +++++++ src/web/app/mobile/tags/drive/file-viewer.tag | 8 + src/web/app/mobile/tags/drive/file.tag | 130 +++ src/web/app/mobile/tags/drive/folder.tag | 45 + src/web/app/mobile/tags/follow-button.tag | 108 +++ src/web/app/mobile/tags/home-timeline.tag | 40 + src/web/app/mobile/tags/home.tag | 17 + src/web/app/mobile/tags/images-viewer.tag | 25 + .../app/mobile/tags/notification-preview.tag | 117 +++ src/web/app/mobile/tags/notification.tag | 142 +++ src/web/app/mobile/tags/notifications.tag | 98 ++ src/web/app/mobile/tags/notify.tag | 35 + src/web/app/mobile/tags/page/drive.tag | 46 + src/web/app/mobile/tags/page/entrance.tag | 57 ++ .../app/mobile/tags/page/entrance/signin.tag | 45 + .../app/mobile/tags/page/entrance/signup.tag | 35 + src/web/app/mobile/tags/page/home.tag | 40 + src/web/app/mobile/tags/page/new-post.tag | 5 + .../app/mobile/tags/page/notifications.tag | 18 + src/web/app/mobile/tags/page/post.tag | 31 + src/web/app/mobile/tags/page/search.tag | 19 + .../app/mobile/tags/page/user-followers.tag | 31 + .../app/mobile/tags/page/user-following.tag | 31 + src/web/app/mobile/tags/page/user.tag | 20 + src/web/app/mobile/tags/post-detail.tag | 415 +++++++++ src/web/app/mobile/tags/post-form.tag | 254 ++++++ src/web/app/mobile/tags/post-preview.tag | 89 ++ src/web/app/mobile/tags/search-posts.tag | 29 + src/web/app/mobile/tags/search.tag | 12 + src/web/app/mobile/tags/stream-indicator.tag | 59 ++ src/web/app/mobile/tags/sub-post-content.tag | 36 + src/web/app/mobile/tags/timeline-post-sub.tag | 99 +++ src/web/app/mobile/tags/timeline-post.tag | 296 ++++++ src/web/app/mobile/tags/timeline.tag | 128 +++ src/web/app/mobile/tags/ui-header.tag | 98 ++ src/web/app/mobile/tags/ui-nav.tag | 169 ++++ src/web/app/mobile/tags/ui.tag | 50 ++ src/web/app/mobile/tags/user-followers.tag | 22 + src/web/app/mobile/tags/user-following.tag | 22 + src/web/app/mobile/tags/user-preview.tag | 103 +++ src/web/app/mobile/tags/user-timeline.tag | 28 + src/web/app/mobile/tags/user.tag | 198 +++++ src/web/app/mobile/tags/users-list.tag | 125 +++ src/web/app/reset.styl | 27 + src/web/apple-touch-icon.ts | 8 + src/web/manifest.ts | 6 + src/web/meta.ts | 13 + src/web/serve-app.ts | 9 + src/web/server.ts | 77 ++ src/web/service/proxy/proxy.ts | 30 + src/web/service/proxy/server.ts | 17 + src/web/service/rss-proxy.ts | 16 + src/web/service/url-preview.ts | 13 + tsconfig.json | 23 + tslint.json | 116 +++ update.sh | 4 + 405 files changed, 29181 insertions(+) create mode 100644 .ci-files/config.yml create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 elasticsearch/README.md create mode 100644 elasticsearch/mappings.json create mode 100644 gulpfile.js create mode 100644 gulpfile.ts create mode 100644 init.js create mode 100644 jsconfig.json create mode 100644 package.json create mode 100644 resources/apple-touch-icon.png create mode 100644 resources/favicon.ico create mode 100644 resources/favicon/128.png create mode 100644 resources/favicon/16.png create mode 100644 resources/favicon/256.png create mode 100644 resources/favicon/32.png create mode 100644 resources/favicon/64.png create mode 100644 resources/icon.ai create mode 100644 resources/icon.png create mode 100644 resources/icon.svg create mode 100644 resources/logo.svg create mode 100644 src/api/api-handler.ts create mode 100644 src/api/authenticate.ts create mode 100644 src/api/common/add-file-to-drive.ts create mode 100644 src/api/common/get-friends.ts create mode 100644 src/api/common/notify.ts create mode 100644 src/api/endpoints.ts create mode 100644 src/api/endpoints/aggregation/posts/like.js create mode 100644 src/api/endpoints/aggregation/posts/likes.js create mode 100644 src/api/endpoints/aggregation/posts/reply.js create mode 100644 src/api/endpoints/aggregation/posts/repost.js create mode 100644 src/api/endpoints/aggregation/users/followers.js create mode 100644 src/api/endpoints/aggregation/users/following.js create mode 100644 src/api/endpoints/aggregation/users/like.js create mode 100644 src/api/endpoints/aggregation/users/post.js create mode 100644 src/api/endpoints/app/create.js create mode 100644 src/api/endpoints/app/name_id/available.js create mode 100644 src/api/endpoints/app/show.js create mode 100644 src/api/endpoints/auth/accept.js create mode 100644 src/api/endpoints/auth/session/generate.js create mode 100644 src/api/endpoints/auth/session/show.js create mode 100644 src/api/endpoints/auth/session/userkey.js create mode 100644 src/api/endpoints/drive.js create mode 100644 src/api/endpoints/drive/files.js create mode 100644 src/api/endpoints/drive/files/create.js create mode 100644 src/api/endpoints/drive/files/find.js create mode 100644 src/api/endpoints/drive/files/show.js create mode 100644 src/api/endpoints/drive/files/update.js create mode 100644 src/api/endpoints/drive/folders.js create mode 100644 src/api/endpoints/drive/folders/create.js create mode 100644 src/api/endpoints/drive/folders/find.js create mode 100644 src/api/endpoints/drive/folders/show.js create mode 100644 src/api/endpoints/drive/folders/update.js create mode 100644 src/api/endpoints/drive/stream.js create mode 100644 src/api/endpoints/following/create.js create mode 100644 src/api/endpoints/following/delete.js create mode 100644 src/api/endpoints/i.js create mode 100644 src/api/endpoints/i/appdata/get.js create mode 100644 src/api/endpoints/i/appdata/set.js create mode 100644 src/api/endpoints/i/favorites.js create mode 100644 src/api/endpoints/i/notifications.js create mode 100644 src/api/endpoints/i/signin_history.js create mode 100644 src/api/endpoints/i/update.js create mode 100644 src/api/endpoints/messaging/history.js create mode 100644 src/api/endpoints/messaging/messages.js create mode 100644 src/api/endpoints/messaging/messages/create.js create mode 100644 src/api/endpoints/messaging/unread.js create mode 100644 src/api/endpoints/meta.js create mode 100644 src/api/endpoints/my/apps.js create mode 100644 src/api/endpoints/notifications/mark_as_read.js create mode 100644 src/api/endpoints/posts.js create mode 100644 src/api/endpoints/posts/context.js create mode 100644 src/api/endpoints/posts/create.js create mode 100644 src/api/endpoints/posts/favorites/create.js create mode 100644 src/api/endpoints/posts/favorites/delete.js create mode 100644 src/api/endpoints/posts/likes.js create mode 100644 src/api/endpoints/posts/likes/create.js create mode 100644 src/api/endpoints/posts/likes/delete.js create mode 100644 src/api/endpoints/posts/mentions.js create mode 100644 src/api/endpoints/posts/replies.js create mode 100644 src/api/endpoints/posts/reposts.js create mode 100644 src/api/endpoints/posts/search.js create mode 100644 src/api/endpoints/posts/show.js create mode 100644 src/api/endpoints/posts/timeline.js create mode 100644 src/api/endpoints/username/available.js create mode 100644 src/api/endpoints/users.js create mode 100644 src/api/endpoints/users/followers.js create mode 100644 src/api/endpoints/users/following.js create mode 100644 src/api/endpoints/users/posts.js create mode 100644 src/api/endpoints/users/recommendation.js create mode 100644 src/api/endpoints/users/search.js create mode 100644 src/api/endpoints/users/search_by_username.js create mode 100644 src/api/endpoints/users/show.js create mode 100644 src/api/event.ts create mode 100644 src/api/limitter.ts create mode 100644 src/api/models/app.ts create mode 100644 src/api/models/appdata.ts create mode 100644 src/api/models/auth-session.ts create mode 100644 src/api/models/drive-file.ts create mode 100644 src/api/models/drive-folder.ts create mode 100644 src/api/models/drive-tag.ts create mode 100644 src/api/models/favorite.ts create mode 100644 src/api/models/following.ts create mode 100644 src/api/models/like.ts create mode 100644 src/api/models/messaging-history.ts create mode 100644 src/api/models/messaging-message.ts create mode 100644 src/api/models/notification.ts create mode 100644 src/api/models/post.ts create mode 100644 src/api/models/signin.ts create mode 100644 src/api/models/user.ts create mode 100644 src/api/models/userkey.ts create mode 100644 src/api/private/signin.ts create mode 100644 src/api/private/signup.ts create mode 100644 src/api/reply.ts create mode 100644 src/api/serializers/app.ts create mode 100644 src/api/serializers/auth-session.ts create mode 100644 src/api/serializers/drive-file.ts create mode 100644 src/api/serializers/drive-folder.ts create mode 100644 src/api/serializers/drive-tag.ts create mode 100644 src/api/serializers/messaging-message.ts create mode 100644 src/api/serializers/notification.ts create mode 100644 src/api/serializers/post.ts create mode 100644 src/api/serializers/signin.ts create mode 100644 src/api/serializers/user.ts create mode 100644 src/api/server.ts create mode 100644 src/api/stream/home.ts create mode 100644 src/api/stream/messaging.ts create mode 100644 src/api/streaming.ts create mode 100644 src/common/text/elements/bold.js create mode 100644 src/common/text/elements/hashtag.js create mode 100644 src/common/text/elements/mention.js create mode 100644 src/common/text/elements/url.js create mode 100644 src/common/text/index.js create mode 100644 src/config.ts create mode 100644 src/db/elasticsearch.ts create mode 100644 src/db/mongodb.ts create mode 100644 src/db/redis.ts create mode 100644 src/file/resources/avatar.jpg create mode 100644 src/file/resources/bad-egg.png create mode 100644 src/file/resources/dummy.png create mode 100644 src/file/server.ts create mode 100644 src/himasaku/resources/himasaku.png create mode 100644 src/himasaku/resources/index.html create mode 100644 src/himasaku/server.ts create mode 100644 src/index.d.ts create mode 100644 src/index.ts create mode 100644 src/server.ts create mode 100644 src/utils/check-dependencies.ts create mode 100644 src/utils/cli/indicator.ts create mode 100644 src/utils/cli/progressbar.ts create mode 100644 src/web/about/base.pug create mode 100644 src/web/about/pages/api/entities/post.pug create mode 100644 src/web/about/pages/api/entities/user.pug create mode 100644 src/web/about/pages/api/getting-started.pug create mode 100644 src/web/about/pages/api/library.pug create mode 100644 src/web/about/pages/license.pug create mode 100644 src/web/about/resources/style.css create mode 100644 src/web/app/auth/resources/logo.svg create mode 100644 src/web/app/auth/script.js create mode 100644 src/web/app/auth/style.styl create mode 100644 src/web/app/auth/tags.ls create mode 100644 src/web/app/auth/tags/form.tag create mode 100644 src/web/app/auth/tags/index.tag create mode 100644 src/web/app/auth/view.pug create mode 100644 src/web/app/base.pug create mode 100644 src/web/app/base.styl create mode 100644 src/web/app/boot.ls create mode 100644 src/web/app/client/script.js create mode 100644 src/web/app/client/view.pug create mode 100644 src/web/app/common/mixins.ls create mode 100644 src/web/app/common/pages/about/base.pug create mode 100644 src/web/app/common/pages/about/pages/staff.pug create mode 100644 src/web/app/common/scripts/api.ls create mode 100644 src/web/app/common/scripts/bytes-to-size.js create mode 100644 src/web/app/common/scripts/check-for-update.ls create mode 100644 src/web/app/common/scripts/date-stringify.ls create mode 100644 src/web/app/common/scripts/generate-default-userdata.ls create mode 100644 src/web/app/common/scripts/get-post-summary.ls create mode 100644 src/web/app/common/scripts/i.ls create mode 100644 src/web/app/common/scripts/is-promise.ls create mode 100644 src/web/app/common/scripts/loading.ls create mode 100644 src/web/app/common/scripts/log.ls create mode 100644 src/web/app/common/scripts/messaging-stream.ls create mode 100644 src/web/app/common/scripts/signout.ls create mode 100644 src/web/app/common/scripts/stream.ls create mode 100644 src/web/app/common/scripts/text-compiler.js create mode 100644 src/web/app/common/scripts/uuid.js create mode 100644 src/web/app/common/tags.ls create mode 100644 src/web/app/common/tags/copyright.tag create mode 100644 src/web/app/common/tags/core-error.tag create mode 100644 src/web/app/common/tags/ellipsis.tag create mode 100644 src/web/app/common/tags/file-type-icon.tag create mode 100644 src/web/app/common/tags/forkit.tag create mode 100644 src/web/app/common/tags/introduction.tag create mode 100644 src/web/app/common/tags/number.tag create mode 100644 src/web/app/common/tags/raw.tag create mode 100644 src/web/app/common/tags/ripple-string.tag create mode 100644 src/web/app/common/tags/signin.tag create mode 100644 src/web/app/common/tags/signup.tag create mode 100644 src/web/app/common/tags/special-message.tag create mode 100644 src/web/app/common/tags/time.tag create mode 100644 src/web/app/common/tags/uploader.tag create mode 100644 src/web/app/common/tags/url-preview.tag create mode 100644 src/web/app/common/tags/url.tag create mode 100644 src/web/app/desktop/mixins.ls create mode 100644 src/web/app/desktop/resources/header-logo.svg create mode 100644 src/web/app/desktop/resources/remove.png create mode 100644 src/web/app/desktop/router.ls create mode 100644 src/web/app/desktop/script.js create mode 100644 src/web/app/desktop/scripts/autocomplete.ls create mode 100644 src/web/app/desktop/scripts/dialog.ls create mode 100644 src/web/app/desktop/scripts/follow-scroll.ls create mode 100644 src/web/app/desktop/scripts/fuck-ad-block.ls create mode 100644 src/web/app/desktop/scripts/input-dialog.ls create mode 100644 src/web/app/desktop/scripts/notify.ls create mode 100644 src/web/app/desktop/scripts/open-window.ls create mode 100644 src/web/app/desktop/scripts/stream.ls create mode 100644 src/web/app/desktop/scripts/update-avatar.ls create mode 100644 src/web/app/desktop/scripts/update-banner.ls create mode 100644 src/web/app/desktop/scripts/update-wallpaper.ls create mode 100644 src/web/app/desktop/scripts/user-preview.ls create mode 100644 src/web/app/desktop/style.styl create mode 100644 src/web/app/desktop/tags.ls create mode 100644 src/web/app/desktop/tags/analog-clock.tag create mode 100644 src/web/app/desktop/tags/autocomplete-suggestion.tag create mode 100644 src/web/app/desktop/tags/big-follow-button.tag create mode 100644 src/web/app/desktop/tags/contextmenu.tag create mode 100644 src/web/app/desktop/tags/crop-window.tag create mode 100644 src/web/app/desktop/tags/debugger.tag create mode 100644 src/web/app/desktop/tags/detect-slow-internet-connection-notice.tag create mode 100644 src/web/app/desktop/tags/dialog.tag create mode 100644 src/web/app/desktop/tags/donation.tag create mode 100644 src/web/app/desktop/tags/drive/base-contextmenu.tag create mode 100644 src/web/app/desktop/tags/drive/browser-window.tag create mode 100644 src/web/app/desktop/tags/drive/browser.tag create mode 100644 src/web/app/desktop/tags/drive/file-contextmenu.tag create mode 100644 src/web/app/desktop/tags/drive/file.tag create mode 100644 src/web/app/desktop/tags/drive/folder-contextmenu.tag create mode 100644 src/web/app/desktop/tags/drive/folder.tag create mode 100644 src/web/app/desktop/tags/drive/nav-folder.tag create mode 100644 src/web/app/desktop/tags/ellipsis-icon.tag create mode 100644 src/web/app/desktop/tags/follow-button.tag create mode 100644 src/web/app/desktop/tags/following-setuper.tag create mode 100644 src/web/app/desktop/tags/go-top.tag create mode 100644 src/web/app/desktop/tags/home-widgets/broadcast.tag create mode 100644 src/web/app/desktop/tags/home-widgets/calendar.tag create mode 100644 src/web/app/desktop/tags/home-widgets/donation.tag create mode 100644 src/web/app/desktop/tags/home-widgets/mentions.tag create mode 100644 src/web/app/desktop/tags/home-widgets/nav.tag create mode 100644 src/web/app/desktop/tags/home-widgets/notifications.tag create mode 100644 src/web/app/desktop/tags/home-widgets/photo-stream.tag create mode 100644 src/web/app/desktop/tags/home-widgets/profile.tag create mode 100644 src/web/app/desktop/tags/home-widgets/rss-reader.tag create mode 100644 src/web/app/desktop/tags/home-widgets/timeline.tag create mode 100644 src/web/app/desktop/tags/home-widgets/tips.tag create mode 100644 src/web/app/desktop/tags/home-widgets/user-recommendation.tag create mode 100644 src/web/app/desktop/tags/home.tag create mode 100644 src/web/app/desktop/tags/image-dialog.tag create mode 100644 src/web/app/desktop/tags/images-viewer.tag create mode 100644 src/web/app/desktop/tags/input-dialog.tag create mode 100644 src/web/app/desktop/tags/list-user.tag create mode 100644 src/web/app/desktop/tags/log-window.tag create mode 100644 src/web/app/desktop/tags/log.tag create mode 100644 src/web/app/desktop/tags/messaging/form.tag create mode 100644 src/web/app/desktop/tags/messaging/index.tag create mode 100644 src/web/app/desktop/tags/messaging/message.tag create mode 100644 src/web/app/desktop/tags/messaging/room-window.tag create mode 100644 src/web/app/desktop/tags/messaging/room.tag create mode 100644 src/web/app/desktop/tags/messaging/window.tag create mode 100644 src/web/app/desktop/tags/notifications.tag create mode 100644 src/web/app/desktop/tags/pages/entrance.tag create mode 100644 src/web/app/desktop/tags/pages/entrance/signin.tag create mode 100644 src/web/app/desktop/tags/pages/entrance/signup.tag create mode 100644 src/web/app/desktop/tags/pages/home.tag create mode 100644 src/web/app/desktop/tags/pages/not-found.tag create mode 100644 src/web/app/desktop/tags/pages/post.tag create mode 100644 src/web/app/desktop/tags/pages/search.tag create mode 100644 src/web/app/desktop/tags/pages/user.tag create mode 100644 src/web/app/desktop/tags/post-detail-sub.tag create mode 100644 src/web/app/desktop/tags/post-detail.tag create mode 100644 src/web/app/desktop/tags/post-form-window.tag create mode 100644 src/web/app/desktop/tags/post-form.tag create mode 100644 src/web/app/desktop/tags/post-preview.tag create mode 100644 src/web/app/desktop/tags/post-status-graph.tag create mode 100644 src/web/app/desktop/tags/progress-dialog.tag create mode 100644 src/web/app/desktop/tags/repost-form-window.tag create mode 100644 src/web/app/desktop/tags/repost-form.tag create mode 100644 src/web/app/desktop/tags/search-posts.tag create mode 100644 src/web/app/desktop/tags/search.tag create mode 100644 src/web/app/desktop/tags/select-file-from-drive-window.tag create mode 100644 src/web/app/desktop/tags/set-avatar-suggestion.tag create mode 100644 src/web/app/desktop/tags/set-banner-suggestion.tag create mode 100644 src/web/app/desktop/tags/settings-window.tag create mode 100644 src/web/app/desktop/tags/settings.tag create mode 100644 src/web/app/desktop/tags/signin-history.tag create mode 100644 src/web/app/desktop/tags/stream-indicator.tag create mode 100644 src/web/app/desktop/tags/sub-post-content.tag create mode 100644 src/web/app/desktop/tags/timeline-post-sub.tag create mode 100644 src/web/app/desktop/tags/timeline-post.tag create mode 100644 src/web/app/desktop/tags/timeline.tag create mode 100644 src/web/app/desktop/tags/ui-header-account.tag create mode 100644 src/web/app/desktop/tags/ui-header-clock.tag create mode 100644 src/web/app/desktop/tags/ui-header-nav.tag create mode 100644 src/web/app/desktop/tags/ui-header-notifications.tag create mode 100644 src/web/app/desktop/tags/ui-header-post-button.tag create mode 100644 src/web/app/desktop/tags/ui-header-search.tag create mode 100644 src/web/app/desktop/tags/ui-header.tag create mode 100644 src/web/app/desktop/tags/ui-notification.tag create mode 100644 src/web/app/desktop/tags/ui.tag create mode 100644 src/web/app/desktop/tags/user-followers-window.tag create mode 100644 src/web/app/desktop/tags/user-followers.tag create mode 100644 src/web/app/desktop/tags/user-following-window.tag create mode 100644 src/web/app/desktop/tags/user-following.tag create mode 100644 src/web/app/desktop/tags/user-friends-graph.tag create mode 100644 src/web/app/desktop/tags/user-graphs.tag create mode 100644 src/web/app/desktop/tags/user-header.tag create mode 100644 src/web/app/desktop/tags/user-home.tag create mode 100644 src/web/app/desktop/tags/user-likes-graph.tag create mode 100644 src/web/app/desktop/tags/user-photos.tag create mode 100644 src/web/app/desktop/tags/user-posts-graph.tag create mode 100644 src/web/app/desktop/tags/user-preview.tag create mode 100644 src/web/app/desktop/tags/user-profile.tag create mode 100644 src/web/app/desktop/tags/user-timeline.tag create mode 100644 src/web/app/desktop/tags/user.tag create mode 100644 src/web/app/desktop/tags/users-list.tag create mode 100644 src/web/app/desktop/tags/window.tag create mode 100644 src/web/app/dev/router.ls create mode 100644 src/web/app/dev/script.js create mode 100644 src/web/app/dev/style.styl create mode 100644 src/web/app/dev/tags.ls create mode 100644 src/web/app/dev/tags/new-app-form.tag create mode 100644 src/web/app/dev/tags/pages/app.tag create mode 100644 src/web/app/dev/tags/pages/apps.tag create mode 100644 src/web/app/dev/tags/pages/index.tag create mode 100644 src/web/app/dev/tags/pages/new-app.tag create mode 100644 src/web/app/dev/view.pug create mode 100644 src/web/app/init.styl create mode 100644 src/web/app/mobile/mixins.ls create mode 100644 src/web/app/mobile/router.ls create mode 100644 src/web/app/mobile/script.js create mode 100644 src/web/app/mobile/scripts/sp-slidemenu.js create mode 100644 src/web/app/mobile/scripts/stream.ls create mode 100644 src/web/app/mobile/scripts/ui.ls create mode 100644 src/web/app/mobile/style.styl create mode 100644 src/web/app/mobile/tags.ls create mode 100644 src/web/app/mobile/tags/drive-selector.tag create mode 100644 src/web/app/mobile/tags/drive.tag create mode 100644 src/web/app/mobile/tags/drive/file-viewer.tag create mode 100644 src/web/app/mobile/tags/drive/file.tag create mode 100644 src/web/app/mobile/tags/drive/folder.tag create mode 100644 src/web/app/mobile/tags/follow-button.tag create mode 100644 src/web/app/mobile/tags/home-timeline.tag create mode 100644 src/web/app/mobile/tags/home.tag create mode 100644 src/web/app/mobile/tags/images-viewer.tag create mode 100644 src/web/app/mobile/tags/notification-preview.tag create mode 100644 src/web/app/mobile/tags/notification.tag create mode 100644 src/web/app/mobile/tags/notifications.tag create mode 100644 src/web/app/mobile/tags/notify.tag create mode 100644 src/web/app/mobile/tags/page/drive.tag create mode 100644 src/web/app/mobile/tags/page/entrance.tag create mode 100644 src/web/app/mobile/tags/page/entrance/signin.tag create mode 100644 src/web/app/mobile/tags/page/entrance/signup.tag create mode 100644 src/web/app/mobile/tags/page/home.tag create mode 100644 src/web/app/mobile/tags/page/new-post.tag create mode 100644 src/web/app/mobile/tags/page/notifications.tag create mode 100644 src/web/app/mobile/tags/page/post.tag create mode 100644 src/web/app/mobile/tags/page/search.tag create mode 100644 src/web/app/mobile/tags/page/user-followers.tag create mode 100644 src/web/app/mobile/tags/page/user-following.tag create mode 100644 src/web/app/mobile/tags/page/user.tag create mode 100644 src/web/app/mobile/tags/post-detail.tag create mode 100644 src/web/app/mobile/tags/post-form.tag create mode 100644 src/web/app/mobile/tags/post-preview.tag create mode 100644 src/web/app/mobile/tags/search-posts.tag create mode 100644 src/web/app/mobile/tags/search.tag create mode 100644 src/web/app/mobile/tags/stream-indicator.tag create mode 100644 src/web/app/mobile/tags/sub-post-content.tag create mode 100644 src/web/app/mobile/tags/timeline-post-sub.tag create mode 100644 src/web/app/mobile/tags/timeline-post.tag create mode 100644 src/web/app/mobile/tags/timeline.tag create mode 100644 src/web/app/mobile/tags/ui-header.tag create mode 100644 src/web/app/mobile/tags/ui-nav.tag create mode 100644 src/web/app/mobile/tags/ui.tag create mode 100644 src/web/app/mobile/tags/user-followers.tag create mode 100644 src/web/app/mobile/tags/user-following.tag create mode 100644 src/web/app/mobile/tags/user-preview.tag create mode 100644 src/web/app/mobile/tags/user-timeline.tag create mode 100644 src/web/app/mobile/tags/user.tag create mode 100644 src/web/app/mobile/tags/users-list.tag create mode 100644 src/web/app/reset.styl create mode 100644 src/web/apple-touch-icon.ts create mode 100644 src/web/manifest.ts create mode 100644 src/web/meta.ts create mode 100644 src/web/serve-app.ts create mode 100644 src/web/server.ts create mode 100644 src/web/service/proxy/proxy.ts create mode 100644 src/web/service/proxy/server.ts create mode 100644 src/web/service/rss-proxy.ts create mode 100644 src/web/service/url-preview.ts create mode 100644 tsconfig.json create mode 100644 tslint.json create mode 100644 update.sh diff --git a/.ci-files/config.yml b/.ci-files/config.yml new file mode 100644 index 0000000000..1875748d68 --- /dev/null +++ b/.ci-files/config.yml @@ -0,0 +1,26 @@ +maintainer: '@syuilo' +url: 'https://misskey.xyz' +secondary_url: 'https://himasaku.net' +port: 80 +https: + enable: false + key: null + cert: null + ca: null +mongodb: + host: localhost + port: 27017 + db: misskey + user: syuilo + pass: '' +redis: + host: localhost + port: 6379 + pass: '' +elasticsearch: + host: localhost + port: 9200 + pass: '' +recaptcha: + siteKey: hima + secretKey: saku diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000..952d6cd0e9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +*.svg -diff -text +*.psd -diff -text +*.ai -diff -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..83620b66fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/.config +/.vscode +/node_modules +/built +npm-debug.log diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..1490a8c83b --- /dev/null +++ b/.travis.yml @@ -0,0 +1,8 @@ +language: node_js +node_js: + - "7.3.0" +before_script: + - "mkdir -p ./.config && cp ./.ci-files/config.yml ./.config" +cache: + directories: + - node_modules diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000..6236d2d77c --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2014-2016 syuilo + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000000..626d6da06d --- /dev/null +++ b/README.md @@ -0,0 +1,44 @@ +# Misskey + +[![][travis-badge]][travis-link] +[![][dependencies-badge]][dependencies-link] +[![][mit-badge]][mit] + +A miniblog-based SNS. + +## Dependencies +* Node.js +* MongoDB +* Redis +* GraphicsMagick + +## Optional dependencies +* Elasticsearch + +## Get started +Misskey requires two domains called the primary domain and the secondary domain. + +* The primary domain is used to provide main service of Misskey. +* The secondary domain is used to avoid vulnerabilities such as XSS. + +**Ensure that the secondary domain is not a subdomain of the primary domain.** + +## Build +1. `git clone git://github.com/syuilo/misskey.git` +2. `cd misskey` +3. `npm install` +4. `npm run config` +5. `npm run build` + +## Launch +`npm start` + +## License +[MIT](LICENSE) + +[mit]: http://opensource.org/licenses/MIT +[mit-badge]: https://img.shields.io/badge/license-MIT-444444.svg?style=flat-square +[travis-link]: https://travis-ci.org/syuilo/misskey +[travis-badge]: http://img.shields.io/travis/syuilo/misskey.svg?style=flat-square +[dependencies-link]: https://gemnasium.com/syuilo/misskey +[dependencies-badge]: https://img.shields.io/gemnasium/syuilo/misskey.svg?style=flat-square diff --git a/elasticsearch/README.md b/elasticsearch/README.md new file mode 100644 index 0000000000..c7fcb245f0 --- /dev/null +++ b/elasticsearch/README.md @@ -0,0 +1,6 @@ +How to create indexes +===================== + +``` shell +curl -XPOST localhost:9200/misskey -d @path/to/mappings.json +``` diff --git a/elasticsearch/mappings.json b/elasticsearch/mappings.json new file mode 100644 index 0000000000..654ab17450 --- /dev/null +++ b/elasticsearch/mappings.json @@ -0,0 +1,65 @@ +{ + "settings": { + "analysis": { + "analyzer": { + "bigram": { + "tokenizer": "bigram_tokenizer" + } + }, + "tokenizer": { + "bigram_tokenizer": { + "type": "nGram", + "min_gram": 2, + "max_gram": 2, + "token_chars": [ + "letter", + "digit" + ] + } + } + } + }, + "mappings": { + "user": { + "properties": { + "username": { + "type": "string", + "index": "analyzed", + "analyzer": "bigram" + }, + "name": { + "type": "string", + "index": "analyzed", + "analyzer": "bigram" + }, + "bio": { + "type": "string", + "index": "analyzed", + "analyzer": "kuromoji" + } + } + }, + "post": { + "properties": { + "text": { + "type": "string", + "index": "analyzed", + "analyzer": "kuromoji" + } + } + }, + "drive_file": { + "properties": { + "name": { + "type": "string", + "index": "analyzed", + "analyzer": "kuromoji" + }, + "user": { + "type": "string", + "index": "not_analyzed" + } + } + } + } +} diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000000..5bd947f72d --- /dev/null +++ b/gulpfile.js @@ -0,0 +1 @@ +eval(require('typescript').transpile(require('fs').readFileSync('./gulpfile.ts').toString())); diff --git a/gulpfile.ts b/gulpfile.ts new file mode 100644 index 0000000000..b01c5f829e --- /dev/null +++ b/gulpfile.ts @@ -0,0 +1,568 @@ +/** + * Gulp tasks + */ + +import * as gulp from 'gulp'; +import * as gutil from 'gulp-util'; +import * as babel from 'gulp-babel'; +import * as ts from 'gulp-typescript'; +import * as tslint from 'gulp-tslint'; +import * as glob from 'glob'; +import * as browserify from 'browserify'; +import * as source from 'vinyl-source-stream'; +import * as buffer from 'vinyl-buffer'; +import * as es from 'event-stream'; +const stylus = require('gulp-stylus'); +const cssnano = require('gulp-cssnano'); +import * as uglify from 'gulp-uglify'; +const ls = require('browserify-livescript'); +const aliasify = require('aliasify'); +const riotify = require('riotify'); +const transformify = require('syuilo-transformify'); +const pug = require('gulp-pug'); +const git = require('git-last-commit'); +import * as rimraf from 'rimraf'; + +const env = process.env.NODE_ENV; +const isProduction = env === 'production'; +const isDebug = !isProduction; + +import { IConfig } from './src/config'; +const config = eval(require('typescript').transpile(require('fs').readFileSync('./src/config.ts').toString())) + ('.config/config.yml') as IConfig; + +const project = ts.createProject('tsconfig.json'); + +gulp.task('build', [ + 'build:js', + 'build:ts', + 'build:copy', + 'build:client' +]); + +gulp.task('rebuild', [ + 'clean', + 'build' +]); + +gulp.task('build:js', () => + gulp.src(['./src/**/*.js', '!./src/web/**/*.js']) + .pipe(babel({ + presets: ['es2015', 'stage-3'] + })) + .pipe(gulp.dest('./built/')) +); + +gulp.task('build:ts', () => + project + .src() + .pipe(project()) + .pipe(babel({ + presets: ['es2015', 'stage-3'] + })) + .pipe(gulp.dest('./built/')) +); + +gulp.task('build:copy', () => { + gulp.src([ + './src/**/resources/**/*', + '!./src/web/app/**/resources/**/*' + ]).pipe(gulp.dest('./built/')); + gulp.src([ + './src/web/about/**/*' + ]).pipe(gulp.dest('./built/web/about/')); +}); + +gulp.task('test', ['lint', 'build']); + +gulp.task('lint', () => + gulp.src('./src/**/*.ts') + .pipe(tslint({ + formatter: 'verbose' + })) + .pipe(tslint.report()) +); + +gulp.task('clean', cb => + rimraf('./built', cb) +); + +gulp.task('cleanall', ['clean'], cb => + rimraf('./node_modules', cb) +); + +gulp.task('default', ['build']); + +const aliasifyConfig = { + aliases: { + 'fetch': './node_modules/whatwg-fetch/fetch.js', + 'page': './node_modules/page/page.js', + 'NProgress': './node_modules/nprogress/nprogress.js', + 'velocity': './node_modules/velocity-animate/velocity.js', + 'chart.js': './node_modules/chart.js/src/chart.js', + 'textarea-caret-position': './node_modules/textarea-caret/index.js', + 'misskey-text': './src/common/text/index.js', + 'strength.js': './node_modules/syuilo-password-strength/strength.js', + 'cropper': './node_modules/cropperjs/dist/cropper.js', + 'Sortable': './node_modules/sortablejs/Sortable.js', + 'fuck-adblock': './node_modules/fuckadblock/fuckadblock.js', + 'reconnecting-websocket': './node_modules/reconnecting-websocket/dist/index.js' + }, + appliesTo: { + 'includeExtensions': ['.js', '.ls'] + } +}; + +gulp.task('build:client', [ + 'build:ts', 'build:js', + 'build:client:scripts', + 'build:client:styles', + 'build:client:pug', + 'copy:client' +], () => { + gutil.log('ビルドが終了しました。'); + + if (isDebug) { + gutil.log('■ 注意! 開発モードでのビルドです。'); + } +}); + +gulp.task('build:client:scripts', done => { + gutil.log('スクリプトを構築します...'); + + // Get commit info + git.getLastCommit((err, commit) => { + glob('./src/web/app/*/script.js', (err, files) => { + const tasks = files.map(entry => { + let bundle = + browserify({ + entries: [entry] + }) + .transform(ls) + .transform(aliasify, aliasifyConfig) + + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + console.log(file); + return source; + })) + + // tagの{}の''を不要にする (その代わりスタイルの記法は使えなくなるけど) + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + + const tag = new Tag(source); + const html = tag.sections.filter(s => s.name == 'html')[0]; + + html.lines = html.lines.map(line => { + if (line.replace(/\t/g, '')[0] === '|') { + return line; + } else { + return line.replace(/([+=])\s?\{(.+?)\}/g, '$1"{$2}"'); + } + }); + + const styles = tag.sections.filter(s => s.name == 'style'); + + if (styles.length == 0) { + return tag.compile(); + } + + styles.forEach(style => { + let head = style.lines.shift(); + head = head.replace(/([+=])\s?\{(.+?)\}/g, '$1"{$2}"'); + style.lines.unshift(head); + }); + + return tag.compile(); + })) + + // tagの@hogeをref='hoge'にする + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + + const tag = new Tag(source); + const html = tag.sections.filter(s => s.name == 'html')[0]; + + html.lines = html.lines.map(line => { + if (line.indexOf('@') === -1) { + return line; + } else if (line.replace(/\t/g, '')[0] === '|') { + return line; + } else { + while (line.match(/[^\s']@[a-z-]+/) !== null) { + const match = line.match(/@[a-z-]+/); + let name = match[0]; + if (line[line.indexOf(name) + name.length] === '(') { + line = line.replace(name + '(', '(ref=\'' + camelCase(name.substr(1)) + '\','); + } else { + line = line.replace(name, '(ref=\'' + camelCase(name.substr(1)) + '\')'); + } + } + return line; + } + }); + + return tag.compile(); + + function camelCase(str): string { + return str.replace(/-([^\s])/g, (match, group1) => { + return group1.toUpperCase(); + }); + } + })) + + // tagのchain-caseをcamelCaseにする + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + + const tag = new Tag(source); + const html = tag.sections.filter(s => s.name == 'html')[0]; + + html.lines = html.lines.map(line => { + (line.match(/\{.+?\}/g) || []).forEach(x => { + line = line.replace(x, camelCase(x)); + }); + return line; + }); + + return tag.compile(); + + function camelCase(str): string { + str = str.replace(/([a-z\-]+):/g, (match, group1) => { + return group1.replace(/\-/g, '###') + ':'; + }); + str = str.replace(/'(.+?)'/g, (match, group1) => { + return "'" + group1.replace(/\-/g, '###') + "'"; + }); + str = str.replace(/-([^\s0-9])/g, (match, group1) => { + return group1.toUpperCase(); + }); + str = str.replace(/###/g, '-'); + + return str; + } + })) + + // tagのstyleの属性 + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + + const tag = new Tag(source); + + const styles = tag.sections.filter(s => s.name == 'style'); + + if (styles.length == 0) { + return tag.compile(); + } + + styles.forEach(style => { + let head = style.lines.shift(); + if (style.attr) { + style.attr = style.attr + ', type=\'stylus\', scoped'; + } else { + style.attr = 'type=\'stylus\', scoped'; + } + style.lines.unshift(head); + }); + + return tag.compile(); + })) + + // tagのstyleの定数 + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + + const tag = new Tag(source); + + const styles = tag.sections.filter(s => s.name == 'style'); + + if (styles.length == 0) { + return tag.compile(); + } + + styles.forEach(style => { + const head = style.lines.shift(); + style.lines.unshift('$theme-color = ' + config.themeColor); + style.lines.unshift('$theme-color-foreground = #fff'); + style.lines.unshift(head); + }); + + return tag.compile(); + })) + + // tagのstyleを暗黙的に:scopeにする + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + + const tag = new Tag(source); + + const styles = tag.sections.filter(s => s.name == 'style'); + + if (styles.length == 0) { + return tag.compile(); + } + + styles.forEach((style, i) => { + if (i != 0) { + return; + } + const head = style.lines.shift(); + style.lines = style.lines.map(line => { + return '\t' + line; + }); + style.lines.unshift(':scope'); + style.lines.unshift(head); + }); + + return tag.compile(); + })) + + // tagのtheme styleのパース + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + + const tag = new Tag(source); + + const styles = tag.sections.filter(s => s.name == 'style'); + + if (styles.length == 0) { + return tag.compile(); + } + + styles.forEach((style, i) => { + if (i == 0) { + return; + } else if (style.attr.substr(0, 6) != 'theme=') { + return; + } + const head = style.lines.shift(); + style.lines = style.lines.map(line => { + return '\t' + line; + }); + style.lines.unshift(':scope'); + style.lines = style.lines.map(line => { + return '\t' + line; + }); + style.lines.unshift('html[data-' + style.attr.match(/theme='(.+?)'/)[0] + ']'); + style.lines.unshift(head); + }); + + return tag.compile(); + })) + + // tagのstyleおよびscriptのインデントを不要にする + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + const tag = new Tag(source); + + tag.sections = tag.sections.map(section => { + if (section.name != 'html') { + section.indent++; + } + return section; + }); + + return tag.compile(); + })) + + // スペースでインデントされてないとエラーが出る + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + return source.replace(/\t/g, ' '); + })) + + .transform(transformify((source, file) => { + return source + .replace(/VERSION/g, `'${commit ? commit.hash : 'null'}'`) + .replace(/CONFIG\.theme-color/g, `'${config.themeColor}'`) + .replace(/CONFIG\.themeColor/g, `'${config.themeColor}'`) + .replace(/CONFIG\.api\.url/g, `'${config.scheme}://api.${config.host}'`) + .replace(/CONFIG\.urls\.about/g, `'${config.scheme}://about.${config.host}'`) + .replace(/CONFIG\.urls\.dev/g, `'${config.scheme}://dev.${config.host}'`) + .replace(/CONFIG\.url/g, `'${config.url}'`) + .replace(/CONFIG\.host/g, `'${config.host}'`) + .replace(/CONFIG\.recaptcha\.siteKey/g, `'${config.recaptcha.siteKey}'`) + ; + })) + + .transform(riotify, { + template: 'pug', + type: 'livescript', + expr: false, + compact: true, + parserOptions: { + style: { + compress: true, + rawDefine: config + } + } + }) + // Riotが謎の空白を挿入する + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + return source.replace(/\s/g, ''); + })) + /* + // LiveScruptがHTMLクラスのショートカットを変な風に生成するのでそれを修正 + .transform(transformify((source, file) => { + if (file.substr(-4) !== '.tag') return source; + return source.replace(/class="\{\(\{(.+?)\}\)\}"/g, 'class="{$1}"'); + }))*/ + .bundle() + .pipe(source(entry.replace('./src/web/app/', './').replace('.ls', '.js'))); + + if (isProduction) { + bundle = bundle + .pipe(buffer()) + // ↓ https://github.com/mishoo/UglifyJS2/issues/448 + .pipe(babel({ + presets: ['es2015'] + })) + .pipe(uglify({ + compress: true + })); + } + + return bundle + .pipe(gulp.dest('./built/web/resources/')); + }); + + es.merge(tasks).on('end', done); + }); + }); +}); + +gulp.task('build:client:styles', () => { + gutil.log('フロントサイドスタイルを構築します...'); + + return gulp.src('./src/web/app/**/*.styl') + .pipe(stylus({ + 'include css': true, + compress: true, + rawDefine: config + })) + .pipe(isProduction + ? cssnano({ + safe: true // 高度な圧縮は無効にする (一部デザインが不適切になる場合があるため) + }) + : gutil.noop()) + .pipe(gulp.dest('./built/web/resources/')); +}); + +gulp.task('copy:client', [ + 'build:client:scripts', + 'build:client:styles' +], () => { + gutil.log('必要なリソースをコピーします...'); + + return es.merge( + gulp.src('./resources/**/*').pipe(gulp.dest('./built/web/resources/')), + gulp.src('./src/web/app/desktop/resources/**/*').pipe(gulp.dest('./built/web/resources/desktop/')), + gulp.src('./src/web/app/mobile/resources/**/*').pipe(gulp.dest('./built/web/resources/mobile/')), + gulp.src('./src/web/app/dev/resources/**/*').pipe(gulp.dest('./built/web/resources/dev/')), + gulp.src('./src/web/app/auth/resources/**/*').pipe(gulp.dest('./built/web/resources/auth/')) + ); +}); + +gulp.task('build:client:pug', [ + 'copy:client', + 'build:client:scripts', + 'build:client:styles' +], () => { + gutil.log('Pugをコンパイルします...'); + + return gulp.src([ + './src/web/app/*/view.pug' + ]) + .pipe(pug({ + locals: { + themeColor: config.themeColor + } + })) + .pipe(gulp.dest('./built/web/app/')); +}); + +class Tag { + sections: { + name: string; + attr?: string; + indent: number; + lines: string[]; + }[]; + + constructor(source) { + this.sections = []; + + source = source + .replace(/\r\n/g, '\n') + .replace(/\n(\t+?)\n/g, '\n') + .replace(/\n+/g, '\n'); + + const html = { + name: 'html', + indent: 0, + lines: [] + }; + + let flag = false; + source.split('\n').forEach((line, i) => { + const indent = line.lastIndexOf('\t') + 1; + if (i != 0 && indent == 0) { + flag = true; + } + if (!flag) { + source = source.replace(/^.*?\n/, ''); + html.lines.push(i == 0 ? line : line.substr(1)); + } + }); + + this.sections.push(html); + + while (source != '') { + const line = source.substr(0, source.indexOf('\n')); + const root = line.match(/^\t*([a-z]+)(\.|\()?/)[1]; + const beginIndent = line.lastIndexOf('\t') + 1; + flag = false; + const section = { + name: root, + attr: (line.match(/\((.+?)\)/) || [null, null])[1], + indent: beginIndent, + lines: [] + }; + source.split('\n').forEach((line, i) => { + const currentIndent = line.lastIndexOf('\t') + 1; + if (i != 0 && (currentIndent == beginIndent || currentIndent == 0)) { + flag = true; + } + if (!flag) { + if (i == 0 && line[line.length - 1] == '.') { + line = line.substr(0, line.length - 1); + } + if (i == 0 && line.indexOf('(') != -1) { + line = line.substr(0, line.indexOf('(')); + } + source = source.replace(/^.*?\n/, ''); + section.lines.push(i == 0 ? line.substr(beginIndent) : line.substr(beginIndent + 1)); + } + }); + this.sections.push(section); + } + } + + compile(): string { + let dist = ''; + this.sections.forEach((section, j) => { + dist += section.lines.map((line, i) => { + if (i == 0) { + const attr = section.attr != null ? '(' + section.attr + ')' : ''; + const tail = j != 0 ? '.' : ''; + return '\t'.repeat(section.indent) + line + attr + tail; + } else { + return '\t'.repeat(section.indent + 1) + line; + } + }).join('\n') + '\n'; + }); + return dist; + } +} diff --git a/init.js b/init.js new file mode 100644 index 0000000000..380ff6cb8b --- /dev/null +++ b/init.js @@ -0,0 +1,182 @@ +const fs = require('fs'); +const yaml = require('js-yaml'); +const inquirer = require('inquirer'); + +const configDirPath = `${__dirname}/.config`; +const configPath = `${configDirPath}/config.yml`; + +const form = [ + { + type: 'input', + name: 'maintainer', + message: 'Maintainer name(and email address):' + }, + { + type: 'input', + name: 'url', + message: 'PRIMARY URL:' + }, + { + type: 'input', + name: 'secondary_url', + message: 'SECONDARY URL:' + }, + { + type: 'input', + name: 'port', + message: 'Listen port:' + }, + { + type: 'confirm', + name: 'https', + message: 'Use TLS?', + default: false + }, + { + type: 'input', + name: 'https_key', + message: 'Path of tls key:', + when: ctx => ctx.https + }, + { + type: 'input', + name: 'https_cert', + message: 'Path of tls cert:', + when: ctx => ctx.https + }, + { + type: 'input', + name: 'https_ca', + message: 'Path of tls ca:', + when: ctx => ctx.https + }, + { + type: 'input', + name: 'mongo_host', + message: 'MongoDB\'s host:', + default: 'localhost' + }, + { + type: 'input', + name: 'mongo_port', + message: 'MongoDB\'s port:', + default: '27017' + }, + { + type: 'input', + name: 'mongo_db', + message: 'MongoDB\'s db:', + default: 'misskey' + }, + { + type: 'input', + name: 'mongo_user', + message: 'MongoDB\'s user:' + }, + { + type: 'password', + name: 'mongo_pass', + message: 'MongoDB\'s password:' + }, + { + type: 'input', + name: 'redis_host', + message: 'Redis\'s host:', + default: 'localhost' + }, + { + type: 'input', + name: 'redis_port', + message: 'Redis\'s port:', + default: '6379' + }, + { + type: 'password', + name: 'redis_pass', + message: 'Redis\'s password:' + }, + { + type: 'confirm', + name: 'elasticsearch', + message: 'Use Elasticsearch?', + default: false + }, + { + type: 'input', + name: 'es_host', + message: 'Elasticsearch\'s host:', + default: 'localhost', + when: ctx => ctx.elasticsearch + }, + { + type: 'input', + name: 'es_port', + message: 'Elasticsearch\'s port:', + default: '9200', + when: ctx => ctx.elasticsearch + }, + { + type: 'password', + name: 'es_pass', + message: 'Elasticsearch\'s password:', + when: ctx => ctx.elasticsearch + }, + { + type: 'input', + name: 'recaptcha_site', + message: 'reCAPTCHA\'s site key:' + }, + { + type: 'input', + name: 'recaptcha_secret', + message: 'reCAPTCHA\'s secret key:' + } +]; + +inquirer.prompt(form).then(as => { + // Mapping answers + const conf = { + maintainer: as['maintainer'], + url: as['url'], + secondary_url: as['secondary_url'], + port: parseInt(as['port'], 10), + https: { + enable: as['https'], + key: as['https_key'] || null, + cert: as['https_cert'] || null, + ca: as['https_ca'] || null + }, + mongodb: { + host: as['mongo_host'], + port: parseInt(as['mongo_port'], 10), + db: as['mongo_db'], + user: as['mongo_user'], + pass: as['mongo_pass'] + }, + redis: { + host: as['redis_host'], + port: parseInt(as['redis_port'], 10), + pass: as['redis_pass'] + }, + elasticsearch: { + enable: as['elasticsearch'], + host: as['es_host'] || null, + port: parseInt(as['es_port'], 10) || null, + pass: as['es_pass'] || null + }, + recaptcha: { + siteKey: as['recaptcha_site'], + secretKey: as['recaptcha_secret'] + } + }; + + console.log('Thanks. Writing the configuration to a file...'); + + try { + fs.mkdirSync(configDirPath); + fs.writeFileSync(configPath, yaml.dump(conf)); + console.log('Well done.'); + } catch (e) { + console.error(e); + } +}); diff --git a/jsconfig.json b/jsconfig.json new file mode 100644 index 0000000000..8d4204dbc2 --- /dev/null +++ b/jsconfig.json @@ -0,0 +1,14 @@ +{ + // Please visit https://go.microsoft.com/fwlink/?LinkId=759670 for more information about jsconfig.json + "compilerOptions": { + "target": "es6", + "module": "commonjs", + "allowSyntheticDefaultImports": true + }, + "exclude": [ + "node_modules", + "jspm_packages", + "tmp", + "temp" + ] +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..8d91857ff3 --- /dev/null +++ b/package.json @@ -0,0 +1,135 @@ +{ + "private": true, + "name": "misskey", + "version": "0.0.0", + "description": "A miniblog-based SNS", + "author": "syuilo ", + "license": "MIT", + "repository": "https://github.com/syuilo/misskey.git", + "bugs": "https://github.com/syuilo/misskey/issues", + "main": "./built/index.js", + "scripts": { + "config": "node ./init.js", + "start": "node ./built/index.js", + "build": "gulp build", + "rebuild": "gulp rebuild", + "clean": "gulp clean", + "cleanall": "gulp cleanall", + "lint": "gulp lint", + "test": "gulp test" + }, + "dependencies": { + "@types/bcrypt": "0.0.30", + "@types/body-parser": "0.0.33", + "@types/browserify": "12.0.30", + "@types/chalk": "0.4.31", + "@types/compression": "0.0.33", + "@types/cors": "0.0.33", + "@types/elasticsearch": "5.0.0", + "@types/event-stream": "3.3.30", + "@types/express": "4.0.34", + "@types/glob": "5.0.30", + "@types/gm": "1.17.29", + "@types/gulp": "3.8.32", + "@types/gulp-babel": "6.1.29", + "@types/gulp-tslint": "3.6.30", + "@types/gulp-typescript": "0.0.32", + "@types/gulp-uglify": "0.0.29", + "@types/gulp-util": "3.0.30", + "@types/inquirer": "0.0.31", + "@types/js-yaml": "3.5.28", + "@types/mongodb": "2.1.34", + "@types/ms": "0.7.29", + "@types/multer": "0.0.32", + "@types/ratelimiter": "2.1.28", + "@types/redis": "0.12.32", + "@types/request": "0.0.33", + "@types/rimraf": "0.0.28", + "@types/serve-favicon": "2.2.28", + "@types/shelljs": "0.3.32", + "@types/uuid": "2.0.29", + "@types/vinyl-buffer": "0.0.28", + "@types/vinyl-source-stream": "0.0.28", + "@types/websocket": "0.0.32", + "accesses": "1.2.0", + "aliasify": "2.1.0", + "argv": "0.0.2", + "babel-core": "6.20.0", + "babel-polyfill": "6.20.0", + "babel-preset-es2015": "6.18.0", + "babel-preset-stage-3": "6.17.0", + "bcrypt": "1.0.1", + "body-parser": "1.15.2", + "browserify": "13.1.1", + "browserify-livescript": "0.2.3", + "chalk": "1.1.3", + "chart.js": "2.4.0", + "compression": "1.6.2", + "cors": "2.8.1", + "cropperjs": "1.0.0-alpha", + "deepcopy": "0.6.3", + "del": "2.2.2", + "elasticsearch": "12.1.2", + "escape-regexp": "0.0.1", + "event-stream": "3.3.4", + "express": "4.14.0", + "file-type": "4.0.0", + "fuckadblock": "3.2.1", + "git-last-commit": "0.2.0", + "glob": "7.1.1", + "gm": "1.23.0", + "gulp": "3.9.1", + "gulp-babel": "6.1.2", + "gulp-cssnano": "2.1.2", + "gulp-livescript": "3.0.1", + "gulp-pug": "3.2.0", + "gulp-replace": "0.5.4", + "gulp-stylus": "2.6.0", + "gulp-tslint": "7.0.1", + "gulp-typescript": "3.1.3", + "gulp-uglify": "2.0.0", + "gulp-util": "3.0.7", + "inquirer": "2.0.0", + "js-yaml": "3.7.0", + "livescript": "1.5.0", + "log-cool": "1.1.0", + "mime-types": "2.1.13", + "mongodb": "2.2.16", + "ms": "0.7.2", + "multer": "1.2.0", + "nprogress": "0.2.0", + "page": "1.7.1", + "prominence": "0.2.0", + "pug": "2.0.0-beta6", + "ratelimiter": "2.1.3", + "recaptcha-promise": "0.1.2", + "reconnecting-websocket": "3.0.3", + "redis": "2.6.3", + "request": "2.79.0", + "rimraf": "2.5.4", + "riot": "3.0.5", + "riot-compiler": "3.1.1", + "riotify": "2.0.0", + "rndstr": "1.0.0", + "serve-favicon": "2.3.2", + "shelljs": "0.7.5", + "sortablejs": "1.5.0-rc1", + "subdomain": "1.2.0", + "summaly": "1.2.7", + "syuilo-password-strength": "0.0.1", + "syuilo-transformify": "0.1.2", + "tcp-port-used": "0.1.2", + "textarea-caret": "3.0.2", + "tslint": "4.0.2", + "typescript": "2.1.4", + "uuid": "3.0.1", + "velocity-animate": "1.4.0", + "vhost": "3.0.2", + "vinyl-buffer": "1.0.0", + "vinyl-source-stream": "1.1.0", + "websocket": "1.0.23", + "whatwg-fetch": "2.0.1", + "xml2json": "0.10.0", + "yargs": "6.5.0" + } +} diff --git a/resources/apple-touch-icon.png b/resources/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..1b1b1b3ecf60e1f68218451dd28837afcc5313ea GIT binary patch literal 1203 zcmeAS@N?(olHy`uVBq!ia0y~yU<5K58911L)MWvCLm(wt;u=vBoS#-wo>-L1;Fyx1 zl&avFo0y&&l$w}QS$HzlhJk@4)YHW=q~g|_JBEJJz7oe1g&#E;?|3SdmtonTe_-pK zfFCkDzDUL#vWn0U+9dh@+@3RM?D~T~gw`MZ{r6t)QjPNWv(GX;d)431z}Udxz@We& zz`()4!oWmzE^SPOx%dB{w@rIg|C;M(|KGR&6Qy_lzvZ6~KPrAD?%#g<@VfU5_IoC_&;D}v`oc}OzpP`Z zD|pEmQ!z05XvxUh_-(b z1C;33Z@k7N@mpIPVX=Da!fhAZJ!ah9$lK=KQn>Z#>mJLy@^ZrkH zo@@S0)xf5T(PjS9DeigZ|D;dUFk8qR`!iYk>8nlB+pK|7JwM&2zqxhx$-mUS|NgRl z`~7)(iU$}D=dNnhT-?zIEQJ_6UHx3vIVCg!0AXv#&Hw-a literal 0 HcmV?d00001 diff --git a/resources/favicon.ico b/resources/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..4fd944c3da2da7410da33bea1f06cc9752820de7 GIT binary patch literal 360414 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 literal 0 HcmV?d00001 diff --git a/resources/favicon/128.png b/resources/favicon/128.png new file mode 100644 index 0000000000000000000000000000000000000000..75b93cb1e1138c0921b3cb74bdad1813b72d59e6 GIT binary patch literal 1423 zcmV;A1#tR_P)}+?{7A_Y)AJe__ z*k=d;0000000000000000000000000004MH;rYk?`t-3MKd8Nrt-IC!g;i{2Y|Gf5 z>CeUIZwm+@4Z?d&_!)mT2@=5*eMUi)5P%)hJMm)}+hv8`-4w_>+{ZSHVpu@{I>Zy% z*G#@9X0hEyAuJ()yoh%pgd!p0J52nQ@mHO&5LOUCv)TMqFvd5LBaBk09t!mOyuCqT zn0)H(53%(UFI1>xys(-Z6h_G>(&w>toRr(-0R|Sw4~`6yKXgWZ;NLRm-v$q`hx}>q z4N4AY5I~;fn-f7{gnVx?Kbf4MFhD+K``QPBlNdg*4YI8Q zt{v3*bXcy}Iv!1=%;8v(G}qqyG%_b*s-J|I41tQ7*TLH=LpYK4MAq4(2| z{Q=H*;zYVCI8y5UfDzji=>2(nfg|+(yuCmn*!wB-{|SC8;aI97kn{jU@M_Oik^;B{ zul8&uA^@K4L*0MCxB%PCjR)w1Q=2xE62KYwv}v<<8F~O(gz>V;bH_W9(R(?0fY!@E zL$iKF{CMB28^7)nl=6TMc&%3cMfZQ~$pa8(;MJb3Bn9vVyxOysm;m@#)CIWR z3(;%oq-O(f!LKbFi3)&M*uo3^yZ0~L3IJZfo^8YhzzRo2up{%=I4)LeMG)<_S%LFE zb$?LWfutgU&E`w+{)&;-)pBZU0^<*UL@0Q5{c3Y1+KWQT?UfmmUk2UHr?@gkIqv6u zWekS`GQc3Sq;PX-)KcC4RxFay&0(f)5w&)4q<0aRyF%97-NBAUVDkQ8b6dmMKm%vh zK^cr*9&P#hc=7VP(*Of0`(k%ARDFCcG_$|aK} z_j>bTtVpRlMIqdT^;!B>6GU?J_mTLURBW1*09XXwu)bGkXzbNyvE8P2EKYI)*b+jY z!$Md2RhI1T7b)5?SpgWS6ijFM?gg+VgsDPEnAJIl7y@Ws1T=cs7y9pL@#esT-8>x< zb;0piBIaLsF`63z2re^AI_7)!0Gk!ZZH~N6QqA_W5CDVL*0<3^SHa<)26qBr_uR%h zyRj$N@2(2iQ57_KERD!bu2U%nx03N`h37jeeIWOP!IOvmI z8Mvb?+_&k(XAJJVzRx7AOio0CKq*R{nO-aah7A-w+{~ae`fKg##1k zEXf5~j#Ux~Y(E}vP!;I1PngL5I`LpKx5g23hrJmM+65ma6Zm!N;s6p5Tdy05$g?;$0iKZ5*KI+v`80fGI1C%Y!q>jNqWW* Q2lOz5r>mdKI;Vst0Dv86B?~=J4qs! z%H6`8LZT>Ii_EAr$!%`KE??&#IImxxugBx{dc2;G$LsQVzLK4(6m{%+EC4|LfTP_p z0H7ro0vMGg@I3!Ga0%8i?cJC(Mj$iF?|cBTImI{`KsXTMM-Mm_;CCt}tTn(I0IdH3 zJDcOtJ-_<;k7~EBQ5CUz<~{}z^vtNdHS5Tzt8~&@J&q&L77cTf{=>V6&Nm&|CfmjPDI-5sjrJIJCU$_B&Sc7 zBOL1RQ@A%-D>qxrbdcu5>t_bKQtOOCP?dmE)7dZFzHqpvmE`k*u7lk{u?L-J8`I7= zrdifz8lV(e`_PF%6o4i@Ce4O7=6Be1C@$H{!?TeCwHlX_at%Rr?F8+a?$86%1K5jC zEHNcI0T8e6!I5z3+)PLH75c&trb^esj|UnTYjRD&gPhU+sAy_KviU_dFBZ7!A{};0 z@F-S%^ei~0Z&q;8_lzqBB$;AREZ{6nB;zn}JYKO4uXrtto?YMg${da0Fp_dyBprpl zf5ds4L?HA_w%|bAHbwdK1oykj2;rl=L6Rhu1;i3}l9r;r?)~GD-%zy>Bq}P^CWG== z{t@BjpNA}Ft1C(AmhxYT)bASUeqG=HQRy0$!#Dr@c)1xVEls#r@~_?@14DTEeui>;1R* zf^NDp?7LQ<5#vrv3b-K_BT(f24#objnOf?bn9xc~Ku<`?z6yzcrI`>SRhNLMNd%IB z?01TyTr~k6$ftyYCbxwOy;4+r+Wf+qFV7vUw(Q$i)UCn$tm|)h4y2^IrWC-?4gA?J zlP$IC2R)Y?Jwxu^=|Zebr*-T3#*13|n+?<#av!Q*zQc#2yYbC#A`l&X;bh7%;H_nA zg87?1F3QtAgfw%gleo!$wSS0tLuOXeO_vh-Oh<-nUcigLONyXD%^n9J4l-I$uBh>l z7*v(T(h8^R<^(22QAJu|Ixr=k`d+6j^GaNCt!N2l9%b=%%@-r8^q=-?JhO?lFFxD# zDqk@Pl7B>G5Y*JeJf-bnz6y*k?maiszwp_Dv0q#OgHEX2@8k)OITz5$|C}Jao7&p@ zKn(|q*Q!{Dx>mLdRs$l^_kB}v=hQCaB|47#j^4}Q>IgezKM;en+6aJI55|x%59&s#R@g?+gP(lD+uwWY*D|=ec|LWo z*Pg}vkabo@2#)#`r)8|Wa&0VvQ>egxL6^rHFol;N)0GVb^cUv$3jQ?4`l2asyry&Bq7MpIPKepZv0p{zUuX-SzSXJWt2HQ>hw2~utL!AB-$dkNYB{q ztt4~gnVF-#(lDVXqd|}nF{$*!b{d~{9@oxFTmMa7=F?QijLP;e#%$?ZKCSz8mA`Q) zXiqtmQo&i;-%~@)Th+M+ev_rIGzc}j_Jz%+X#>}0{bm6+JNz`|M=#f6_hKHOks$)V`0+>OEi2#!nvYKa$MA|IV%uLvg|?s4 zuPujV57&5sV5`N9iRm0i9rEwyp~Un=6|(o!m53MB(nFpyb|*tf)y=1XR6@pP;RaZPrTxAY^o;nh>%meq0c-z`xu-ooc?mc0SYg2sb zUecke#ro^B6o%?v0j1fB+le%{DuT>Xy zW!-)6u*m&mQ9Ml0lo zV353t7oZVa54%S#x@?MSTJ02?)1^wCl&6L4#f_NBfy2<4;eEB0%!b<(4x%9pxci^XIFw(*^I13HL&Xo zy4~sn+Ntg-4dCY5M}C=tpl519WkCO(0s%+nxt^&A>yzs#Ls7su5@M&^p9g|}_u2i^b2I#J@&S9QU5Tx4 G;{O4JiUn=} literal 0 HcmV?d00001 diff --git a/resources/favicon/32.png b/resources/favicon/32.png new file mode 100644 index 0000000000000000000000000000000000000000..2a403654564298fde4044ac0ce3f7d3324f20d65 GIT binary patch literal 532 zcmV+v0_**WP)}r{a9yBm5Jq5wRwR0$KL1EIj_!0m9PlSwpFb&55bgKn zeg#7g{s5kWuas9t@DBV7{3t)ZN6NaA2>b^AssT0G1YUzzu0W&%Jb~c=z7~EqfEN%% zZ+hXz0{#R;8vriw+ZNZNp8)RsM=Wc1x|5KGoSzu^77Si&R8Htpu#W&PaLv8-1Yq}T z+PTkM zhh9xVLcLB3_=U}vxskX=;#5M5^`5`+q}XC9ij_{l(vSc;#&@5@Ig@od5)PcJ1UsGt ztHI|-CW!}=5}pF99|;@?yD@Q~xq=WiHWG5Kl5a(u(L}aI0ze{_jz)zgWYULOhMMtD<74&%=baMg;sgLYJt~@B;T+rk1Q-C` WOpWIqLzP~yJL4b*qI&8SYUW>-yXZi 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%0DyN7kXUl%2H`Sh3WOzPemOfJOx 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±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

>*1y$W^ zoXkWy}u=P@FA49a3O_mKCd5LKre4l!bh$Tnbaas)0A zOrDL#=^RaRZ8hDLH+>TiFD8C|zj_%A{UTcCwCeutzQ*g_LoVuX`(rN9O= zE2m{ualwwG7XHT#1P?}F9I>##C#e7I2hiBC?U3(LPaUFYmWowC8C(*-FJdnloVsud zwh=~!@MKxy3zK-M9wo{LreZpVo#xYi5DoqHE{ECDcB_5EMOl&QtUBa42!4`Fi)F^4 zBxG|2Gyj3&Yf9DHqqPC9Malvf0pbIcB7UAIuPv|kz?h|t^-tOnVw2eo6PC8n0G z-IQiH3X?g;5m+s_0F3!P>^=uAYO?U!eyHm$FaCTA`Lv~wPp7MUciy@8-rcuP?|%Qr zJ2y{v?!A3;Ob>tTXCTATrw13_nP}t!E)z|(7(=kTM!$&nmY;9_{J(kh@Q)rn{HwEf zmA}|ZUp`g6`Y${#<6alpyQF)&^o$!AZ0;!KLy5oeY?*_7MRIXsaHvMZiWX4Lwe_%^ zrL|qrK3g(dOF*fe83-@dxtJ;2p1`dy2SbuS;fhMb9G&8NOqVW0P*uJb*kLFT5NG)L zrZ8EtU%)(6q6>kbdQ3A)CO{8J)J`Pz>DIZT`ct@g77LnCRRGSM1?m`^3#?E#%bB7H zs3sjVj8}NWpcWTFL|uKQ9;O&2aLR^N#GO@fRnyr(7{f^LAR+w?)+C!uB0~i!f(Rw$ z)=~_Ksq`i)S(*jCC+Sz!H&-xEjg!0+Z-wnjiuLS-Pc9%to2_#1VnMNqiEJ@fj}P;E zufgW1dzj8qK9X*-z%XEDVot_pb8=4FV4$g`vb{?&|qhRRlJLkY! zP4!3u7HMI+;DHjaSS;f_3wi}+Gj`$C(`#=Q7IXJ|r&nHo{R>xD@7?*@y$9qJc2-|` zy?f+Mu)5c8-@N;FIo#j>@)zqfU%0$H`&40HSKt5jd#~U8?!Eh!N4)Z@kNsfBNsL7} z=;f1Qk?fFjM>i&zKV0LJNCTQIib6*s)Lvl=-8NNS7>;K~SP&27@nYy$fy-jAW@^vV zDtB16;@+ST7M@=a{Xm_O1yUc;R-}ao%4iIBg6j}D@;Y5b4G*}t%m|4MeSlpCxMPG5&6b!mB8dJMj{XWYpiEK_T8=Haq=-O5CRsCjnyr(-y!g2ut-xag{n)KF8QQvHm4l>&I%}gg zE0Oq0er)=*uTcdevvpNnhW?E7o&^cYcC@k;jZg5p5(d?1W`6XnzNG=S3OMMV2z9b> zSA`wq-~d9j<_uqnu7NSi!un7f+F63sHIO1u2V6Rsh2?h`5?3^p3lq)AZebECX&%fq%B>KQ z<()*-90k}6kUi+&lxWsDctOC`Q2raq2cR1_ORK+v_?s9sqp+KCGLl1oaG=v;|JcwL z3MFh~Yhtq~p#f-PSBJV)Ab4#LlS>H9_q%P6rYcWx^XlYnUzt ztEV#|kvZ}_m7Q}Zss&Dm0lqY_9M03hnO18v!HSlcVn0BSboi)r4BlYF>73HMEee$B z(A$xYeD9Hj1ag8iU?L%kX-k^|Bm>)Gdvu{B1|ujo9c z(1$k%=^F^?z-v<$#OppuPGFbSB?E65OC&~THAhj005VCYT0j+8Nr|bp1)pGpu2?HG zd!d13IGiw$7)@L=Nt4|Pu4G8jtjA?EVwso=JUGhcN04ucWC*F27Y;IO5@#izIWtH& z@hGte1=u}rmjK_jr&hLhr=b5?@j&KYR$Avm{HDXl2!&j z`(hN@K2mBc$c>#^79PR4%37u_hP+s3eqIJ2bab3G|AZeI91l`?O4bDi2DcP?$r%dm zH7}-*QjFtdP{)URsdg8g;VI1|VBY1V>a2#nxn_YPh~Y%r)q+5)nnl^47yyb9?;kml zWZAO=<%uAnC@xv|18Gv;sj(3+{vNP@mIouD9eC1J3WQOAQ~HoB7G?{R2MRLX*v1&p zkLXbJI#SdnMV4^k!dFEl2r?6vPtfHe8ZWrrX}7sWVGjE8%IgpomRMPjg6l=N2rQez zX7)BUUQXlav?@NK`1*ZJd`((up#*=GP+J zm>Ki1E?3pog6?Y_8rilJ`QvLP=b75#YJFUQIgk<<0o!a*O+dD|@*IpZQ?bC{GIG{| zsZ11FcR4}RG8?ljr0pqPr`peFwh9ZYy8I~Nc%4+#2SQlF8;hVgPnL~?1C-=)V4&z4 z6}rh1TbRC2v&u&NPDRy>EsSu7ld z4VSHHGp|`v?%3wQq&6-Vp===Q4e@BI|ClE99z-*6LMCPvQ`h!Jm~deugpGU|C`78Z z!0vgpL{M}n)A8es%9uXNln2t^g!^wyB3r2g@Qx1U^;{L-G8}yZtUQ4pa;ww>>e-kA zuOlqYgohc+tQ2aytSx}hbL6V>*0@{(o5`UjAsM`c$KINBUoa!F_7$bD0WO$Jw=`~) z%koYOGpKY4gm;&b7Quuk8dnfF>M~R*Q=V|-SIJ$q)VndY#0KW0OVzmV-j0s-WJ9`1 zR(if#GQ&Rfi&Rxkfc(_g7lUjorq8l9Us5MTjxfrq0B~Wz!Xqiv~>$0WgMhVEzZi zDc~|#75it7i=4#kyh^u)8>fnG6q4{@)`g2Bm!V2SX+#Mu2B_qPk(DZCB-0PptCq^Y zw=FdNtH^p@+RiJ!Bs?s$pZeFwPDB)llXI_z#PlXxjNLj7!RC+L1 zxvVoQM1Ytw%nPfDLg@=Zbq!wJn499j#8c`RGj|yG=2lrdj=uC3vw}0Vb?G&R?^gk# zvNi{>s+-{Sm{b6PMOlwNsGi880N#q*bV;YL*gO&?1Nn(6uhq;1`e>oV-Vt!+6?eu9 zoZT>3uu23B)Vz)H0r?*p&9!yAX?8Ju{s=&3<^RoNY+H!4m5LY-;?D_N>? z+ITNgQG&pb3k6@&#SR7yL~V=_DE68I?_*^-P8l+o6(L}`=_tmxO?M!H}9XmdjIW$osatCNq@b5^DK~M2|VqW<^R!dKTl!%c^le7@ojhw za`+ae8_xe+R;{0bg>Zg6H@)`i>B8v0oSBjOpTkKyVC7Zj3H>`?ubGaQh~|aCz%Hg)e;j`kUp+^zESiAlAIgb9*zxk_SbSrVazKF4Vyldqc_u7n<+FHW+#nS3I zwubrr^e4aj)8G4l@smHjcB;jE^-OvEDsCfL%?&O%~gLZ zyZGjVAKt!M6x!E*_3pjjxy#?6<*`6)Q7M0|Y#4OM*S=76!1r&qzrS|fGWmYx*6p$? zzHa9IgR`G13iZ9;zWH%xTrhg?!PjnH-78A! z+x)8@G{l}PA8(qADFwM3bASgAnR&&wR$b|0_$CI1XSc!yF^d8w|;W^Xy zU;EW zW@z5Ke{u5%WyY`RXp|RjRdV+o++0`5chwb-VgY-d}q z9Q{EEuakZ3&hIykCg#W5_76V_{N9F$Lg0@n<9z6&(=B!T$m3V?0tgy}^6XI4a)iJB z?#(Z~fAj8F-+o)fgJCv|eZ1yF?7sE3S_k+utpk=1?;M~~_#NikTs|!%wC^hM{7C3C zxchGU&^|QJWcNPd95BufF%-lj&BUv}wy(EzE7#T++u^!=_&ZHEis3@=^(V!D9F$`ZvAQ!d}u)PeXm@ zwzKr~5uMw9G3Y5hM>XZMt*?GWd&!q-{gbqleW}%d0;{cyEW16Q=l$> zI)on@S>k_x{^*1M2qF0JCojdqXGAP~_-bBY4D~ahb|>iF=zfC8pJNRuTvf*%+Wfq% z6|u5kdGAL*`r+v-H{Sj2XC=k_w?goi{O9!VIeiRy)wuT83Uc0i_vh*a5su`aL?X-g zpP8tC2mkoSNBWST9B;K{@o(LI@D6d7o1pvdyAtQILhjtYb?5JwU7Mo06C=--e?I2z zOL713e|z-suO2=8=QIlcw#fZ&|HC(5O8n1=#Q%kp@Y8?xKmGLI{ST*ayp$xLNlEgl&^JE?F2!HJf8*}E-@SML&a=u> zNyWCvNI&`SpWKdU=z36RV+s844$iW>@7=k6@10v#rnSBF?$wRkH!pA9-QZ?%+O`s=hlyIj!)mmrO}(;h5>lv?wK#W)xP8^m;?W#Z~w}TtAdo>AUD5z z`{vbyZ|a3<+P(6|&HGoyM5s!*XgzND6y)bfigj6a>SIJ}^s$V)*iLG1FP4wvq+ z@;FHxvws4LnPxBJcwL@zy39)s`;x={+!>NXXfnOzNuLtqz%6DR-bgIc`?q#yk3yC=Y z?2rER(U1S_fBEOX_cCMkvl?{%v>fk^*3Rab()SPE4WW9?r!Zf{WnOSEB%)_q@;|54 z)1MnVL_?W{iI~nW=*lvo*DyK`09UJuzalwviGk&3UUJMAz%l>JpZtqQAN-5|{MUc| z=*R!;(U1S-qYwVc$)DbN@Z6c_d0Xgf?5}?P-u=5b@4x$u>>=mhkymp@@g{gTYgO}P z-c8kd54gYevAvx4Kg!GbnUNANzIwq+Ve?X#d~uwSm8HLQ_wBB(^hXz{exum(4<6jq z4!r@(?qc<8c9LW(+Ow1&*2%GBYF^o>Olw0;k^lSA2Y>zOgTHz7@F)2B;oq|K#-BWT z_*cLADzWg5AF1}phdlTB&%JSVjzC^{^zeUu^zbj4d;gRA)KlN{FJJuwWb{9L_~?Vb zY+pqWYm}l}7j)<3=3QhH=tzHCqjS1%@yhkDeDhaNT*Sji4}Y(HVJeCM!tv-fHZ7RX z>gP3z6wrT)b?y33ly!aZr>t4>x9uzI;^w3dhSQo_$mb%}a(MnQlMnvgqlf>7cZL3H z`%E#rQBe<_3eiaovzil9`1rL}xq2yR3G48+fB)!%Ki)pYxi3kLx^NA6)s+-i>-05T zql>+t2NeAQE6r?+bJ2OKPwVWHk1Uu!J41FV*j5d|Shle&z5mkx{^$d&@6iYUseLR= zK4;-}URP!(PE&am^{Hl?)1)u4|2MiM>XX0h(&;O|3=h~he|eowzgm9WrZ{v6C+E;ki7h+jjb~q8UK2`CL?bT~EsKx+Hxs@I9E=|= z`-4RXwn-~r3h3S~^%fieYv$D>VnNrEx{ro*Zt-)R7f~RZ^nt4+bg7fhTn-B-&o!5C zPEelfH?dD`m$py(SU#y`6?F&;U2|Gyt-XAQ8`#RUJ5Ag^=_mWj>3}KJKfzY%WjQRG4bQ4{O;r`6(~L0{ZOlb@}aHSNUpAkNIk0 zfjZUY7-6nDLTJD@<9H=dR3t>TGxfPeWZ%Bmk^PDeatl4EWLl(cG(siwtYja-Pt!*K z_T80R-)J;+t50RIi3f2>*2pH1Y`J3mfjOce)l{AzY`_^Ow``IX)Ug zpvanXPL!^jyO^cItuwPh7*!_uB>-;U-7pt8=%JbChL zc}^z0RFu&XV1m7ZWP{*v;&}-La6k5~=AY8Il1FhlUN8Gs$W;P7eFI>Gp{PF$G6XL7=pRoE{opObpkc*XKn8 zCn==Q1Q_tnPL?@ok$j_JS7|Rde1U_!@a|{Cm;tZX!^j8<2}r=89wdKhPR{c}$%8(L z7yzvvA0cK>A7`imI}+ZO2`@+VBZ9(vJjkFh?a;{Jbe%|ndLcP{2sq>e8YeRC^)tlv z!5f32EdrD&YvXWa;MEB&A~4NE6aabx9vVp;utwqK^3Y(Qyg|m?A(+cae4HflavUMD z5}|BnY2lZ!Em#0(oIan|hh%X8Uc8H}SnvVaQeJl5dL+gVz9tYU0unlV0mGt2k5u8jV@XjKr zenA6z1xf(s4ItwL7#Dt;(f;t#X?XuAP|}RFg;pezYE0h=#w)@(&d*cA&%tKT0>Pb` zAAakb$P2+-ANh?j)wjiHe(|TsH~GJzzXclNPKFLNDsPL>{M|qNzkc}kiyyxIs~^7o z;ottrf6xJ1`i1~bvXzLu#J@sEAp8}7To*pzAAb9v{_xws%U%GbXH5IRW4fgg8kZiw zfHh~|Kkys}x%W6lZ9$&i|A8iwJTHw-;tVtDf>V=VRp6GZ7unW8Hu@OjC+H`U<_2;A zI5NnDVa*|aX|Nw;U&Q#>cz|&j6gwQ!4>B+@DdypNNdblFXLK6vVLdp%;K7MM$iT!^8nE0^YcfEy zqB`I~jBt**#D312XFtfm#2f=)w-W)zq@x&X8>lPN!@94WNhkdv1CzoWO+T0qB=Ws5 zNQHfX(wuXyr5|KhfdJD3To?q33VJB~!#TF$BmE#ZN5V4xyTAH_AHMyru!i>uJ>3Wv zFb|nqt|6%Z2BH2J zaOA)Ee}R_&`+xrLe*HiF`(ORX|AiX{RP8|1|Jkqq?eDQYN5chV`$MGgg&;I;_b_k& z4jR$#pTAmy=g5LUr256bf3-5tLHpll!lVEw3TQ*3{fTwwK5n+BDx+p6I=Sd?XT$j> z+1+%}-QU~~%(dU5?FaNjqgc3K42BAiug@(%n@#7SfwQ~Wtj4|VhRyv$*NU&Y|5?GD z2n*J(|1q8pRzvPackbLl?lJtc5w2dhnC9!wciq+c8oRLuZ~I+$y5$?g2Wy3fFwc!` z$M$9|{`y}$spgCMP!E-+i~eLd&_yP$spCg6z}|R1039av3=;56b-1}lqtP)?y!k1a z;&`Hv>O|2;igC9$9qL*cdS?3ncShJ>D1^S#FBV|i9|yz3arU?C&0_W=5P!c>77eYt z@FokWL%}-K&#C`8)=d@O{PyGO&wpJ@dHw#=9|3>=7a+;k({ca59x+Y^`CK-|5q}KF zqYqunk8k>;5Z$DY&>Y)O#{+mGxQ?N*Eq@dvb5azoIkfL}4eR?K$&l!P=74^tPwf0i z2E-p6s5qY9Vgsy-!tj0buo?p=R@d-$U;5D)5J)ms7u!{T7>7BpKMo55D7GI(KW4*C zcK}SQ6LcncbZ3kMHa?<@=djie*~R4Xaf^4^9dNEHpWiw|$5V(G#d^8eNZWO%V~%A# z1Xpu9SdLwsh~Ta=TCn#8q0Yr}=dtZ(abD@JH^UWIR*Hd*whI(WY9E{Ci0^?0@gO}V>+1b%@{x~olZ(OnJBLB|(d z&nc_{s=8fv!36ie2mKlk06cyiUJCBiDnUD^K<~D?+rlE&x!%SrKI-7}dOcp{rX=16 z&9-Bj5}+KrGz=LhZSgdgN;LFd5A`ViH|t)DscI+8>tX`5!K-|+uZ;m2XGBnx#Sv`;7V-^3osfem_@De z7Bv`LgQz8Dl5{XN!QADVOk%lGcLjih{;Z!q#{G}dYVkgv4)2EFOJ+pWG&SoD2XalB zSr)Y|CDX8?4=g8<86n>owgkFpEwT!E)*&#~0ru#0JRhE~QH2}FKa}#~%PxQxTFkR} zxWz)!F2Y4%7Cq=qdOMwRnuxvK5NZdc=NcQqA(nQH!^7YHy9 z>C;>UkabJb?tBO#h=rif4`4t^s0D-7kn{2S0}uz7h3IvEP=_EzFi=N;GTs59OBM?t zm^CMY0y8#r5k!EpLlITUj$T1^d5C?XVi1Ij(@PNAn1|5M`EH2jU6!0m9YAlWc0gHf zsl4VF{09*euaK?g{B8&j+BpZ-6{bnB6MRG~wz};BK29&@n{@@MOc`+?!P1|Om%v$p z`zAjF&dLa^ueHW2OU@@*d^cPzVLAi%hTl)>WM;-wQQ~D?jL4I9mk$+LNN`JX^TF_E z)nR`zAMCw~c+8STq!(>^yjpK~-QmV7NhNw~r_vNU7q1ir54iNbQY6Wgg;V4!<)Oj9 zo}aZa@d@?zc`T>uT8NDS7>j^bKnz*|Lcjp$1OzbwxhBDKO+}GwP}>Jq7*|EQflz8z z6J$2NGEf`FeQ`fBOCl~_5|XwLQs&%`aO8QaRNaGeu(cNH7??U~$28U^p17R?^_WIF z$;U#J(vQTWM)QTR`p?I6Btf8~ENgwqaIrvZw-D}ie=d5iCGast*7WWRe0m%l0AjR{G zUT*%rIL8c4ZMm_NJgC}nILaY{D2}}B^m@ISx&};a2z8#Pp9W>%d+ZBXv{uhc%fSph zp3e36YdO#lp2?LEUXf#i<#NTgQ|SO~#CCvlMuT1#zg;P9ito<_XwpT~N-ag3lmQp% zQ))P#fyLyS0(GUk@n*rtb^<<^SEKa`?}*3aq5|senav+Q@CWlZ|CA~1W&(d)QtCg3 zU%&nGSAX$)fBV;eqCRC1_*^8FcRSFR(pR~ah_))g7P!Zt0#4d)hf7d16hPMyJA>hR zJeuP%dP+@(^w=FNRhXzHavb$E?5=y`&8)lBFZsk1vpe?b$~6nl@gHoeNN{4DcR<$feUz{!13tHgg~V&-+|WtSgd|71U_Y(E!k+XDq|qTBbPce zvW>VjGR)$aHbWbSO9KwOjr#MoDqDj_8{q5Un@$R&1XDKcE_IND zZKyVY4d*ZjGf)`~&Y%Y7Mx&guFK6hXUlJ zSW)SEL(o<_Us!2Lchr2=T}{@K9_eVk!Ktm)81IQ3i@wh{uEA78Ix>xxtM|oRRW!nO z>urx;u^zdZTZ7>Wn<}!)X6@>Yp@pf&0BhHLIO<{>jS8*$JJChOhCoNnHK0w&K8kDd zF+BMxSi9CAU0~u5)u;tq4L300F$et<=phSF(#$WX8Gg2OiQ_jkA%_<P(+xEf7?I#kCBcN4l|Us$2#V%b-dKB&c7ZTeA*!It;)HFjSm zuWHyRz-?PgG>T%g_wBr|#x6`FHm1O{jD{LPFLmd0KHo&%cSg-LeUknRH@GbM?y@^S zPcZos1VMt2z_T4_c!o1z4&p|moE-`pLDxbRkSj)xcL(@fP?9`nF zqEktTyc8XxT-09C+~C1)*TdwZkG8Gkz55vVChp(kF(0q6IbRz8Y&b0qSMS5Vl+uOn zdc&nB*pGQLv(T#T-ia-23nO~x4813jQKohM!_e1cPTl7mh@!q=mx$oJx z{7Cg`#6>!s3T-{{fF~ad^p+!g+*WpgHeu2pyUe~O-)rxIRJUusj5($?0*wTXk@8)n z=LrBlcGs9jeZJfa<%tJP`JYSw15LnRL|jO|`a4TL+vJ(~5&RcjTSox|_QKFIzdvH% zA)O>Yq3;|((>)ld@qGdw5EtB!7oP#}zNRWw+w%ndKkTtryT~oLF#oZ7*3bk(V(RbZ|D`L+^5pr!cp26%p zH3OJyTN#e@HZ1|^np1Zp1rh^uBQAJ{3Ur`hTTW4O1`E!*D46|k*sAVd{F8I$3}*1w zIVynv^B;cufB)gP{|zeT{+%Db{qYaq{>2a9{^)Q1(;xim&;KP;3;(Ns{r~*xAA?7K zfiHp|zWowD{jFaxEr0db|LBKre|9dy`|X#1`|tkv@Bh_*CxY@@-*WRL3ekZEfBM6> zKLK#j$*2Dt%y0a~AHhz*L`M|z{KdZ&6`+6n<=_0r|Ma*2?2jb?fB5#-KYaVcAHMyc zqLF_43()fKf9s$9@a-S{&0qfRum9!$0MKzG`7_uUzW7gJjXpD;U;LB5|Mr)^{_p>1 zX8hr9Ei(YqA=zc%sSJr;`a0#;Co0w3ebaLM?6taqUzD7>eg;HBe5_52dW`2udj_ZX9~S-+fLK`E?b z{#6w|QGZ#&Cp&M`@^K~oDb_wMx zP)R^fbS2iP0z!@4KrKcTMyPW}G7wXDGpv1VXT5oMJQXq`HRrzu*0i`BL@%>qos*02 z;ufwn|5IsOLTWTV4RuoGD~C&Mm9C8 zroJ8`R5g54>oDoBLtJC*EfUr03+?b}ZM?^&RDgv6<_FVa2t|Z{nkDxeyK0!)juD6X_>iF7s#yS7Fcu2>r?QRmlIG z*(5((ar)Wmh|Ly-bu3rieApvC<2Ie7FQ4L{Q}Yu*lYA%gJy~d%{@}a+vHbEHt0i2I z%2@Gsp;k0JNw=b*wQFWiI(uH-@vL!zPFzI@o9A02))NYHi-}aGby6T znelRB*I`#$@zTJqDLe0Se{p@3uD*Q=Mq*<>dYt9|r0vC?I~7^_hp$O18ct7!x;4W3ht zx7#gGC5CxL+}fT9&;nD}D$7k;bCG2>sQ7e?W+~tuuY6zzZ4P^U5o}ATDIPjbdX7}guJQsGprQwpjb0u@1{w^y8m{|=}zh-x_hjzS} zPQ2#>1bNpRq!WW#6G>8;dC0`4zMCfjxNlRqd5&jm#v4f|02@Otj1hc7}xbMWSW6B zU9mzYE81`J&v_2(=oQE`4|sZ2uw^!#!7NsN%O~CA8%vr2@NUlfVzuI86n(7ay6IG) z+yH38b|Ur4Q)HQPEbgha$royXiQfiO{mGY!!+;jbw9ANT(~G$PWaW)HR+%nsc)M~p z3q+xr_E$b^uGn3(3d>$GiOx0XL~DN2L!$6iBSk92W=g)t%x#fMsHD>!tKkC;1PI{y z5G*ZkPx6#w0oY(7(mdGfHlS8Bi-^PR&_+@iSf8RfP*u@ zCbi_Mev<^u(TSYrciE*av7J@EQbOCNu4J z$zuxpvhYuadNVjPRB|w#_!iO72>k}%yz>RxohI4$+mfiU-~0Rlpk+5re>wc}0UJ-e zD-sM707%0(5@15Fum%f2rSjgntGyEKu=#XO?F$ z@aG)-E$*v+(S0xvUr+G$x_6a$YzVW&ya7NVC{ffMzt2z^L6d`bCkco!LORRNDp&`f9#(<`WAj+$eRKyj_1#jk@SygrJ>JdJQIecw zt}-1kd0vYT6ZPBjRXE+*)NIjfqGYhA9vfLsC^#=M;f#q`sr~7^1Zae&$lzkM0T5?) z%<3hBz!JQR`^-R@T3;0HRR8sJAszEfF7wE|+7UK(WO@Y_dBUG^I^OBz-R&2RqltHh zvC6z!k5xChS;}5;Tf|SN>1gn3$r)=Et6g9&`xi$0W^Zq_M5ZV z5YafJjkDN%Hs0fkBu7r`ErX%#iM#>L_h3$Z*qaIWIC2wW=Sxf~k&QP2-0w;QG=irg zqFgD9rT}2|7@>PsU>Kj}QLHCQ#+oU@usd7_01d;prR?-ku&cyI71q_@WO$bVLmJqD z8yN{EGfn?HaCcG^L1pNo0G9z;HdtSgQ3uUIvHizkeJdy5>-G##I_V^39i*70i2MMk zxPpujYllq}rS;`cw_7%2IyK+I(NI^xSpcy>*m3E83HbYke7szWg3&Z*_pArCZC=I7 z_tnRA(mLQC2t2wf zigsWzmEMKL8-ng^J3gUq48jB6(;`BH*_41<{vHw>`Zvlx<>^qf5?X?tTf|FO*?#-j z{o*jx{L$;X<^{0LKMZ2v53y=y@CoMTW<^EohM+JBH-TJL6b&`fR~RTR%E7uv!5-4$VMo)KMZhtA&WL&+ zVF7;7rVr-u`8qjVJ`|9R;WHL!Bm9j@-qng10)GGFv`_<@@AeXfABcG?`Cd!D1Anl% zjd`Q7msGzrwMwigHVibnngHnSgGQM+A}n6 z2fe*zSX{xfD2fGwyL)hlVQ>lV?(Xg~xCD213GTrmxCD21m*DOaBzcp4_OZS1_rCk< z_K%shs=Ld&R?n>J>guiDfzPW>mEp6BTn;^49|&1>4bD!c9>R5_Qloq)d*tGgW^31f zD|td3*+v7#^voV-$ycC^N}-#^SuEEYKI+V}??SFJ?9)QR);b!OUFF=I#CXEVv3os= zi=a*(KLDi0rjWN5NNHK@Iw*UxLtulqmK~BUEt5V9W(UTfXG6qy$PSf3ng-+(8Jd1~ zAQ@9l3)V7j?3blw^qqI?3>Zvi9HMVJUSIn(hZ)_*fg@g+8066VitaF-giXoJ-7AlUwb;AKFT$FpYi; zPpg4pO&a1qz+c}#`?YobGNmJG+nMvk45o$}11hm!`Eo)!!>&v$uDs3yF63luWR??z2 z3Eq=(uV2qd^V>U=Y2OClddFy`ehgX~WjKq}!!ID}AIoazxJt%ytvm3?5W4Y2hEa@{ zJ%bc7%#4usFz={I%d&AF&Le!F9|-akT%LLaq?xWi97i+f`%mpy9J_lvPj8QM)fw!r?7tcf1O_up;tu6K0)dfB5(wJ}Q+k6eDHO z>E~HbH4Yb>_$ax#a zD4=Le-I7mVq5kp@u-hNI<*nmBsEPgB+ru$*(r%EZB>4e6$XZ85yJn%X(8 zPw5FaF|32MuEI`C#YxP1b7!z-!m=;->p=DZlbM!0 zRi#=W5XD%u;{(>IZ^fgfm-{90Ot&JY>!sGKP)O_Ijv02W#mcN2kG%2DKQ|DPb^8*N zGLTfZmdq{3A#VLZZ+lTuN2!|X^;vUChyTeQtJVBw6M|Y=Z{zrDiaRCc$^dxo%Nv5D*Vf}X3 zh$Vjrdms;RtS{b`P>X|K9WLF(2c)YIs!sK=e-F~vO<;G>M)WWz61sF97;8qE$$ZC; zL_B(P7u`1i7#sLRX~MGVzBJAFb+pL;XsW>92crI{_!YyxFv-e{-ho6_6ETW7gRQuJ z{7?v4-KvILo!wSXlGpv6MXZ>~M=1C^k85gksxW3F#bswVN~C>^E$t{vKzF6&F7<`% zeY<*I-%b}Xyz+Llo;3uyFSF0HIh6LM9$$t9cT2EY$~{jW$E+0{ zhMVutYrF6@S?Nup4O$|(wsGr8XatLDAaLA@WqQmaN?^OyYH@sI;*OVK+7XmD*u~{Z zCaP2Bk4V{VCf$3Vt=>v8r{JnD;5K-;L|31pIHmWJi+`~{Y+MP92k5c-(AP&94?2LL z^Q)39b_|Y|JUGax*}crfrRw@(>uh6uyN2qZmgFh3E8OhB{!;wJHb|JBIg&~_T=Fkn z_O&8(J?)ke28uo1?nzA(uK61A>NufX9|55nF1%kaYq2X>a>=IH8NoSk(LIc)L2r0Gp|cD#`#F0HJpFoaIuJ?%1g`ZmX}W&<(FKuf;M@2?{ww0w4|vGvr~@RI zc8Ye7{djeWUKGM3(5E3=dkn62=sv^28+iGT`t&*H0;)0)-Xj#PJ}VIJuD~R(em-)S zvJc7$lQ1MMbS#p3{AQ?2<)$!{zt(WtN%i9c;a=qs3NZ`9#P;ff&GDW*6P$huyco5= z=K{)#KDZ<(e4y7RPeZ9{HDj@_z-Rnj=3E`A0n=b|y%iW56M}&NYI7LPu;eh35LJ3V z#_<%cO-BNf@!lF~yn;4xFFLO4U&00dRQd79qVx-qYza{PEZ1PXzP78wxFWWk)%SfD z;bha}y3aBI&PAq8LVByY2hIMlhg^?8*DPMXgZd4__oy~YBDZRPqgcnEuDipbOFB3x z<@tcB5gw8XuhFrM9KC*G(fJj{G-)q{QKWu=LEN)D_=^D@21)#ZSJ)Tt>* zQq$P|H{)ipy>BYg=vxhZN0sBL?6rb8pC{uyqP9vVGm@)j$?fc9Yf%{>QYsevTz=M= ze=Dq+ekMeu^0?{cXFp65)bbb+;D3de5$gYvq=?=q+3D!NbNjRrvTgu^YP8!6%%kPi zL-BfsGyje0?R3#rZU3`JeoU@mXiOfLvECx&3&Mc+;FED3L0@sN+YJ_k6;fQhVqWhF zr#;e=6PsyWcUK3}qkXxJ6BeO#a5Ak*NOp~mkjE|i9L`yZl456A$U`7x)5?A|0=!1=TbqDNgBb8&FW%no8@c<|MuZ!!F!R$MfMv6AD zmYvd=m)WJElB%$FSLHp5&H;ZoV7-&RiUqBXE8fGebULDcTF39(JZdU1S5P?|5m%WT zJ1~F)EL5|t!y2-yni$zo^NjVz>DQ8K*3S13vui}?1R!p8+ISY4M}O#)=$nAByHCik z)ab);^Fj!$7D!^tIOb?#M$kGDFX7efTQg|lB~|CCasmcUOlCigH&ddS&@DAzwH1eO z*nWN7n7-BO4@+;lsn-_KA}^B;*X?GL6p2jlB+__w%R^x>dhlOic04I0(g?6YM&M>cIeoA+bh)3cKzlMzh3*F|1BRAsHpzp&K24&sCqqb_k#2$h#(t zG1TyLhaisas2xrn6wt~>U^)8)g4{wARVDUxD5R;wCcB0NyRP)LyKVI-(J0gk?WbGSK_Vfu39a|doZg5jvZ2iF|F24Sz@LC4z4^@IiH@2_m>BBNyh7tGrt(3h8vvo)uhz7Gy zqYXqFpStjlu`b#j65MzHOS`BgbV7uThhf zqN;mUc^n?`rQ6mg4eBs?zhP7%5#TtJi*C;%Xc<}+rd8@-*Ew)4iX0BIL_Z|12q{W9 zw>Ir|y^6xy#(COhQ`~AH3f~nCI(=a<3dP9`cpm*_r84k>)u>09S{D~-IV`VBa^{EP zAmC5%!Wn(;q}vr;_-RSn%4Uke$ghiP?E;U<$h=|BYXJ2zx&&FuN4v@~H%uJ;OuW(F zb=(x05J?KXSQay4rMuCSq`dt76WS$oRRK$d-bT`$B+eP7RkG3h>9Zw62zL1wS)2+E zyVj*7f$uBDWE#VlN!4w&BtwG;Jp}r)!&&Wy7>rAk)%u^X3Tzeh+`W;0E>Wl8jz<9! zWfterDs45H4e%#Kqkq8HmA0r9c<#9ivS~Sv^3gQBi$}27{34)W;BR>lfgF~xKZK>P zKM}1On9?E@sLhKtTY;@RM8>GHa?qgTE3=YimxhMMp6Fuu{BZB8Fj&^pVj%aSiF!HT zOoy&pp|}qk?&*8GDJfRrD(>aUo`{7P4t-NxG30O1^^&H)4p2@qO0gh>c`lv#V41Wz zS@EE3ao9-d;6#!>4&A;*xVphp$=9#RS?O!qIMBn~eDlwsfZux(`Pv?BeJZerYRmjY z<)GqfvG5rSeve=SRkBqgtz2?R}%B^RvhQ~u1W^eZvqR`q3eRh@#s zYtOru;Rgxo-1po0*9gl33E=H61E_k1%uI)wH%bwi+NW<^;G(KnktI42UgCSM8X5uZ zC!Re6%UotEL4p)+>@8A`g0jRCj!u}MZ`_t8N-Y?;=igi2n6CKE9BoG7Gb1|g6s7lA z4du7iDw_i4jn;w`GgTJ4*l6Zrm+ytHuOJ|bw1==c+lRRX7&1npv0RES9T`3ex3=d` za8c2iCJSqRB(xnkW}~pg9o0UDu|X4aQOwLg>{M$)J0dS?kOUn&JhO@9EBG9&Ls(_r0ZA6qmK7 zWHJztYRj%5BhGyU3UePe=60s@ctd}{LX3<&1QXtZ0DjC@cAw0ie~wjaAkoF>3x(Qof5;sQJ34vl;h;ov@70s;i7qn~%9x-)$^VyCq@1w1Q;0dFyKpXFD zat6JhC31y0^|)Q3VNE@ylQv~t)5^#dUZg1927fza;j&U`SL&WL%`jyO;!8{BfOhWuzMzWn|%{x^tFy7wuKu;A$SrG^ZjToyP>wo966jWt>DOJta+FZ5+V zJYTCtl8DM&yAr5cl77h(QiSi1Li$vY4gi|8=JJmQ1~Gz?KM;=a6*16tV-biLn5n^2omsI&f@BNk!Hs7M_^!_Iv) zXb`;Dw`uYywdyE(P{`ZXOIitEO+^J@`IUOOE03bRHNBCkg_E%RYr%G|!b2*ez@sc? zFaHq3(!*aMYGo7Vj~Z54lqU^K)>j@+B>M#{kBuO1xctCB?QOH{=wx12H%CSW#95zc zxd}$0&JE1Ye}2eVs)tzA%@x;K!g6)eri@c+SV`!xbeKL(jDD=j9? ztyB?#&Bi`~-2P_zduORiBee&&3&zSw1s!FG?Cap_L33)He$@4-pCbfofEOH z{=vNeGQbhnd)Z%nc#Q2hB|L9G6 z?ek|Dnmb$L64$kPSA0DO@PU69S__OU1}9^^SWWrCM*y=nwTn1AGEKpZr1Z6?TS#c) zzzuB@{3p9_i#3(CK2G1oCCOmup;EO*7+Dq({+z5`qj##4WQ`ZBLyA zEWYgs-mnROlGuh=>kMa{_6d>2GT}=Qa;Ycg3Bw5qqNOC~Y~1Xyc)V4xvoO8KrTm^% z5pV9q_lM- zAs7nF>0Vb3=@2!MEF!@9Kr~R<3oQ{TX#Bd(>vi?9$<8doDNe!`DSHGaAft36^(pJ#O@!DS4u`iAW6y~#P_AI;H^-_NAi`|j)Z0g z2xEOJ)cuo#Wjn(7hHxU+;lH?6)RJ`x1Kbs}W*SMF7Exi$ljoxe_(G!=s;1nOliB68 z>Bt`T#OE%Y>lrqC9ABllfLsWNAM_~NpP;Zy2*>6UT3f?qxge_Z&)ez%@>e8SAsPT8 zMZe=Fj9=0V!B-B1EMoO0{@Ij_6;PL z9$H4blX)QHrdi4Qj&^EabR7Ec-DFhjRdic3AG}?3F>-R3sNmBfY+X@-$bkobnvnz8 z?#`(p%mJmlTBixzawrRDkhzH&!XzFTrC6b{oMcS>hUf@o0z5*Jmg;1*d=4#d65X6+ zHzeH_S~_0fxKTdl5vD1Epx}+g_??#isz1aUR4f}bP6(JnrN9MKX9jn$Fd+`8}m_T=+K*R@Yato|Hub7*N6kODI(8-i-}16o&Sns8x4f2@w5)q8Jwt!Wg>bJU8!RB;glJD&;tgi^wvIlR8ua)`oQTV>|_Ucdzd9Ax;bkTgA<# z1=b*Ec;o`VW35c@7$XQ9mkMlurQ}YN!jt-tP<=i1^oqv0rk{(=#=BNMg-#U%ogD0s z#ePZJ)p(+|wd@sy%eq(JDz+Lo!yxd{>&+pqyMZq%CO64bN1F7mYE@EjWQX$tY7r_S| z!GJ^Hf$wRPuiW7~{22Jx;@6Iqg36SoX7z#T!{p6Bsw~SvH!ugj9a=X!f>T8N?OzqU z(yn57AP=8i1huPtnMfU7h;*mQ4917|H*)j8DrOD!`T<;(G=rUb*9msAvAb_UjSAnMB%ePAP!RS<=a z?k_H}ppev8)tbAcn^x%V5~a- zVjcQysnLG=@v5>6EPMo2+)FdM;{kn^uTHS|Dwa27lFEIR@YA>6Fys(u7u$h3VMSce ztw3(p?$S7;%%L5u3vX%Oc#YM~f=C!{(XzNmAfjl}`eUM7(f6^-$7z;C^qc_BvA8h< z0|b~R)xCNepmR{Vy~l7_HjdsCdx?mg{w+iGGP?w9N|@`0{P<$mrTRU+)G^6JNl~GS zVLtMVY$qEtGTI(Gc8O3n_mb2V>~EUD)PWvu-+9EC$^s(1R8ZR8M>U)M?-TW1HV7Op zS#HZ%uuW%RYPn z%}A~_+v*^IpT#A0!{Tj!Pu9_WEELkOIRT!w0+<~UNpgz{;lj@ZXZ%~w?P?w_PGw4) zq!Xu4aM+3sBj3gm-0<|+GRAnA&#gjEmde-ooYK%l9Y5Ko61dkFQ2gLGs?R~8%a>0i z{;U}Jn%W!*!a9$9$Vn1_4li~Rj7}(_ckWSRl(Sn;>A%iYtz8g!9gPdE=$UEjG7-iw zlra;cTRNrtNH*9|m7>;5YFyUDsmUvImJvEFZDp=Db)j7;5yV(UZU zzOQ1JZ=KYMsHmXpY8R{Y=A_zIYD3i{~}cB{a8O*GJq@%x{gye83g| zYwO@__M3Gf7ewq40OV{JV49{Gzs1@oVHt?#Wy#Cx-AITF5lk^DiK>9nl~^2eTpK>0 z>R#iFqV^kWmQV|+MU~P=t2%${crsH)T(CG+6VCp7W$>-#`DooM?#EGrxt4-C2@|P) z8|q+-nsyTK%AcpigL2+UMM5VZFBcTzI@48L9K=m2P8UDtW_MOWMwHoKKOxO&) zX@AV;$d+cq$h_Y$`UhrOT0hG&G0U5RAdS&=`=L z9D3ud3$!9SUts~)EBJ$=-Z5x=g7DN zFE!;Y$nGO@aj%Zv*KH}~T$ktFVsWq28=xP_#uWT$hsdtnp|4fyX{D&*kMyQ_?=wtd z_;8`#^2Ru_;<#5%BmA7>o`)jF>=0Rs;tITJ{&l+4@Dpf)U5v@(aAUe+DY#r|TW2>l zec6}da3)G}b$mDS73phw0qq)#qrf%`KQpJPapK^;`WyhaX>N0GCTgFXDFliFl)p7# zxjOi>a&Xtx7$MQ94Ak$y_*je$1MVw_ zdi49!cfeB2-i0zcLnp_R=s~VXI>AzqHu{;3emvfOU2cKN=e$`odmh`u3SwBEw_{8` z{H1|AqtY^cAHM8pKm>NyOAI*M@SZSc@l)`Dphjljx3cAo0Ws{2a<1{qlbDn0TYF{R zTOW@Y-Aw(`^x`D}IPd6anZxhi21g_w`4wdoBW55v4_Q01D^+J&7iUG+E)rb{nkey{ zU60yJ!sw(Q23HAn`YKI*aSO^TF)1Pnmpz|sM zMK0E{L7IaGwqR!fzWz&liF?JC|Jk9=9ty(hf{wI6y_`B}Be~aHUw4rS=L_QNekY9d z$|Y8)pNqO5ALsoI_(EB%#LZRAIFI!vNS6%l!VC;D_N6RP)Lo$w#rr9D@b(=V@fGFs zgjsE$;DOvoGS1`Z#hsLgv!c=pjpiavOTCMkah{&kq1};PM-iAp;k_Q~K?oQNH7W_2 zAovg+J@q`jw9`Z|WGoUpr;PDjb)u2S-Z*cADAzb0HmAH!(E%k@wHk%_JQ>=p)DQZu zJJjz~<7gD&^^tnbIMh!@jL+G4n@w!Js7`U}17EU+H?bj@^rhR7re9uFogzv=f}nRp zpJ3Z0&aa8rB81odZ!i0L8x&w(##Y?9G~&RBD^HUbxrVVHM-xqHzWMw9gQ*RP-+!6f z!d0FH5-sW)o66Ec9~tjg1>@?Q^ij z19?D6W1WgAgHg4FbQRyLk#l^n3yusnHXTMErFChg+>6xOn>5>MzAI{teLPP~Q_oLV zn^Ui*vRjZ+&o_J+Q`D=D)Gy7BT`&(S(btLGL||jDxT5>=XFGS$wIlt{#s2DX)ACct zo1*AS-P0dp9_}@lQe~ym%2GwXBN0AQg2j7cpI_Vfj0}sNf3A+l|A64gT(45!X}fl#Nu z1KoqH8m6y?LR`OpeaI`t+QF3VAv=TC= zm|9J~4$TlM8D)9MWVI7($8oC)F3MQ}@v zr8ZsdRmY=6m7qzL#PMT3vCkEIu9(3*-HMZWsnld(q{~@0;l`?3J>x{5^?YGrPlC?n zj-d8>JYtu7myI#fjK}5sBw{K(zkZUKBK(Q>r)<2oTC5eGrC-+~6MObZFyb73AX1?+ z?jZ2&*g>DD-_xaC%*da3-vJGB6xAl_^@NNw!29f^HwMash# zrP(gao27rCSyP=EjsFE82a+bG*W^~-WI9fsTh+4UJ6QSV))K^l>gt7Piyu-Y|53IC zsky@zLmd$cST|N@;{<}`2(HGt>s6#`p1Q8tquPj#%u^TRiiTGJGUHG&Z#RsnAF^?f z9NX}61*)p6Wwvk{GN-j3V(QCVp6F{(%cTEdTfW}K(9y$K(O?#%oO9j|hSJEpak*@} zl&bqg*OLa*td4-r4qFRYlCxOcua`N4j5X}OV8A&@SQ@ENJNz)F{1zte9Q*a%TPJ-9 z8qgO;)xh*(rc19Mv8+}(L5Lou6$xU{?WJhpd-@~45idYhrQbCECn5pn4BsDj%SK>~ z5WAkhAhx?5Bb3<2bTX4N($vUim%y!%IbU%@AaFgf#vOI2K#xzNG&0M8e*+H`pl8pA z`F4-TeM;<_qT7o^5H5}ps2t7mS;6I-l@ zgT0u{9*%8D>mkeDV&GaEqYT3`I#21)D>zEsFec{UmV&TqLe+0D?@>uAdKlS_R;_%?S*tFM!F4MnRx1!+aP_B`umvtub1v0;zh*c1!dWq@a`L_E^ zf(iM&fH^Om0bO5ySdYHtdF8v23(>JPka!Gx{e_~XV*ab|Fj{pEue!L}ycaoFt3XSn z3LHs2gY!yu9F_Aqc}ks+q^xtU#uhy(!8ZHIEWKdMnakEPOstbs z-a>M7P9vF0d0H*GKhX;PPa%wzrR60G1rTW zY$C>2OsOoRLIv+at$R|^L8-hbkm68FV(=H8^K!C<&v(8WQ)UJ%K*X1#bZi-uoXzbS z^U$c>@OcA`5ewvs@%TL!hJEuj$HxW{W6@hKZ|wq=mCJ#2>~F=ixwj2{jeeI;{BI2+ z&d-o;vRE4E>x*(8K>D-;2R?qj#R`qn7UI*X=K1z(r##i_xYlbY52t7Q@bJ>dOFd6d zC6tBVxFloO)b8sAGH5P85K$LJr7UgAbS_+X3uL-1ug9b9tB5>y3v6aY2WBMoJ1)Q{J2)~bGlWIgJ$MWVSu1iaQYRw-zWM&PTu4mg@}q> zCbReaD8Rz6X4w1nQLQDyP0zF3f4yMfl>ej8qKp{JE**TuD*3&ce)F3C6CyWCOS z(aSHp1%>EQ4tyJv2i(_vqQ71Mm(Bg`yAvrC-xdYKIlFLqiM@6U<^<#Q8$vzyL&Hz$ zw)hg)MZv87xIoL!hL&MI^?n-Tu2gN>7nu$X_b%>%XZ~BKG+`~N=h^xs5Gt9jhvebN z$6u8KKA?HGz-~0uh|{+C@f56r&50w57O*6=+VM$)o?$jce+6i-j$fLj10&h)NRyZ4 z2CJp*o4hd_iNiNpFfiPu)Jt)<{^-y36IL#`&MjUSjnl`#v-~wm1mD56>Phg^YNCu?H2sj*(hS>#}Zz3 z?m1x4Kxw_c4v1mSL`03JT?}s_mf5q0GxQw6GN9XZ_bhdm%f1AUx+9<2p7k(T$GKuu zq=CFUeWK!5B%AK#;=uP%&2L~6if}vaxW0zizDqzv_1G^sZtgEq#cBihTn&=N=Xn8h z9(^MR`Z$CBcPvh1Qto%bc9mLRQ5H`E+AWA9+Xt&)W4wXqt*?J`_59pyt5R51t9%ZO zAR5F21dnEU4TBT(Sj?1G+PCN>?_xctsj8h#AjW8<77Q1Gv)HpZ)2FJ$D;DNLIaxCT zQ5^F92X_u^cnGVp8@lnB42wvk!F#I#U7e4VoAL@l>Yl5ASN2MCe=5RW>$;6LpCVya zE+S*cFX+lg@7i3JAs?b8a<1SkHUuK5M?aof(>la&R24qHY=&n_ZL$c6@n%F}oM+L5u%z8||rg$P!t$ZjfXp zAa@zDG_**@%X9SAvr)Y!qQz;wU1yVTvJ$);uUm~#!h{>&$|cZ&t3+q?$) zPAl$fAzDxJqwp=Q#!CwzC8Y_^6`7@w1jG(i?a~demK+)_f8MJ4A&=fkN%+2m<)44!pL98E#f}K3Gh?(IBd7^Xn<@ln84R-eDhG!o8gu z{h-!keqJ2>ao7xwa=(5?T`AI>zSslg;(2UR?#9iefuiX96t=bNYj}gQ9q!wWw0hy( z--LU&j!3z0;OzgP2^ggv5eH*_vV&{_L^*#Xl?vpP0-#+^#L7R%ey19-ZFZ*K?I8iN zU~+8)e~n)KakB>b!SEu%Km!!R@2NpPs|o&pUPrZ2QV!7>yg>TQbP*tLXB3OH7r~VO z6;UYeW59>oIw*OP4K#lG?tr$mopjzH+dDm2OTz1i!98XT8j@@8Nr!m6eu?J=6xLRY zkPdC9Tdi$wpku&9x25IZeMBNfZEd+IWygxXjVlvTCvrtGl8LQK7-dTc!ZYJuBE-c+ zosBv3UN8wMJT8g*LOOSWF02XC$Oz^RX09L?b7OmC@Rg&94Kff&#!B}0f}fv7&fLKg zWJLyK<7N{S{AV)+$;o~l0GH93Rc|W+MSp~){X17xVH>e_z{7J;rU(!K(M)KtD0yO2X(i2=H03YO#msB938<~ zL6)Fk<`!6s%=P-RAG#yh^da{Q^0gApxPu+vvkuNaxU8RXy5;9GHec50#IQDJu4fNs zD@k+pRV1q63%MXN9&|=eL~)C1@qGq4{FjUo=(0Tcr_$Y@^^e;AtKSQfsM*aHpt?cQ zm-7J_Sx^eXWY6dgj#Z#pQB#F&Ogb0osa z&5f98gKB`LDLi{(R!n{V=>%^R?YdGH57(tA4IbgQoJj)*=AT>0tay^1U?0}L!@f{4 zBG(h9esv)Yzz;VT7g+F-Z7rtWR*`Uno*DxwhxxEZ z8}!3cW35)ziCy?gPfQ8NDA(WniJCn@AXrd1NSa+$4>8V47ka?Ps9d_%P%c3YHlnE) z&#lAKN$Gm6=YwQOxg;5skZA`3;TAXVj6)zwQlnBv29ap^emgxcb|_8X<3S*eh^R|R zYuE9NFr4CdyZSoN@s?mj1e#8xm{C1Jh_$d7x2R@Rz6(bW*^KaON)C9azysW3L*k^@D_v{ho#qL+miPbV0I! z<-AtUZlYccBMQ`Ky_YfNfvD1Hq_6htVmHCI5K|~~dXexW^N|L_WBRA2Plch;OVyNl z`ew@?MWrK7E<*&V>{)3H#~G1qn0QPkOFasH%CwV*_BI{`=Ae0EMox)F825YK3C&uo zS}NLinI&JB79v;o;c(J#QM#@y|BajK_GT81RH3$T*aC3975px9UN%kM=}%#6-sN{ynK-Yrdj4~3K1dmghnFYb|Y&p-!+`DxI=y|Lr|o;wZ`#iKL& zcynw;9dZuMaJgbx=uS=@nUuU%!1~05FjxZz?6QD(G3G*IXG!c0VlH`C}NL`x6lowXc8 zVxAGrZru7l-WHvBIh>uGclR0zhEPSp6fyCCLmSF;x%(N`6~d3($d;#4>&15mfA-5_ zU-$%rW}d7ib=H|s0%kSvp0$`H0#{>ZDN z=LeP{t6IBO?+9C-lo^}Yt-*Lr$4A2O{wax>p?eif%M26DRn+MvdK+L;PM=}Cy< zrdG&k)%lb~h7%z+`_fd`yCXF<>3e|xA(EZjyHYHvcThDX`3z1Gno_h)ZWCjNB6{LC zdX#9q6`o<22b=zgfyCyhuct5>`OtZdkq>VWrB7pH1w!-}h+^r0Yp<egM#$Uqux0CSAEzn9 z*lhdF0Xv-BRkO5jJdSngESI|Shercq)D=60J+B#!!2?CQs12df6% z`Z)plRy+GbpRBU2nSo{G<}$BSWO=KCv2*KOC((H$+Y)j_q0pItqpq@m5MLX-s;6ljjR*(P z;~9?~4{b+VR&8n%@ajvgQXQ-~KD!QKp2%e2`4$cGiFu%RYvZm5>*R#Awh(Addluf~ zGu!OwC2|&>>%z=WB;#W|-h|hv3ALxzdS^D+krniYq8vf8c@?iVUoRtxjcsWy2*Gk_ z;O|s2OYO`hlk_05u|j!iG9!H353LNC7OF{#gn>AytW|IEs{`wloJv50DW$;VsCSpn zn?B;2f`p;4gYJv{NY}MoHmwixv07(<#mC$1Fmr3lMCZ$#=PlFEPA!dIZS_@NcqN;Y zvbsa!mZt0u@gU>mRSNyk9C5q6MGQM{u~%^5@GiZ7rrK(GYzG^JS$iV+4aMfqDhLRo zc=v&gJt=`uM%WPt^nuF_$9TgH$H`QmH`W#(=Kfu-ftH9Hm^s933p+ivcE{2tuZRKs zMlVlMy7T-bX5)FJ{KWHY_{6Gy&dDklzHnNdlIBSI298lG#c)mq7%VK)$m>9((?1=DGBr0=6P(J(UQt_yckv{X9QXc zzX^iTEe+aOg{n^u7@Bk1-h1D|+3>m^7oF(~xEj{IsH)dcQxzDc&6kouLCH{a{`V~J zQ$;I;rlMAGT)Gx<0#bL z?oalsyXK`FIx282 zVQY|P;8&Z_;x*f{>Y`N-oCAE8xyiuiWJ=P0+6#g1J%kXZAA8%+T(cZk!CzVdkQzjk4w-I8kbB`;(}T)-tc0Qg>EutTo3w9`+EKBh z>DsLUv1odzI3pA88k*<+<;h|EI<7ylky>VL{m1h$^4cYuwJQD?B!t{d*S-G0_(`10 z;5_T|wZUTEn3(KfC+A%Z(yYyi!p_cdijnEJCVR;? zzpdu*c(=KqT9&s<(~T3Dn9gNM2QXODiVhBt{E$0cI3SZeDQ2gKkVlH0?!n~nKVs|< zD$DFzS&tK~%Gc=%q?5nd4cG7GuJc%Pi?+%+e>lFL_mWc5r0Zeb&5t{Vy*xv(8FwL} zEqwP*b%Ciiio}dhcjp)EmkLD4d!BwbMl@T^I*n)vJwWi3TY{5X#(AFiHL*eIWWw+R z?wn zi7;CunH_)@jXdXUyR`ZlSRfW)QTvZ?EdLcthI;}DNmt5M=}EX}3vga^gf}%}hi2J@ zf%QmK$vv-v*LSpX!rQOENFjg2{doyKqQvGd>om9?MEt^ZKjm*CTwQ$>$^$X$Jgkwu z-}(ce<#2QH>4^MZw@9n}DCwxcwdUfX?ZbWealp_{AofMQyAcbY5`MxQ6kGWj!*Vv^`5F1a| zx+K0YFhAb!zfw*`AF(j~^mT$I(SRUf1(b#J!o?}|>>(uh6Zn<%)(U>UBlf43KJEPS zhkqq zsboq%_auCN=@&g=7J0zugpZU5&gkE{M*L0DSo7e6tQkA8&DdZvHFC-B=wEVhx`Gmu ze@zta8h&c~o#Y#C??%OG`l}4hQXAii(}as=0YbCn(n?F$ZCG_=waD$MW7o?=(9o_u zdl!SBm$W(zoTZh6-|!2gKLR-C(hW<1+bsl9`X5w~7azs{!4bjn-+l;b2ave~h>Yzo zNQFhg*xsC0&e+Qwyd6x<%gLNYQB;ye-PPFAoQ#e0Zwo=e|8!>jqsL!ohAxYSwYdk2 zn7OO9rGx%|RsU`NPhW>9*a-r1bdh$ja0Gik{&`0$4EBkblS$jzxq;muf9t^{m|5vq zRNYKKe@P3wfIJ*sYz4vPKam2s{)to<@DFbOiyH8MNc0c<){YKh#vpTAFrsNlb#w&f3Jq*u{&Cjg=Xk#hFFT8f0fqYi;W2K=WRPBtJL*bVi!{11G8 z@BRb!pLBujT%WZ~kQ2c1uVV@1Vh8+dUryG4vu8E{CoAi} z^kw4#KQjMnFC1K8@9Y280oaB(0so^fcxJM;Gj~Bo08d=1*52mehm}Re(Gfgh{$sxU zJEf8RWAag9krvbC7Z(;66&DxgkPzXP5C;#BFb6w$j3ih&0X#q+aiFLmzbKakI~$uQ zC#QsnFozhpl80Sbf{RO-=Pz9VkFcOVc!EH11sS`5{$qXsbHvRGL`I;Z5?7Q!M)>~# DX88xv literal 0 HcmV?d00001 diff --git a/resources/icon.png b/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8e0077b4bf1612d3ff2db5b5062fea72f263ce00 GIT binary patch literal 2753 zcmaJ@`#;nBAAf&#VNJA(RyneC5xMP@6lx<(rKQ$!%QZ12mdx!de5OB?~=J4qs! z%H6`8LZT>Ii_EAr$!%`KE??&#IImxxugBx{dc2;G$LsQVzLK4(6m{%+EC4|LfTP_p z0H7ro0vMGg@I3!Ga0%8i?cJC(Mj$iF?|cBTImI{`KsXTMM-Mm_;CCt}tTn(I0IdH3 zJDcOtJ-_<;k7~EBQ5CUz<~{}z^vtNdHS5Tzt8~&@J&q&L77cTf{=>V6&Nm&|CfmjPDI-5sjrJIJCU$_B&Sc7 zBOL1RQ@A%-D>qxrbdcu5>t_bKQtOOCP?dmE)7dZFzHqpvmE`k*u7lk{u?L-J8`I7= zrdifz8lV(e`_PF%6o4i@Ce4O7=6Be1C@$H{!?TeCwHlX_at%Rr?F8+a?$86%1K5jC zEHNcI0T8e6!I5z3+)PLH75c&trb^esj|UnTYjRD&gPhU+sAy_KviU_dFBZ7!A{};0 z@F-S%^ei~0Z&q;8_lzqBB$;AREZ{6nB;zn}JYKO4uXrtto?YMg${da0Fp_dyBprpl zf5ds4L?HA_w%|bAHbwdK1oykj2;rl=L6Rhu1;i3}l9r;r?)~GD-%zy>Bq}P^CWG== z{t@BjpNA}Ft1C(AmhxYT)bASUeqG=HQRy0$!#Dr@c)1xVEls#r@~_?@14DTEeui>;1R* zf^NDp?7LQ<5#vrv3b-K_BT(f24#objnOf?bn9xc~Ku<`?z6yzcrI`>SRhNLMNd%IB z?01TyTr~k6$ftyYCbxwOy;4+r+Wf+qFV7vUw(Q$i)UCn$tm|)h4y2^IrWC-?4gA?J zlP$IC2R)Y?Jwxu^=|Zebr*-T3#*13|n+?<#av!Q*zQc#2yYbC#A`l&X;bh7%;H_nA zg87?1F3QtAgfw%gleo!$wSS0tLuOXeO_vh-Oh<-nUcigLONyXD%^n9J4l-I$uBh>l z7*v(T(h8^R<^(22QAJu|Ixr=k`d+6j^GaNCt!N2l9%b=%%@-r8^q=-?JhO?lFFxD# zDqk@Pl7B>G5Y*JeJf-bnz6y*k?maiszwp_Dv0q#OgHEX2@8k)OITz5$|C}Jao7&p@ zKn(|q*Q!{Dx>mLdRs$l^_kB}v=hQCaB|47#j^4}Q>IgezKM;en+6aJI55|x%59&s#R@g?+gP(lD+uwWY*D|=ec|LWo z*Pg}vkabo@2#)#`r)8|Wa&0VvQ>egxL6^rHFol;N)0GVb^cUv$3jQ?4`l2asyry&Bq7MpIPKepZv0p{zUuX-SzSXJWt2HQ>hw2~utL!AB-$dkNYB{q ztt4~gnVF-#(lDVXqd|}nF{$*!b{d~{9@oxFTmMa7=F?QijLP;e#%$?ZKCSz8mA`Q) zXiqtmQo&i;-%~@)Th+M+ev_rIGzc}j_Jz%+X#>}0{bm6+JNz`|M=#f6_hKHOks$)V`0+>OEi2#!nvYKa$MA|IV%uLvg|?s4 zuPujV57&5sV5`N9iRm0i9rEwyp~Un=6|(o!m53MB(nFpyb|*tf)y=1XR6@pP;RaZPrTxAY^o;nh>%meq0c-z`xu-ooc?mc0SYg2sb zUecke#ro^B6o%?v0j1fB+le%{DuT>Xy zW!-)6u*m&mQ9Ml0lo zV353t7oZVa54%S#x@?MSTJ02?)1^wCl&6L4#f_NBfy2<4;eEB0%!b<(4x%9pxci^XIFw(*^I13HL&Xo zy4~sn+Ntg-4dCY5M}C=tpl519WkCO(0s%+nxt^&A>yzs#Ls7su5@M&^p9g|}_u2i^b2I#J@&S9QU5Tx4 G;{O4JiUn=} literal 0 HcmV?d00001 diff --git a/resources/icon.svg b/resources/icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..f661393ba0bd7e68507c0a7beef8c1e4f2ec248c GIT binary patch literal 843 zcmbVKO>dh(5Ir|i{=;;8sZ7n5D)gy)tpin@7jgw#BS=?4R~%HZC*7+Ob+8C3(57T%Zsb`jKRh>wok{lU&80S z-CNk!^W91GZeU)^pKAWVoKCK2N1{${37TnVxMN`RE(*e!*ario96w|yZtecF{(HV70T z91xQPk}1XWF(&*G(pE(|{&SC&omQ>O0tpClzk7*vUt6|Mv!|kO4-Kkq z?1YF{7}UKSx-_#f>3TcZ{%GfByL2q!v8wBsd{`&J$}6SE0h_!ifiLh6;MB*h?7#&h2mbI6NT3)&4Q}P@MvMEQkrUwSY*W=`y6l(qQGND za;bf40_I8Xn=nz4MoNUqkSfn$33IlUPFyTlI1yAW^eoIEdY1YEvu5Q)yXszmqBZK~ vk?%P}OIP-k2a@~-t~l5u literal 0 HcmV?d00001 diff --git a/resources/logo.svg b/resources/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..474e96691df44fe92e10ff2334d19c7a42ee3d6b GIT binary patch literal 628 zcmZuuT~ES55PX(||FEvF(z`1k0-=%shL{*75j8%R_GnK^uW3&T|K7b)Q4(Idz1f+$ z+1t^0zfE8#^Fry=L*Z})nFcykY2@LzS-1K)zHc^0*Rz-D@^kS3#V&%y+vMqS3fN-o zqdjG8ww%H1`y7NL7<+g|z}T2<$XHpHPT6*J9rg7vvCEb3QbINbGKtVu~boP z{?0ok%Zau+;9yDB$3Bt2N@Q#02b(C@-)ZP!Dof&=u_*np$oxQhnB}sN`A$}IW;!`g zUI}ZJB&5`-L?}$IH^jW5$$n3*I$pbTLaZ+EGY_k4&)_euXoctaRvDQmii#ZyAtR2V z7p2+zgPzm9?eV_Tg+Ax>-w}ks9q*j*s$1h-;hyk{S>tWjYL7myYqduo;gWNWBeVOp H3#0l2URbk} literal 0 HcmV?d00001 diff --git a/src/api/api-handler.ts b/src/api/api-handler.ts new file mode 100644 index 0000000000..c0714ad69a --- /dev/null +++ b/src/api/api-handler.ts @@ -0,0 +1,55 @@ +import * as express from 'express'; + +import { IEndpoint } from './endpoints'; +import authenticate from './authenticate'; +import { IAuthContext } from './authenticate'; +import _reply from './reply'; +import limitter from './limitter'; + +export default async (endpoint: IEndpoint, req: express.Request, res: express.Response) => { + const reply = _reply.bind(null, res); + let ctx: IAuthContext; + + // Authetication + try { + ctx = await authenticate(req); + } catch (e) { + return reply(403, 'AUTHENTICATION_FAILED'); + } + + if (endpoint.secure && !ctx.isSecure) { + return reply(403, 'ACCESS_DENIED'); + } + + if (endpoint.shouldBeSignin && ctx.user == null) { + return reply(401, 'PLZ_SIGNIN'); + } + + if (ctx.app && endpoint.kind) { + if (!ctx.app.permission.some((p: any) => p === endpoint.kind)) { + return reply(403, 'ACCESS_DENIED'); + } + } + + if (endpoint.shouldBeSignin) { + try { + await limitter(endpoint, ctx); // Rate limit + } catch (e) { + return reply(429); + } + } + + let exec = require(`${__dirname}/endpoints/${endpoint.name}`); + + if (endpoint.withFile) { + exec = exec.bind(null, req.file); + } + + // API invoking + try { + const res = await exec(req.body, ctx.user, ctx.app, ctx.isSecure); + reply(res); + } catch (e) { + reply(400, e); + } +}; diff --git a/src/api/authenticate.ts b/src/api/authenticate.ts new file mode 100644 index 0000000000..5798adb83d --- /dev/null +++ b/src/api/authenticate.ts @@ -0,0 +1,61 @@ +import * as express from 'express'; +import App from './models/app'; +import User from './models/user'; +import Userkey from './models/userkey'; + +export interface IAuthContext { + /** + * App which requested + */ + app: any; + + /** + * Authenticated user + */ + user: any; + + /** + * Weather if the request is via the (Misskey Web Client or user direct) or not + */ + isSecure: boolean; +} + +export default (req: express.Request) => + new Promise(async (resolve, reject) => { + const token = req.body['i']; + if (token) { + const user = await User + .findOne({ token: token }); + + if (user === null) { + return reject('user not found'); + } + + return resolve({ + app: null, + user: user, + isSecure: true + }); + } + + const userkey = req.headers['userkey'] || req.body['_userkey']; + if (userkey) { + const userkeyDoc = await Userkey.findOne({ + key: userkey + }); + + if (userkeyDoc === null) { + return reject('invalid userkey'); + } + + const app = await App + .findOne({ _id: userkeyDoc.app_id }); + + const user = await User + .findOne({ _id: userkeyDoc.user_id }); + + return resolve({ app: app, user: user, isSecure: false }); + } + + return resolve({ app: null, user: null, isSecure: false }); +}); diff --git a/src/api/common/add-file-to-drive.ts b/src/api/common/add-file-to-drive.ts new file mode 100644 index 0000000000..0bd9f34825 --- /dev/null +++ b/src/api/common/add-file-to-drive.ts @@ -0,0 +1,149 @@ +import * as mongodb from 'mongodb'; +import * as crypto from 'crypto'; +import * as gm from 'gm'; +const fileType = require('file-type'); +const prominence = require('prominence'); +import DriveFile from '../models/drive-file'; +import DriveFolder from '../models/drive-folder'; +import serialize from '../serializers/drive-file'; +import event from '../event'; + +/** + * Add file to drive + * + * @param user User who wish to add file + * @param fileName File name + * @param data Contents + * @param comment Comment + * @param type File type + * @param folderId Folder ID + * @param force If set to true, forcibly upload the file even if there is a file with the same hash. + * @return Object that represents added file + */ +export default ( + user: any, + data: Buffer, + name: string = null, + comment: string = null, + folderId: mongodb.ObjectID = null, + force: boolean = false +) => new Promise(async (resolve, reject) => { + // File size + const size = data.byteLength; + + // File type + let mime = 'application/octet-stream'; + const type = fileType(data); + if (type !== null) { + mime = type.mime; + + if (name === null) { + name = `untitled.${type.ext}`; + } + } else { + if (name === null) { + name = 'untitled'; + } + } + + // Generate hash + const hash = crypto + .createHash('sha256') + .update(data) + .digest('hex') as string; + + if (!force) { + // Check if there is a file with the same hash and same data size (to be safe) + const much = await DriveFile.findOne({ + user_id: user._id, + hash: hash, + datasize: size + }); + + if (much !== null) { + resolve(much); + return; + } + } + + // Fetch all files to calculate drive usage + const files = await DriveFile + .find({ user_id: user._id }, { + datasize: true, + _id: false + }) + .toArray(); + + // Calculate drive usage (in byte) + const usage = files.map(file => file.datasize).reduce((x, y) => x + y, 0); + + // If usage limit exceeded + if (usage + size > user.drive_capacity) { + return reject('no-free-space'); + } + + // If the folder is specified + let folder: any = null; + if (folderId !== null) { + folder = await DriveFolder + .findOne({ + _id: folderId, + user_id: user._id + }); + + if (folder === null) { + return reject('folder-not-found'); + } + } + + let properties: any = null; + + // If the file is an image + if (/^image\/.*$/.test(mime)) { + // Calculate width and height to save in property + const g = gm(data, name); + const size = await prominence(g).size(); + properties = { + width: size.width, + height: size.height + }; + } + + // Create DriveFile document + const res = await DriveFile.insert({ + created_at: new Date(), + user_id: user._id, + folder_id: folder !== null ? folder._id : null, + data: data, + datasize: size, + type: mime, + name: name, + comment: comment, + hash: hash, + properties: properties + }); + + const file = res.ops[0]; + + resolve(file); + + // Serialize + const fileObj = await serialize(file); + + // Publish drive_file_created event + event(user._id, 'drive_file_created', fileObj); + + // Register to search database + if (config.elasticsearch.enable) { + const es = require('../../db/elasticsearch'); + es.index({ + index: 'misskey', + type: 'drive_file', + id: file._id.toString(), + body: { + name: file.name, + user_id: user._id.toString() + } + }); + } +}); diff --git a/src/api/common/get-friends.ts b/src/api/common/get-friends.ts new file mode 100644 index 0000000000..5d50bcdb13 --- /dev/null +++ b/src/api/common/get-friends.ts @@ -0,0 +1,25 @@ +import * as mongodb from 'mongodb'; +import Following from '../models/following'; + +export default async (me: mongodb.ObjectID, includeMe: boolean = true) => { + // Fetch relation to other users who the I follows + // SELECT followee + const myfollowing = await Following + .find({ + follower_id: me, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + followee_id: true + }) + .toArray(); + + // ID list of other users who the I follows + const myfollowingIds = myfollowing.map(follow => follow.followee_id); + + if (includeMe) { + myfollowingIds.push(me); + } + + return myfollowingIds; +}; diff --git a/src/api/common/notify.ts b/src/api/common/notify.ts new file mode 100644 index 0000000000..c4c94ee704 --- /dev/null +++ b/src/api/common/notify.ts @@ -0,0 +1,32 @@ +import * as mongo from 'mongodb'; +import Notification from '../models/notification'; +import event from '../event'; +import serialize from '../serializers/notification'; + +export default ( + notifiee: mongo.ObjectID, + notifier: mongo.ObjectID, + type: string, + content: any +) => new Promise(async (resolve, reject) => { + if (notifiee.equals(notifier)) { + return resolve(); + } + + // Create notification + const res = await Notification.insert(Object.assign({ + created_at: new Date(), + notifiee_id: notifiee, + notifier_id: notifier, + type: type, + is_read: false + }, content)); + + const notification = res.ops[0]; + + resolve(notification); + + // Publish notification event + event(notifiee, 'notification', + await serialize(notification)); +}); diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts new file mode 100644 index 0000000000..ad45f42bc7 --- /dev/null +++ b/src/api/endpoints.ts @@ -0,0 +1,101 @@ +const second = 1000; +const minute = 60 * second; +const hour = 60 * minute; +const day = 24 * hour; + +export interface IEndpoint { + name: string; + shouldBeSignin: boolean; + limitKey?: string; + limitDuration?: number; + limitMax?: number; + minInterval?: number; + withFile?: boolean; + secure?: boolean; + kind?: string; +} + +export default [ + { name: 'meta', shouldBeSignin: false }, + + { name: 'username/available', shouldBeSignin: false }, + + { name: 'my/apps', shouldBeSignin: true }, + + { name: 'app/create', shouldBeSignin: true, limitDuration: day, limitMax: 3 }, + { name: 'app/show', shouldBeSignin: false }, + { name: 'app/name_id/available', shouldBeSignin: false }, + + { name: 'auth/session/generate', shouldBeSignin: false }, + { name: 'auth/session/show', shouldBeSignin: false }, + { name: 'auth/session/userkey', shouldBeSignin: false }, + { name: 'auth/accept', shouldBeSignin: true, secure: true }, + { name: 'auth/deny', shouldBeSignin: true, secure: true }, + + { name: 'aggregation/users/post', shouldBeSignin: false }, + { name: 'aggregation/users/like', shouldBeSignin: false }, + { name: 'aggregation/users/followers', shouldBeSignin: false }, + { name: 'aggregation/users/following', shouldBeSignin: false }, + { name: 'aggregation/posts/like', shouldBeSignin: false }, + { name: 'aggregation/posts/likes', shouldBeSignin: false }, + { name: 'aggregation/posts/repost', shouldBeSignin: false }, + { name: 'aggregation/posts/reply', shouldBeSignin: false }, + + { name: 'i', shouldBeSignin: true }, + { name: 'i/update', shouldBeSignin: true, limitDuration: day, limitMax: 50, kind: 'account-write' }, + { name: 'i/appdata/get', shouldBeSignin: true }, + { name: 'i/appdata/set', shouldBeSignin: true }, + { name: 'i/signin_history', shouldBeSignin: true, kind: 'account-read' }, + + { name: 'i/notifications', shouldBeSignin: true, kind: 'notification-read' }, + { name: 'notifications/delete', shouldBeSignin: true, kind: 'notification-write' }, + { name: 'notifications/delete_all', shouldBeSignin: true, kind: 'notification-write' }, + { name: 'notifications/mark_as_read', shouldBeSignin: true, kind: 'notification-write' }, + { name: 'notifications/mark_as_read_all', shouldBeSignin: true, kind: 'notification-write' }, + + { name: 'drive', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/stream', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/files', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/files/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, withFile: true, kind: 'drive-write' }, + { name: 'drive/files/show', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/files/find', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/files/delete', shouldBeSignin: true, kind: 'drive-write' }, + { name: 'drive/files/update', shouldBeSignin: true, kind: 'drive-write' }, + { name: 'drive/folders', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/folders/create', shouldBeSignin: true, limitDuration: hour, limitMax: 50, kind: 'drive-write' }, + { name: 'drive/folders/show', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/folders/find', shouldBeSignin: true, kind: 'drive-read' }, + { name: 'drive/folders/update', shouldBeSignin: true, kind: 'drive-write' }, + + { name: 'users', shouldBeSignin: false }, + { name: 'users/show', shouldBeSignin: false }, + { name: 'users/search', shouldBeSignin: false }, + { name: 'users/search_by_username', shouldBeSignin: false }, + { name: 'users/posts', shouldBeSignin: false }, + { name: 'users/following', shouldBeSignin: false }, + { name: 'users/followers', shouldBeSignin: false }, + { name: 'users/recommendation', shouldBeSignin: true, kind: 'account-read' }, + + { name: 'following/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'following-write' }, + { name: 'following/delete', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'following-write' }, + + { name: 'posts/show', shouldBeSignin: false }, + { name: 'posts/replies', shouldBeSignin: false }, + { name: 'posts/context', shouldBeSignin: false }, + { name: 'posts/create', shouldBeSignin: true, limitDuration: hour, limitMax: 120, minInterval: 1 * second, kind: 'post-write' }, + { name: 'posts/reposts', shouldBeSignin: false }, + { name: 'posts/search', shouldBeSignin: false }, + { name: 'posts/timeline', shouldBeSignin: true, limitDuration: 10 * minute, limitMax: 100 }, + { name: 'posts/mentions', shouldBeSignin: true, limitDuration: 10 * minute, limitMax: 100 }, + { name: 'posts/likes', shouldBeSignin: true }, + { name: 'posts/likes/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'like-write' }, + { name: 'posts/likes/delete', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'like-write' }, + { name: 'posts/favorites/create', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'favorite-write' }, + { name: 'posts/favorites/delete', shouldBeSignin: true, limitDuration: hour, limitMax: 100, kind: 'favorite-write' }, + + { name: 'messaging/history', shouldBeSignin: true, kind: 'messaging-read' }, + { name: 'messaging/unread', shouldBeSignin: true, kind: 'messaging-read' }, + { name: 'messaging/messages', shouldBeSignin: true, kind: 'messaging-read' }, + { name: 'messaging/messages/create', shouldBeSignin: true, kind: 'messaging-write' } + +] as IEndpoint[]; diff --git a/src/api/endpoints/aggregation/posts/like.js b/src/api/endpoints/aggregation/posts/like.js new file mode 100644 index 0000000000..b82c494ff1 --- /dev/null +++ b/src/api/endpoints/aggregation/posts/like.js @@ -0,0 +1,83 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../../models/post'; +import Like from '../../../models/like'; + +/** + * Aggregate like of a post + * + * @param {Object} params + * @return {Promise} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + const datas = await Like + .aggregate([ + { $match: { post_id: post._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data) + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: 0 + }) + }; + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/posts/likes.js b/src/api/endpoints/aggregation/posts/likes.js new file mode 100644 index 0000000000..0317245159 --- /dev/null +++ b/src/api/endpoints/aggregation/posts/likes.js @@ -0,0 +1,76 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../../models/post'; +import Like from '../../../models/like'; + +/** + * Aggregate likes of a post + * + * @param {Object} params + * @return {Promise} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1)); + + const likes = await Like + .find({ + post_id: post._id, + $or: [ + { deleted_at: { $exists: false } }, + { deleted_at: { $gt: startTime } } + ] + }, { + _id: false, + post_id: false + }, { + sort: { created_at: -1 } + }) + .toArray(); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + day = new Date(day.setMilliseconds(999)); + day = new Date(day.setSeconds(59)); + day = new Date(day.setMinutes(59)); + day = new Date(day.setHours(23)); + //day = day.getTime(); + + const count = likes.filter(l => + l.created_at < day && (l.deleted_at == null || l.deleted_at > day) + ).length; + + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: count + }); + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/posts/reply.js b/src/api/endpoints/aggregation/posts/reply.js new file mode 100644 index 0000000000..e578bc6d7d --- /dev/null +++ b/src/api/endpoints/aggregation/posts/reply.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../../models/post'; + +/** + * Aggregate reply of a post + * + * @param {Object} params + * @return {Promise} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + const datas = await Post + .aggregate([ + { $match: { reply_to: post._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data) + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: 0 + }) + }; + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/posts/repost.js b/src/api/endpoints/aggregation/posts/repost.js new file mode 100644 index 0000000000..38d63442a8 --- /dev/null +++ b/src/api/endpoints/aggregation/posts/repost.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../../models/post'; + +/** + * Aggregate repost of a post + * + * @param {Object} params + * @return {Promise} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + const datas = await Post + .aggregate([ + { $match: { repost_id: post._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data) + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: 0 + }) + }; + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/users/followers.js b/src/api/endpoints/aggregation/users/followers.js new file mode 100644 index 0000000000..16dda09675 --- /dev/null +++ b/src/api/endpoints/aggregation/users/followers.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../../models/user'; +import Following from '../../../models/following'; + +/** + * Aggregate followers of a user + * + * @param {Object} params + * @return {Promise} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1)); + + const following = await Following + .find({ + followee_id: user._id, + $or: [ + { deleted_at: { $exists: false } }, + { deleted_at: { $gt: startTime } } + ] + }, { + _id: false, + follower_id: false, + followee_id: false + }, { + sort: { created_at: -1 } + }) + .toArray(); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + day = new Date(day.setMilliseconds(999)); + day = new Date(day.setSeconds(59)); + day = new Date(day.setMinutes(59)); + day = new Date(day.setHours(23)); + // day = day.getTime(); + + const count = following.filter(f => + f.created_at < day && (f.deleted_at == null || f.deleted_at > day) + ).length; + + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: count + }); + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/users/following.js b/src/api/endpoints/aggregation/users/following.js new file mode 100644 index 0000000000..7b7448d715 --- /dev/null +++ b/src/api/endpoints/aggregation/users/following.js @@ -0,0 +1,76 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../../models/user'; +import Following from '../../../models/following'; + +/** + * Aggregate following of a user + * + * @param {Object} params + * @return {Promise} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + const startTime = new Date(new Date().setMonth(new Date().getMonth() - 1)); + + const following = await Following + .find({ + follower_id: user._id, + $or: [ + { deleted_at: { $exists: false } }, + { deleted_at: { $gt: startTime } } + ] + }, { + _id: false, + follower_id: false, + followee_id: false + }, { + sort: { created_at: -1 } + }) + .toArray(); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + day = new Date(day.setMilliseconds(999)); + day = new Date(day.setSeconds(59)); + day = new Date(day.setMinutes(59)); + day = new Date(day.setHours(23)); + + const count = following.filter(f => + f.created_at < day && (f.deleted_at == null || f.deleted_at > day) + ).length; + + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: count + }); + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/users/like.js b/src/api/endpoints/aggregation/users/like.js new file mode 100644 index 0000000000..830f1f1bba --- /dev/null +++ b/src/api/endpoints/aggregation/users/like.js @@ -0,0 +1,83 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../../models/user'; +import Like from '../../../models/like'; + +/** + * Aggregate like of a user + * + * @param {Object} params + * @return {Promise} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Like + .aggregate([ + { $match: { user_id: user._id } }, + { $project: { + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + } + }}, + { $group: { + _id: '$date', + count: { $sum: 1 } + }} + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data) + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + count: 0 + }) + }; + } + + res(graph); +}); diff --git a/src/api/endpoints/aggregation/users/post.js b/src/api/endpoints/aggregation/users/post.js new file mode 100644 index 0000000000..d75df30f5d --- /dev/null +++ b/src/api/endpoints/aggregation/users/post.js @@ -0,0 +1,113 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../../models/user'; +import Post from '../../../models/post'; + +/** + * Aggregate post of a user + * + * @param {Object} params + * @return {Promise} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + const datas = await Post + .aggregate([ + { $match: { user_id: user._id } }, + { $project: { + repost_id: '$repost_id', + reply_to_id: '$reply_to_id', + created_at: { $add: ['$created_at', 9 * 60 * 60 * 1000] } // Convert into JST + }}, + { $project: { + date: { + year: { $year: '$created_at' }, + month: { $month: '$created_at' }, + day: { $dayOfMonth: '$created_at' } + }, + type: { + $cond: { + if: { $ne: ['$repost_id', null] }, + then: 'repost', + else: { + $cond: { + if: { $ne: ['$reply_to_id', null] }, + then: 'reply', + else: 'post' + } + } + } + }} + }, + { $group: { _id: { + date: '$date', + type: '$type' + }, count: { $sum: 1 } } }, + { $group: { + _id: '$_id.date', + data: { $addToSet: { + type: '$_id.type', + count: '$count' + }} + } } + ]) + .toArray(); + + datas.forEach(data => { + data.date = data._id; + delete data._id; + + data.posts = (data.data.filter(x => x.type == 'post')[0] || { count: 0 }).count; + data.reposts = (data.data.filter(x => x.type == 'repost')[0] || { count: 0 }).count; + data.replies = (data.data.filter(x => x.type == 'reply')[0] || { count: 0 }).count; + + delete data.data; + }); + + const graph = []; + + for (let i = 0; i < 30; i++) { + let day = new Date(new Date().setDate(new Date().getDate() - i)); + + const data = datas.filter(d => + d.date.year == day.getFullYear() && d.date.month == day.getMonth() + 1 && d.date.day == day.getDate() + )[0]; + + if (data) { + graph.push(data) + } else { + graph.push({ + date: { + year: day.getFullYear(), + month: day.getMonth() + 1, // In JavaScript, month is zero-based. + day: day.getDate() + }, + posts: 0, + reposts: 0, + replies: 0 + }) + }; + } + + res(graph); +}); diff --git a/src/api/endpoints/app/create.js b/src/api/endpoints/app/create.js new file mode 100644 index 0000000000..d83062c8e9 --- /dev/null +++ b/src/api/endpoints/app/create.js @@ -0,0 +1,75 @@ +'use strict'; + +/** + * Module dependencies + */ +import rndstr from 'rndstr'; +import App from '../../models/app'; +import serialize from '../../serializers/app'; + +/** + * Create an app + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = async (params, user) => + new Promise(async (res, rej) => +{ + // Get 'name_id' parameter + const nameId = params.name_id; + if (nameId == null || nameId == '') { + return rej('name_id is required'); + } + + // Validate name_id + if (!/^[a-zA-Z0-9\-]{3,30}$/.test(nameId)) { + return rej('invalid name_id'); + } + + // Get 'name' parameter + const name = params.name; + if (name == null || name == '') { + return rej('name is required'); + } + + // Get 'description' parameter + const description = params.description; + if (description == null || description == '') { + return rej('description is required'); + } + + // Get 'permission' parameter + const permission = params.permission; + if (permission == null || permission == '') { + return rej('permission is required'); + } + + // Get 'callback_url' parameter + let callback = params.callback_url; + if (callback === '') { + callback = null; + } + + // Generate secret + const secret = rndstr('a-zA-Z0-9', 32); + + // Create account + const inserted = await App.insert({ + created_at: new Date(), + user_id: user._id, + name: name, + name_id: nameId, + name_id_lower: nameId.toLowerCase(), + description: description, + permission: permission.split(','), + callback_url: callback, + secret: secret + }); + + const app = inserted.ops[0]; + + // Response + res(await serialize(app)); +}); diff --git a/src/api/endpoints/app/name_id/available.js b/src/api/endpoints/app/name_id/available.js new file mode 100644 index 0000000000..179925dce4 --- /dev/null +++ b/src/api/endpoints/app/name_id/available.js @@ -0,0 +1,40 @@ +'use strict'; + +/** + * Module dependencies + */ +import App from '../../../models/app'; + +/** + * Check available name_id of app + * + * @param {Object} params + * @return {Promise} + */ +module.exports = async (params) => + new Promise(async (res, rej) => +{ + // Get 'name_id' parameter + const nameId = params.name_id; + if (nameId == null || nameId == '') { + return rej('name_id is required'); + } + + // Validate name_id + if (!/^[a-zA-Z0-9\-]{3,30}$/.test(nameId)) { + return rej('invalid name_id'); + } + + // Get exist + const exist = await App + .count({ + name_id_lower: nameId.toLowerCase() + }, { + limit: 1 + }); + + // Reply + res({ + available: exist === 0 + }); +}); diff --git a/src/api/endpoints/app/show.js b/src/api/endpoints/app/show.js new file mode 100644 index 0000000000..8d12f9aeb1 --- /dev/null +++ b/src/api/endpoints/app/show.js @@ -0,0 +1,51 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import App from '../../models/app'; +import serialize from '../../serializers/app'; + +/** + * Show an app + * + * @param {Object} params + * @param {Object} user + * @param {Object} _ + * @param {Object} isSecure + * @return {Promise} + */ +module.exports = (params, user, _, isSecure) => + new Promise(async (res, rej) => +{ + // Get 'app_id' parameter + let appId = params.app_id; + if (appId == null || appId == '') { + appId = null; + } + + // Get 'name_id' parameter + let nameId = params.name_id; + if (nameId == null || nameId == '') { + nameId = null; + } + + if (appId === null && nameId === null) { + return rej('app_id or name_id is required'); + } + + // Lookup app + const app = appId !== null + ? await App.findOne({ _id: new mongo.ObjectID(appId) }) + : await App.findOne({ name_id_lower: nameId.toLowerCase() }); + + if (app === null) { + return rej('app not found'); + } + + // Send response + res(await serialize(app, user, { + includeSecret: isSecure && app.user_id.equals(user._id) + })); +}); diff --git a/src/api/endpoints/auth/accept.js b/src/api/endpoints/auth/accept.js new file mode 100644 index 0000000000..7c45650c6b --- /dev/null +++ b/src/api/endpoints/auth/accept.js @@ -0,0 +1,64 @@ +'use strict'; + +/** + * Module dependencies + */ +import rndstr from 'rndstr'; +import AuthSess from '../../models/auth-session'; +import Userkey from '../../models/userkey'; + +/** + * Accept + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'token' parameter + const token = params.token; + if (token == null) { + return rej('token is required'); + } + + // Fetch token + const session = await AuthSess + .findOne({ token: token }); + + if (session === null) { + return rej('session not found'); + } + + // Generate userkey + const key = rndstr('a-zA-Z0-9', 32); + + // Fetch exist userkey + const exist = await Userkey.findOne({ + app_id: session.app_id, + user_id: user._id, + }); + + if (exist === null) { + // Insert userkey doc + await Userkey.insert({ + created_at: new Date(), + app_id: session.app_id, + user_id: user._id, + key: key + }); + } + + // Update session + await AuthSess.updateOne({ + _id: session._id + }, { + $set: { + user_id: user._id + } + }); + + // Response + res(); +}); diff --git a/src/api/endpoints/auth/session/generate.js b/src/api/endpoints/auth/session/generate.js new file mode 100644 index 0000000000..bb49cf090d --- /dev/null +++ b/src/api/endpoints/auth/session/generate.js @@ -0,0 +1,51 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as uuid from 'uuid'; +import App from '../../../models/app'; +import AuthSess from '../../../models/auth-session'; + +/** + * Generate a session + * + * @param {Object} params + * @return {Promise} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'app_secret' parameter + const appSecret = params.app_secret; + if (appSecret == null) { + return rej('app_secret is required'); + } + + // Lookup app + const app = await App.findOne({ + secret: appSecret + }); + + if (app == null) { + return rej('app not found'); + } + + // Generate token + const token = uuid.v4(); + + // Create session token document + const inserted = await AuthSess.insert({ + created_at: new Date(), + app_id: app._id, + token: token + }); + + const doc = inserted.ops[0]; + + // Response + res({ + token: doc.token, + url: `${config.auth_url}/${doc.token}` + }); +}); diff --git a/src/api/endpoints/auth/session/show.js b/src/api/endpoints/auth/session/show.js new file mode 100644 index 0000000000..67160c6993 --- /dev/null +++ b/src/api/endpoints/auth/session/show.js @@ -0,0 +1,36 @@ +'use strict'; + +/** + * Module dependencies + */ +import AuthSess from '../../../models/auth-session'; +import serialize from '../../../serializers/auth-session'; + +/** + * Show a session + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'token' parameter + const token = params.token; + if (token == null) { + return rej('token is required'); + } + + // Lookup session + const session = await AuthSess.findOne({ + token: token + }); + + if (session == null) { + return rej('session not found'); + } + + // Response + res(await serialize(session, user)); +}); diff --git a/src/api/endpoints/auth/session/userkey.js b/src/api/endpoints/auth/session/userkey.js new file mode 100644 index 0000000000..2626e4ce39 --- /dev/null +++ b/src/api/endpoints/auth/session/userkey.js @@ -0,0 +1,74 @@ +'use strict'; + +/** + * Module dependencies + */ +import App from '../../../models/app'; +import AuthSess from '../../../models/auth-session'; +import Userkey from '../../../models/userkey'; +import serialize from '../../../serializers/user'; + +/** + * Generate a session + * + * @param {Object} params + * @return {Promise} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'app_secret' parameter + const appSecret = params.app_secret; + if (appSecret == null) { + return rej('app_secret is required'); + } + + // Lookup app + const app = await App.findOne({ + secret: appSecret + }); + + if (app == null) { + return rej('app not found'); + } + + // Get 'token' parameter + const token = params.token; + if (token == null) { + return rej('token is required'); + } + + // Fetch token + const session = await AuthSess + .findOne({ + token: token, + app_id: app._id + }); + + if (session === null) { + return rej('session not found'); + } + + if (session.user_id == null) { + return rej('this session is not allowed yet'); + } + + // Lookup userkey + const userkey = await Userkey.findOne({ + app_id: app._id, + user_id: session.user_id + }); + + // Delete session + AuthSess.deleteOne({ + _id: session._id + }); + + // Response + res({ + userkey: userkey.key, + user: await serialize(session.user_id, null, { + detail: true + }) + }); +}); diff --git a/src/api/endpoints/drive.js b/src/api/endpoints/drive.js new file mode 100644 index 0000000000..4df4ac33fa --- /dev/null +++ b/src/api/endpoints/drive.js @@ -0,0 +1,33 @@ +'use strict'; + +/** + * Module dependencies + */ +import DriveFile from './models/drive-file'; + +/** + * Get drive information + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Fetch all files to calculate drive usage + const files = await DriveFile + .find({ user_id: user._id }, { + datasize: true, + _id: false + }) + .toArray(); + + // Calculate drive usage (in byte) + const usage = files.map(file => file.datasize).reduce((x, y) => x + y, 0); + + res({ + capacity: user.drive_capacity, + usage: usage + }); +}); diff --git a/src/api/endpoints/drive/files.js b/src/api/endpoints/drive/files.js new file mode 100644 index 0000000000..7e8ff59f2a --- /dev/null +++ b/src/api/endpoints/drive/files.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../../models/drive-file'; +import serialize from '../../serializers/drive-file'; + +/** + * Get drive files + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @return {Promise} + */ +module.exports = (params, user, app) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Get 'folder_id' parameter + let folder = params.folder_id; + if (folder === undefined || folder === null || folder === 'null') { + folder = null; + } else { + folder = new mongo.ObjectID(folder); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = { + user_id: user._id, + folder_id: folder + }; + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const files = await DriveFile + .find(query, { + data: false + }, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(files.map(async file => + await serialize(file)))); +}); diff --git a/src/api/endpoints/drive/files/create.js b/src/api/endpoints/drive/files/create.js new file mode 100644 index 0000000000..5966499c59 --- /dev/null +++ b/src/api/endpoints/drive/files/create.js @@ -0,0 +1,59 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as fs from 'fs'; +import * as mongo from 'mongodb'; +import File from '../../../models/drive-file'; +import { validateFileName } from '../../../models/drive-file'; +import User from '../../../models/user'; +import serialize from '../../../serializers/drive-file'; +import create from '../../../common/add-file-to-drive'; + +/** + * Create a file + * + * @param {Object} file + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (file, params, user) => + new Promise(async (res, rej) => +{ + const buffer = fs.readFileSync(file.path); + fs.unlink(file.path); + + // Get 'name' parameter + let name = file.originalname; + if (name !== undefined && name !== null) { + name = name.trim(); + if (name.length === 0) { + name = null; + } else if (name === 'blob') { + name = null; + } else if (!validateFileName(name)) { + return rej('invalid name'); + } + } else { + name = null; + } + + // Get 'folder_id' parameter + let folder = params.folder_id; + if (folder === undefined || folder === null || folder === 'null') { + folder = null; + } else { + folder = new mongo.ObjectID(folder); + } + + // Create file + const driveFile = await create(user, buffer, name, null, folder); + + // Serialize + const fileObj = await serialize(driveFile); + + // Response + res(fileObj); +}); diff --git a/src/api/endpoints/drive/files/find.js b/src/api/endpoints/drive/files/find.js new file mode 100644 index 0000000000..e4e4c230d2 --- /dev/null +++ b/src/api/endpoints/drive/files/find.js @@ -0,0 +1,48 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../../../models/drive-file'; +import serialize from '../../../serializers/drive-file'; + +/** + * Find a file(s) + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'name' parameter + const name = params.name; + if (name === undefined || name === null) { + return rej('name is required'); + } + + // Get 'folder_id' parameter + let folder = params.folder_id; + if (folder === undefined || folder === null || folder === 'null') { + folder = null; + } else { + folder = new mongo.ObjectID(folder); + } + + // Issue query + const files = await DriveFile + .find({ + name: name, + user_id: user._id, + folder_id: folder + }, { + data: false + }) + .toArray(); + + // Serialize + res(await Promise.all(files.map(async file => + await serialize(file)))); +}); diff --git a/src/api/endpoints/drive/files/show.js b/src/api/endpoints/drive/files/show.js new file mode 100644 index 0000000000..79b07dace2 --- /dev/null +++ b/src/api/endpoints/drive/files/show.js @@ -0,0 +1,40 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../../../models/drive-file'; +import serialize from '../../../serializers/drive-file'; + +/** + * Show a file + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'file_id' parameter + const fileId = params.file_id; + if (fileId === undefined || fileId === null) { + return rej('file_id is required'); + } + + const file = await DriveFile + .findOne({ + _id: new mongo.ObjectID(fileId), + user_id: user._id + }, { + data: false + }); + + if (file === null) { + return rej('file-not-found'); + } + + // Serialize + res(await serialize(file)); +}); diff --git a/src/api/endpoints/drive/files/update.js b/src/api/endpoints/drive/files/update.js new file mode 100644 index 0000000000..bbcb10b42d --- /dev/null +++ b/src/api/endpoints/drive/files/update.js @@ -0,0 +1,89 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import DriveFile from '../../../models/drive-file'; +import { validateFileName } from '../../../models/drive-file'; +import serialize from '../../../serializers/drive-file'; +import event from '../../../event'; + +/** + * Update a file + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'file_id' parameter + const fileId = params.file_id; + if (fileId === undefined || fileId === null) { + return rej('file_id is required'); + } + + const file = await DriveFile + .findOne({ + _id: new mongo.ObjectID(fileId), + user_id: user._id + }, { + data: false + }); + + if (file === null) { + return rej('file-not-found'); + } + + // Get 'name' parameter + let name = params.name; + if (name) { + name = name.trim(); + if (validateFileName(name)) { + file.name = name; + } else { + return rej('invalid file name'); + } + } + + // Get 'folder_id' parameter + let folderId = params.folder_id; + if (folderId !== undefined && folderId !== 'null') { + folderId = new mongo.ObjectID(folderId); + } + + let folder = null; + if (folderId !== undefined && folderId !== null) { + if (folderId === 'null') { + file.folder_id = null; + } else { + folder = await DriveFolder + .findOne({ + _id: folderId, + user_id: user._id + }); + + if (folder === null) { + return reject('folder-not-found'); + } + + file.folder_id = folder._id; + } + } + + DriveFile.updateOne({ _id: file._id }, { + $set: file + }); + + // Serialize + const fileObj = await serialize(file); + + // Response + res(fileObj); + + // Publish drive_file_updated event + event(user._id, 'drive_file_updated', fileObj); +}); diff --git a/src/api/endpoints/drive/folders.js b/src/api/endpoints/drive/folders.js new file mode 100644 index 0000000000..f95a60036f --- /dev/null +++ b/src/api/endpoints/drive/folders.js @@ -0,0 +1,82 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../models/drive-folder'; +import serialize from '../../serializers/drive-folder'; + +/** + * Get drive folders + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @return {Promise} + */ +module.exports = (params, user, app) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Get 'folder_id' parameter + let folder = params.folder_id; + if (folder === undefined || folder === null || folder === 'null') { + folder = null; + } else { + folder = new mongo.ObjectID(folder); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = { + user_id: user._id, + parent_id: folder + }; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const folders = await DriveFolder + .find(query, { + data: false + }, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(folders.map(async folder => + await serialize(folder)))); +}); diff --git a/src/api/endpoints/drive/folders/create.js b/src/api/endpoints/drive/folders/create.js new file mode 100644 index 0000000000..ba40d1763e --- /dev/null +++ b/src/api/endpoints/drive/folders/create.js @@ -0,0 +1,79 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import { isValidFolderName } from '../../../models/drive-folder'; +import serialize from '../../../serializers/drive-folder'; +import event from '../../../event'; + +/** + * Create drive folder + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'name' parameter + let name = params.name; + if (name !== undefined && name !== null) { + name = name.trim(); + if (name.length === 0) { + name = null; + } else if (!isValidFolderName(name)) { + return rej('invalid name'); + } + } else { + name = null; + } + + if (name == null) { + name = '無題のフォルダー'; + } + + // Get 'folder_id' parameter + let parentId = params.folder_id; + if (parentId === undefined || parentId === null) { + parentId = null; + } else { + parentId = new mongo.ObjectID(parentId); + } + + // If the parent folder is specified + let parent = null; + if (parentId !== null) { + parent = await DriveFolder + .findOne({ + _id: parentId, + user_id: user._id + }); + + if (parent === null) { + return reject('parent-not-found'); + } + } + + // Create folder + const inserted = await DriveFolder.insert({ + created_at: new Date(), + name: name, + parent_id: parent !== null ? parent._id : null, + user_id: user._id + }); + + const folder = inserted.ops[0]; + + // Serialize + const folderObj = await serialize(folder); + + // Response + res(folderObj); + + // Publish drive_folder_created event + event(user._id, 'drive_folder_created', folderObj); +}); diff --git a/src/api/endpoints/drive/folders/find.js b/src/api/endpoints/drive/folders/find.js new file mode 100644 index 0000000000..01805dc910 --- /dev/null +++ b/src/api/endpoints/drive/folders/find.js @@ -0,0 +1,46 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import serialize from '../../../serializers/drive-folder'; + +/** + * Find a folder(s) + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'name' parameter + const name = params.name; + if (name === undefined || name === null) { + return rej('name is required'); + } + + // Get 'parent_id' parameter + let parentId = params.parent_id; + if (parentId === undefined || parentId === null || parentId === 'null') { + parentId = null; + } else { + parentId = new mongo.ObjectID(parentId); + } + + // Issue query + const folders = await DriveFolder + .find({ + name: name, + user_id: user._id, + parent_id: parentId + }) + .toArray(); + + // Serialize + res(await Promise.all(folders.map(async folder => + await serialize(folder)))); +}); diff --git a/src/api/endpoints/drive/folders/show.js b/src/api/endpoints/drive/folders/show.js new file mode 100644 index 0000000000..4424361a87 --- /dev/null +++ b/src/api/endpoints/drive/folders/show.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import serialize from '../../../serializers/drive-folder'; + +/** + * Show a folder + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'folder_id' parameter + const folderId = params.folder_id; + if (folderId === undefined || folderId === null) { + return rej('folder_id is required'); + } + + // Get folder + const folder = await DriveFolder + .findOne({ + _id: new mongo.ObjectID(folderId), + user_id: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + // Serialize + res(await serialize(folder, { + includeParent: true + })); +}); diff --git a/src/api/endpoints/drive/folders/update.js b/src/api/endpoints/drive/folders/update.js new file mode 100644 index 0000000000..ff26a09aae --- /dev/null +++ b/src/api/endpoints/drive/folders/update.js @@ -0,0 +1,114 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../../../models/drive-folder'; +import { isValidFolderName } from '../../../models/drive-folder'; +import serialize from '../../../serializers/drive-file'; +import event from '../../../event'; + +/** + * Update a folder + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'folder_id' parameter + const folderId = params.folder_id; + if (folderId === undefined || folderId === null) { + return rej('folder_id is required'); + } + + // Fetch folder + const folder = await DriveFolder + .findOne({ + _id: new mongo.ObjectID(folderId), + user_id: user._id + }); + + if (folder === null) { + return rej('folder-not-found'); + } + + // Get 'name' parameter + let name = params.name; + if (name) { + name = name.trim(); + if (isValidFolderName(name)) { + folder.name = name; + } else { + return rej('invalid folder name'); + } + } + + // Get 'parent_id' parameter + let parentId = params.parent_id; + if (parentId !== undefined && parentId !== 'null') { + parentId = new mongo.ObjectID(parentId); + } + + let parent = null; + if (parentId !== undefined && parentId !== null) { + if (parentId === 'null') { + folder.parent_id = null; + } else { + // Get parent folder + parent = await DriveFolder + .findOne({ + _id: parentId, + user_id: user._id + }); + + if (parent === null) { + return rej('parent-folder-not-found'); + } + + // Check if the circular reference will be occured + async function checkCircle(folderId) { + // Fetch folder + const folder2 = await DriveFolder.findOne({ + _id: folderId + }, { + _id: true, + parent_id: true + }); + + if (folder2._id.equals(folder._id)) { + return true; + } else if (folder2.parent_id) { + return await checkCircle(folder2.parent_id); + } else { + return false; + } + } + + if (parent.parent_id !== null) { + if (await checkCircle(parent.parent_id)) { + return rej('detected-circular-definition'); + } + } + + folder.parent_id = parent._id; + } + } + + // Update + DriveFolder.updateOne({ _id: folder._id }, { + $set: folder + }); + + // Serialize + const folderObj = await serialize(folder); + + // Response + res(folderObj); + + // Publish drive_folder_updated event + event(user._id, 'drive_folder_updated', folderObj); +}); diff --git a/src/api/endpoints/drive/stream.js b/src/api/endpoints/drive/stream.js new file mode 100644 index 0000000000..0f407f5591 --- /dev/null +++ b/src/api/endpoints/drive/stream.js @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../../models/drive-file'; +import serialize from '../../serializers/drive-file'; + +/** + * Get drive stream + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Get 'type' parameter + let type = params.type; + if (type === undefined || type === null) { + type = null; + } else if (!/^[a-zA-Z\/\-\*]+$/.test(type)) { + return rej('invalid type format'); + } else { + type = new RegExp(`^${type.replace(/\*/g, '.+?')}$`); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = { + user_id: user._id + }; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + if (type !== null) { + query.type = type; + } + + // Issue query + const files = await DriveFile + .find(query, { + data: false + }, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(files.map(async file => + await serialize(file)))); +}); diff --git a/src/api/endpoints/following/create.js b/src/api/endpoints/following/create.js new file mode 100644 index 0000000000..da714cb180 --- /dev/null +++ b/src/api/endpoints/following/create.js @@ -0,0 +1,86 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import Following from '../../models/following'; +import notify from '../../common/notify'; +import event from '../../event'; +import serializeUser from '../../serializers/user'; + +/** + * Follow a user + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + const follower = user; + + // Get 'user_id' parameter + let userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // 自分自身 + if (user._id.equals(userId)) { + return rej('followee is yourself'); + } + + // Get followee + const followee = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (followee === null) { + return rej('user not found'); + } + + // Check arleady following + const exist = await Following.findOne({ + follower_id: follower._id, + followee_id: followee._id, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return rej('already following'); + } + + // Create following + await Following.insert({ + created_at: new Date(), + follower_id: follower._id, + followee_id: followee._id + }); + + // Send response + res(); + + // Increment following count + User.updateOne({ _id: follower._id }, { + $inc: { + following_count: 1 + } + }); + + // Increment followers count + User.updateOne({ _id: followee._id }, { + $inc: { + followers_count: 1 + } + }); + + // Publish follow event + event(follower._id, 'follow', await serializeUser(followee, follower)); + event(followee._id, 'followed', await serializeUser(follower, followee)); + + // Notify + notify(followee._id, follower._id, 'follow'); +}); diff --git a/src/api/endpoints/following/delete.js b/src/api/endpoints/following/delete.js new file mode 100644 index 0000000000..f1096801b6 --- /dev/null +++ b/src/api/endpoints/following/delete.js @@ -0,0 +1,83 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import Following from '../../models/following'; +import event from '../../event'; +import serializeUser from '../../serializers/user'; + +/** + * Unfollow a user + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + const follower = user; + + // Get 'user_id' parameter + let userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Check if the followee is yourself + if (user._id.equals(userId)) { + return rej('followee is yourself'); + } + + // Get followee + const followee = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (followee === null) { + return rej('user not found'); + } + + // Check not following + const exist = await Following.findOne({ + follower_id: follower._id, + followee_id: followee._id, + deleted_at: { $exists: false } + }); + + if (exist === null) { + return rej('already not following'); + } + + // Delete following + await Following.updateOne({ + _id: exist._id + }, { + $set: { + deleted_at: new Date() + } + }); + + // Send response + res(); + + // Decrement following count + User.updateOne({ _id: follower._id }, { + $inc: { + following_count: -1 + } + }); + + // Decrement followers count + User.updateOne({ _id: followee._id }, { + $inc: { + followers_count: -1 + } + }); + + // Publish follow event + event(follower._id, 'unfollow', await serializeUser(followee, follower)); +}); diff --git a/src/api/endpoints/i.js b/src/api/endpoints/i.js new file mode 100644 index 0000000000..481ddbb9fa --- /dev/null +++ b/src/api/endpoints/i.js @@ -0,0 +1,25 @@ +'use strict'; + +/** + * Module dependencies + */ +import serialize from '../serializers/user'; + +/** + * Show myself + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @param {Boolean} isSecure + * @return {Promise} + */ +module.exports = (params, user, _, isSecure) => + new Promise(async (res, rej) => +{ + // Serialize + res(await serialize(user, user, { + detail: true, + includeSecrets: isSecure + })); +}); diff --git a/src/api/endpoints/i/appdata/get.js b/src/api/endpoints/i/appdata/get.js new file mode 100644 index 0000000000..0a86697469 --- /dev/null +++ b/src/api/endpoints/i/appdata/get.js @@ -0,0 +1,53 @@ +'use strict'; + +/** + * Module dependencies + */ +import Appdata from '../../../models/appdata'; + +/** + * Get app data + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @param {Boolean} isSecure + * @return {Promise} + */ +module.exports = (params, user, app, isSecure) => + new Promise(async (res, rej) => +{ + // Get 'key' parameter + let key = params.key; + if (key === undefined) { + key = null; + } + + if (isSecure) { + if (!user.data) { + return res(); + } + if (key !== null) { + const data = {}; + data[key] = user.data[key]; + res(data); + } else { + res(user.data); + } + } else { + const select = {}; + if (key !== null) { + select['data.' + key] = true; + } + const appdata = await Appdata.findOne({ + app_id: app._id, + user_id: user._id + }, select); + + if (appdata) { + res(appdata.data); + } else { + res(); + } + } +}); diff --git a/src/api/endpoints/i/appdata/set.js b/src/api/endpoints/i/appdata/set.js new file mode 100644 index 0000000000..e161a803d0 --- /dev/null +++ b/src/api/endpoints/i/appdata/set.js @@ -0,0 +1,55 @@ +'use strict'; + +/** + * Module dependencies + */ +import Appdata from '../../../models/appdata'; +import User from '../../../models/user'; + +/** + * Set app data + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @param {Boolean} isSecure + * @return {Promise} + */ +module.exports = (params, user, app, isSecure) => + new Promise(async (res, rej) => +{ + const data = params.data; + if (data == null) { + return rej('data is required'); + } + + if (isSecure) { + const set = { + $set: { + data: Object.assign(user.data || {}, JSON.parse(data)) + } + }; + await User.updateOne({ _id: user._id }, set); + res(204); + } else { + const appdata = await Appdata.findOne({ + app_id: app._id, + user_id: user._id + }); + const set = { + $set: { + data: Object.assign((appdata || {}).data || {}, JSON.parse(data)) + } + }; + await Appdata.updateOne({ + app_id: app._id, + user_id: user._id + }, Object.assign({ + app_id: app._id, + user_id: user._id + }, set), { + upsert: true + }); + res(204); + } +}); diff --git a/src/api/endpoints/i/favorites.js b/src/api/endpoints/i/favorites.js new file mode 100644 index 0000000000..e30ea2867b --- /dev/null +++ b/src/api/endpoints/i/favorites.js @@ -0,0 +1,60 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Favorite from '../../models/favorite'; +import serialize from '../../serializers/post'; + +/** + * Get followers of a user + * + * @param {Object} params + * @return {Promise} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'sort' parameter + let sort = params.sort || 'desc'; + + // Get favorites + const favorites = await Favorites + .find({ + user_id: user._id + }, {}, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(favorites.map(async favorite => + await serialize(favorite.post) + ))); +}); diff --git a/src/api/endpoints/i/notifications.js b/src/api/endpoints/i/notifications.js new file mode 100644 index 0000000000..a28ceb76a0 --- /dev/null +++ b/src/api/endpoints/i/notifications.js @@ -0,0 +1,120 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Notification from '../../models/notification'; +import serialize from '../../serializers/notification'; +import getFriends from '../../common/get-friends'; + +/** + * Get notifications + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'following' parameter + const following = params.following === 'true'; + + // Get 'mark_as_read' parameter + let markAsRead = params.mark_as_read; + if (markAsRead == null) { + markAsRead = true; + } else { + markAsRead = markAsRead === 'true'; + } + + // Get 'type' parameter + let type = params.type; + if (type !== undefined && type !== null) { + type = type.split(',').map(x => x.trim()); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + const query = { + notifiee_id: user._id + }; + + const sort = { + _id: -1 + }; + + if (following) { + // ID list of the user itself and other users who the user follows + const followingIds = await getFriends(user._id); + + query.notifier_id = { + $in: followingIds + }; + } + + if (type) { + query.type = { + $in: type + }; + } + + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const notifications = await Notification + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(notifications.map(async notification => + await serialize(notification)))); + + // Mark as read all + if (notifications.length > 0 && markAsRead) { + const ids = notifications + .filter(x => x.is_read == false) + .map(x => x._id); + + // Update documents + await Notification.update({ + _id: { $in: ids } + }, { + $set: { is_read: true } + }, { + multi: true + }); + } +}); diff --git a/src/api/endpoints/i/signin_history.js b/src/api/endpoints/i/signin_history.js new file mode 100644 index 0000000000..7def8a41e5 --- /dev/null +++ b/src/api/endpoints/i/signin_history.js @@ -0,0 +1,71 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Signin from '../../models/signin'; +import serialize from '../../serializers/signin'; + +/** + * Get signin history of my account + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + const query = { + user_id: user._id + }; + + const sort = { + _id: -1 + }; + + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const history = await Signin + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(history.map(async record => + await serialize(record)))); +}); diff --git a/src/api/endpoints/i/update.js b/src/api/endpoints/i/update.js new file mode 100644 index 0000000000..a6b68cf01e --- /dev/null +++ b/src/api/endpoints/i/update.js @@ -0,0 +1,95 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import serialize from '../../serializers/user'; +import event from '../../event'; + +/** + * Update myself + * + * @param {Object} params + * @param {Object} user + * @param {Object} _ + * @param {boolean} isSecure + * @return {Promise} + */ +module.exports = async (params, user, _, isSecure) => + new Promise(async (res, rej) => +{ + // Get 'name' parameter + const name = params.name; + if (name !== undefined && name !== null) { + if (name.length > 50) { + return rej('too long name'); + } + + user.name = name; + } + + // Get 'location' parameter + const location = params.location; + if (location !== undefined && location !== null) { + if (location.length > 50) { + return rej('too long location'); + } + + user.location = location; + } + + // Get 'bio' parameter + const bio = params.bio; + if (bio !== undefined && bio !== null) { + if (bio.length > 500) { + return rej('too long bio'); + } + + user.bio = bio; + } + + // Get 'avatar_id' parameter + const avatar = params.avatar_id; + if (avatar !== undefined && avatar !== null) { + user.avatar_id = new mongo.ObjectID(avatar); + } + + // Get 'banner_id' parameter + const banner = params.banner_id; + if (banner !== undefined && banner !== null) { + user.banner_id = new mongo.ObjectID(banner); + } + + await User.updateOne({ _id: user._id }, { + $set: user + }); + + // Serialize + const iObj = await serialize(user, user, { + detail: true, + includeSecrets: isSecure + }) + + // Send response + res(iObj); + + // Publish i updated event + event(user._id, 'i_updated', iObj); + + // Update search index + if (config.elasticsearch.enable) { + const es = require('../../../db/elasticsearch'); + + es.index({ + index: 'misskey', + type: 'user', + id: user._id.toString(), + body: { + name: user.name, + bio: user.bio + } + }); + } +}); diff --git a/src/api/endpoints/messaging/history.js b/src/api/endpoints/messaging/history.js new file mode 100644 index 0000000000..dafb38fd1a --- /dev/null +++ b/src/api/endpoints/messaging/history.js @@ -0,0 +1,48 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import History from '../../models/messaging-history'; +import serialize from '../../serializers/messaging-message'; + +/** + * Show messaging history + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get history + const history = await History + .find({ + user_id: user._id + }, {}, { + limit: limit, + sort: { + updated_at: -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(history.map(async h => + await serialize(h.message, user)))); +}); diff --git a/src/api/endpoints/messaging/messages.js b/src/api/endpoints/messaging/messages.js new file mode 100644 index 0000000000..12bd13597a --- /dev/null +++ b/src/api/endpoints/messaging/messages.js @@ -0,0 +1,139 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Message from '../../models/messaging-message'; +import User from '../../models/user'; +import serialize from '../../serializers/messaging-message'; +import publishUserStream from '../../event'; +import { publishMessagingStream } from '../../event'; + +/** + * Get messages + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + let recipient = params.user_id; + if (recipient !== undefined && recipient !== null) { + recipient = await User.findOne({ + _id: new mongo.ObjectID(recipient) + }); + + if (recipient === null) { + return rej('user not found'); + } + } else { + return rej('user_id is required'); + } + + // Get 'mark_as_read' parameter + let markAsRead = params.mark_as_read; + if (markAsRead == null) { + markAsRead = true; + } else { + markAsRead = markAsRead === 'true'; + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + const query = { + $or: [{ + user_id: user._id, + recipient_id: recipient._id + }, { + user_id: recipient._id, + recipient_id: user._id + }] + }; + + const sort = { + created_at: -1 + }; + + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const messages = await Message + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(messages.map(async message => + await serialize(message, user, { + populateRecipient: false + })))); + + if (messages.length === 0) { + return; + } + + // Mark as read all + if (markAsRead) { + const ids = messages + .filter(m => m.is_read == false) + .filter(m => m.recipient_id.equals(user._id)) + .map(m => m._id); + + // Update documents + await Message.update({ + _id: { $in: ids } + }, { + $set: { is_read: true } + }, { + multi: true + }); + + // Publish event + publishMessagingStream(recipient._id, user._id, 'read', ids.map(id => id.toString())); + + const count = await Message + .count({ + recipient_id: user._id, + is_read: false + }); + + if (count == 0) { + // 全ての(いままで未読だった)メッセージを(これで)読みましたよというイベントを発行 + publishUserStream(user._id, 'read_all_messaging_messages'); + } + } +}); diff --git a/src/api/endpoints/messaging/messages/create.js b/src/api/endpoints/messaging/messages/create.js new file mode 100644 index 0000000000..33634a6140 --- /dev/null +++ b/src/api/endpoints/messaging/messages/create.js @@ -0,0 +1,152 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Message from '../../../models/messaging-message'; +import History from '../../../models/messaging-history'; +import User from '../../../models/user'; +import DriveFile from '../../../models/drive-file'; +import serialize from '../../../serializers/messaging-message'; +import publishUserStream from '../../../event'; +import { publishMessagingStream } from '../../../event'; + +/** + * 最大文字数 + */ +const maxTextLength = 500; + +/** + * Create a message + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + let recipient = params.user_id; + if (recipient !== undefined && recipient !== null) { + recipient = await User.findOne({ + _id: new mongo.ObjectID(recipient) + }); + + if (recipient === null) { + return rej('user not found'); + } + } else { + return rej('user_id is required'); + } + + // Get 'text' parameter + let text = params.text; + if (text !== undefined && text !== null) { + text = text.trim(); + if (text.length === 0) { + text = null; + } else if (text.length > maxTextLength) { + return rej('too long text'); + } + } else { + text = null; + } + + // Get 'file_id' parameter + let file = params.file_id; + if (file !== undefined && file !== null) { + file = await DriveFile.findOne({ + _id: new mongo.ObjectID(file), + user_id: user._id + }, { + data: false + }); + + if (file === null) { + return rej('file not found'); + } + } else { + file = null; + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (text === null && file === null) { + return rej('text or file is required'); + } + + // メッセージを作成 + const inserted = await Message.insert({ + created_at: new Date(), + file_id: file ? file._id : undefined, + recipient_id: recipient._id, + text: text ? text : undefined, + user_id: user._id, + is_read: false + }); + + const message = inserted.ops[0]; + + // Serialize + const messageObj = await serialize(message); + + // Reponse + res(messageObj); + + // 自分のストリーム + publishMessagingStream(message.user_id, message.recipient_id, 'message', messageObj); + publishUserStream(message.user_id, 'messaging_message', messageObj); + + // 相手のストリーム + publishMessagingStream(message.recipient_id, message.user_id, 'message', messageObj); + publishUserStream(message.recipient_id, 'messaging_message', messageObj); + + // 5秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する + setTimeout(async () => { + const freshMessage = await Message.findOne({ _id: message._id }, { is_read: true }); + if (!freshMessage.is_read) { + publishUserStream(message.recipient_id, 'unread_messaging_message', messageObj); + } + }, 5000); + + // Register to search database + if (message.text && config.elasticsearch.enable) { + const es = require('../../../db/elasticsearch'); + + es.index({ + index: 'misskey', + type: 'messaging_message', + id: message._id.toString(), + body: { + text: message.text + } + }); + } + + // 履歴作成(自分) + History.updateOne({ + user_id: user._id, + partner: recipient._id + }, { + updated_at: new Date(), + user_id: user._id, + partner: recipient._id, + message: message._id + }, { + upsert: true + }); + + // 履歴作成(相手) + History.updateOne({ + user_id: recipient._id, + partner: user._id + }, { + updated_at: new Date(), + user_id: recipient._id, + partner: user._id, + message: message._id + }, { + upsert: true + }); +}); diff --git a/src/api/endpoints/messaging/unread.js b/src/api/endpoints/messaging/unread.js new file mode 100644 index 0000000000..d2de0bc448 --- /dev/null +++ b/src/api/endpoints/messaging/unread.js @@ -0,0 +1,27 @@ +'use strict'; + +/** + * Module dependencies + */ +import Message from '../../models/messaging-message'; + +/** + * Get count of unread messages + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + const count = await Message + .count({ + recipient_id: user._id, + is_read: false + }); + + res({ + count: count + }); +}); diff --git a/src/api/endpoints/meta.js b/src/api/endpoints/meta.js new file mode 100644 index 0000000000..7938cb91b4 --- /dev/null +++ b/src/api/endpoints/meta.js @@ -0,0 +1,24 @@ +'use strict'; + +/** + * Module dependencies + */ +import Git from 'nodegit'; + +/** + * Show core info + * + * @param {Object} params + * @return {Promise} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + const repository = await Git.Repository.open(__dirname + '/../../'); + + res({ + maintainer: config.maintainer, + commit: (await repository.getHeadCommit()).sha(), + secure: config.https.enable + }); +}); diff --git a/src/api/endpoints/my/apps.js b/src/api/endpoints/my/apps.js new file mode 100644 index 0000000000..d23bc38b1c --- /dev/null +++ b/src/api/endpoints/my/apps.js @@ -0,0 +1,59 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import App from '../../models/app'; +import serialize from '../../serializers/app'; + +/** + * Get my apps + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + const query = { + user_id: user._id + }; + + // Execute query + const apps = await App + .find(query, {}, { + limit: limit, + skip: offset, + sort: { + created_at: -1 + } + }) + .toArray(); + + // Reply + res(await Promise.all(apps.map(async app => + await serialize(app)))); +}); diff --git a/src/api/endpoints/notifications/mark_as_read.js b/src/api/endpoints/notifications/mark_as_read.js new file mode 100644 index 0000000000..16eb2009ac --- /dev/null +++ b/src/api/endpoints/notifications/mark_as_read.js @@ -0,0 +1,54 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Notification from '../../../models/notification'; +import serialize from '../../../serializers/notification'; +import event from '../../../event'; + +/** + * Mark as read a notification + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + const notificationId = params.notification; + + if (notificationId === undefined || notificationId === null) { + return rej('notification is required'); + } + + // Get notifcation + const notification = await Notification + .findOne({ + _id: new mongo.ObjectID(notificationId), + i: user._id + }); + + if (notification === null) { + return rej('notification-not-found'); + } + + // Update + notification.is_read = true; + Notification.updateOne({ _id: notification._id }, { + $set: { + is_read: true + } + }); + + // Response + res(); + + // Serialize + const notificationObj = await serialize(notification); + + // Publish read_notification event + event(user._id, 'read_notification', notificationObj); +}); diff --git a/src/api/endpoints/posts.js b/src/api/endpoints/posts.js new file mode 100644 index 0000000000..05fc871ec1 --- /dev/null +++ b/src/api/endpoints/posts.js @@ -0,0 +1,65 @@ +'use strict'; + +/** + * Module dependencies + */ +import Post from '../models/post'; +import serialize from '../serializers/post'; + +/** + * Lists all posts + * + * @param {Object} params + * @return {Promise} + */ +module.exports = (params) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = {}; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const posts = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(posts.map(async post => await serialize(post)))); +}); diff --git a/src/api/endpoints/posts/context.js b/src/api/endpoints/posts/context.js new file mode 100644 index 0000000000..5f040b8505 --- /dev/null +++ b/src/api/endpoints/posts/context.js @@ -0,0 +1,83 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a context of a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found', 'POST_NOT_FOUND'); + } + + const context = []; + let i = 0; + + async function get(id) { + i++; + const p = await Post.findOne({ _id: id }); + + if (i > offset) { + context.push(p); + } + + if (context.length == limit) { + return; + } + + if (p.reply_to_id) { + await get(p.reply_to_id); + } + } + + if (post.reply_to_id) { + await get(post.reply_to_id); + } + + // Serialize + res(await Promise.all(context.map(async post => + await serialize(post, user)))); +}); diff --git a/src/api/endpoints/posts/create.js b/src/api/endpoints/posts/create.js new file mode 100644 index 0000000000..cdcbf4f966 --- /dev/null +++ b/src/api/endpoints/posts/create.js @@ -0,0 +1,345 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import parse from '../../../common/text'; +import Post from '../../models/post'; +import User from '../../models/user'; +import Following from '../../models/following'; +import DriveFile from '../../models/drive-file'; +import serialize from '../../serializers/post'; +import createFile from '../../common/add-file-to-drive'; +import notify from '../../common/notify'; +import event from '../../event'; + +/** + * 最大文字数 + */ +const maxTextLength = 300; + +/** + * 添付できるファイルの数 + */ +const maxMediaCount = 4; + +/** + * Create a post + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @return {Promise} + */ +module.exports = (params, user, app) => + new Promise(async (res, rej) => +{ + // Get 'text' parameter + let text = params.text; + if (text !== undefined && text !== null) { + text = text.trim(); + if (text.length == 0) { + text = null; + } else if (text.length > maxTextLength) { + return rej('too long text'); + } + } else { + text = null; + } + + // Get 'media_ids' parameter + let media = params.media_ids; + let files = []; + if (media !== undefined && media !== null) { + media = media.split(','); + + if (media.length > maxMediaCount) { + return rej('too many media'); + } + + // Drop duplicates + media = media.filter((x, i, s) => s.indexOf(x) == i); + + // Fetch files + // forEach だと途中でエラーなどがあっても return できないので + // 敢えて for を使っています。 + for (let i = 0; i < media.length; i++) { + const image = media[i]; + + // Fetch file + // SELECT _id + const entity = await DriveFile.findOne({ + _id: new mongo.ObjectID(image), + user_id: user._id + }, { + _id: true + }); + + if (entity === null) { + return rej('file not found'); + } else { + files.push(entity); + } + } + } else { + files = null; + } + + // Get 'repost_id' parameter + let repost = params.repost_id; + if (repost !== undefined && repost !== null) { + // Fetch repost to post + repost = await Post.findOne({ + _id: new mongo.ObjectID(repost) + }); + + if (repost == null) { + return rej('repostee is not found'); + } else if (repost.repost_id && !repost.text && !repost.media_ids) { + return rej('cannot repost to repost'); + } + + // Fetch recently post + const latestPost = await Post.findOne({ + user_id: user._id + }, {}, { + sort: { + _id: -1 + } + }); + + // 直近と同じRepost対象かつ引用じゃなかったらエラー + if (latestPost && + latestPost.repost_id && + latestPost.repost_id.equals(repost._id) && + text === null && files === null) { + return rej('二重Repostです(NEED TRANSLATE)'); + } + + // 直近がRepost対象かつ引用じゃなかったらエラー + if (latestPost && + latestPost._id.equals(repost._id) && + text === null && files === null) { + return rej('二重Repostです(NEED TRANSLATE)'); + } + } else { + repost = null; + } + + // Get 'reply_to_id' parameter + let replyTo = params.reply_to_id; + if (replyTo !== undefined && replyTo !== null) { + replyTo = await Post.findOne({ + _id: new mongo.ObjectID(replyTo) + }); + + if (replyTo === null) { + return rej('reply to post is not found'); + } + + // 返信対象が引用でないRepostだったらエラー + if (replyTo.repost_id && !replyTo.text && !replyTo.media_ids) { + return rej('cannot reply to repost'); + } + } else { + replyTo = null; + } + + // テキストが無いかつ添付ファイルが無いかつRepostも無かったらエラー + if (text === null && files === null && repost === null) { + return rej('text, media_ids or repost_id is required'); + } + + // 投稿を作成 + const inserted = await Post.insert({ + created_at: new Date(), + media_ids: media ? files.map(file => file._id) : undefined, + reply_to_id: replyTo ? replyTo._id : undefined, + repost_id: repost ? repost._id : undefined, + text: text, + user_id: user._id, + app_id: app ? app._id : null + }); + + const post = inserted.ops[0]; + + // Serialize + const postObj = await serialize(post); + + // Reponse + res(postObj); + + //-------------------------------- + // Post processes + + let mentions = []; + + function addMention(mentionee, type) { + // Reject if already added + if (mentions.some(x => x.equals(mentionee))) return; + + // Add mention + mentions.push(mentionee); + + // Publish event + if (!user._id.equals(mentionee)) { + event(mentionee, type, postObj); + } + } + + // Publish event to myself's stream + event(user._id, 'post', postObj); + + // Fetch all followers + const followers = await Following + .find({ + followee_id: user._id, + // 削除されたドキュメントは除く + deleted_at: { $exists: false } + }, { + follower_id: true, + _id: false + }) + .toArray(); + + // Publish event to followers stream + followers.forEach(following => + event(following.follower_id, 'post', postObj)); + + // Increment my posts count + User.updateOne({ _id: user._id }, { + $inc: { + posts_count: 1 + } + }); + + // If has in reply to post + if (replyTo) { + // Increment replies count + Post.updateOne({ _id: replyTo._id }, { + $inc: { + replies_count: 1 + } + }); + + // 自分自身へのリプライでない限りは通知を作成 + notify(replyTo.user_id, user._id, 'reply', { + post_id: post._id + }); + + // Add mention + addMention(replyTo.user_id, 'reply'); + } + + // If it is repost + if (repost) { + // Notify + const type = text ? 'quote' : 'repost'; + notify(repost.user_id, user._id, type, { + post_id: post._id + }); + + // If it is quote repost + if (text) { + // Add mention + addMention(repost.user_id, 'quote'); + } else { + // Publish event + if (!user._id.equals(repost.user_id)) { + event(repost.user_id, 'repost', postObj); + } + } + + // 今までで同じ投稿をRepostしているか + const existRepost = await Post.findOne({ + user_id: user._id, + repost_id: repost._id, + _id: { + $ne: post._id + } + }); + + if (!existRepost) { + // Update repostee status + Post.updateOne({ _id: repost._id }, { + $inc: { + repost_count: 1 + } + }); + } + } + + // If has text content + if (text) { + // Analyze + const tokens = parse(text); + + // Extract a hashtags + const hashtags = tokens + .filter(t => t.type == 'hashtag') + .map(t => t.hashtag) + // Drop dupulicates + .filter((v, i, s) => s.indexOf(v) == i); + + // ハッシュタグをデータベースに登録 + //registerHashtags(user, hashtags); + + // Extract an '@' mentions + const atMentions = tokens + .filter(t => t.type == 'mention') + .map(m => m.username) + // Drop dupulicates + .filter((v, i, s) => s.indexOf(v) == i); + + // Resolve all mentions + await Promise.all(atMentions.map(async (mention) => { + // Fetch mentioned user + // SELECT _id + const mentionee = await User + .findOne({ + username_lower: mention.toLowerCase() + }, { _id: true }); + + // When mentioned user not found + if (mentionee == null) return; + + // 既に言及されたユーザーに対する返信や引用repostの場合も無視 + if (replyTo && replyTo.user_id.equals(mentionee._id)) return; + if (repost && repost.user_id.equals(mentionee._id)) return; + + // Add mention + addMention(mentionee._id, 'mention'); + + // Create notification + notify(mentionee._id, user._id, 'mention', { + post_id: post._id + }); + + return; + })); + } + + // Register to search database + if (text && config.elasticsearch.enable) { + const es = require('../../../db/elasticsearch'); + + es.index({ + index: 'misskey', + type: 'post', + id: post._id.toString(), + body: { + text: post.text + } + }); + } + + // Append mentions data + if (mentions.length > 0) { + Post.updateOne({ _id: post._id }, { + $set: { + mentions: mentions + } + }); + } +}); diff --git a/src/api/endpoints/posts/favorites/create.js b/src/api/endpoints/posts/favorites/create.js new file mode 100644 index 0000000000..d20a523d5d --- /dev/null +++ b/src/api/endpoints/posts/favorites/create.js @@ -0,0 +1,56 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Favorite from '../../models/favorite'; +import Post from '../../models/post'; + +/** + * Favorite a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + let postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get favoritee + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Check arleady favorited + const exist = await Favorite.findOne({ + post_id: post._id, + user_id: user._id + }); + + if (exist !== null) { + return rej('already favorited'); + } + + // Create favorite + const inserted = await Favorite.insert({ + created_at: new Date(), + post_id: post._id, + user_id: user._id + }); + + const favorite = inserted.ops[0]; + + // Send response + res(); +}); diff --git a/src/api/endpoints/posts/favorites/delete.js b/src/api/endpoints/posts/favorites/delete.js new file mode 100644 index 0000000000..e250d1772c --- /dev/null +++ b/src/api/endpoints/posts/favorites/delete.js @@ -0,0 +1,52 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Favorite from '../../models/favorite'; +import Post from '../../models/post'; + +/** + * Unfavorite a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + let postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get favoritee + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Check arleady favorited + const exist = await Favorite.findOne({ + post_id: post._id, + user_id: user._id + }); + + if (exist === null) { + return rej('already not favorited'); + } + + // Delete favorite + await Favorite.deleteOne({ + _id: exist._id + }); + + // Send response + res(); +}); diff --git a/src/api/endpoints/posts/likes.js b/src/api/endpoints/posts/likes.js new file mode 100644 index 0000000000..4778189fc6 --- /dev/null +++ b/src/api/endpoints/posts/likes.js @@ -0,0 +1,77 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import Like from '../../models/like'; +import serialize from '../../serializers/user'; + +/** + * Show a likes of a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'sort' parameter + let sort = params.sort || 'desc'; + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Issue query + const likes = await Like + .find({ + post_id: post._id, + deleted_at: { $exists: false } + }, {}, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(likes.map(async like => + await serialize(like.user_id, user)))); +}); diff --git a/src/api/endpoints/posts/likes/create.js b/src/api/endpoints/posts/likes/create.js new file mode 100644 index 0000000000..eb35c1e4b0 --- /dev/null +++ b/src/api/endpoints/posts/likes/create.js @@ -0,0 +1,93 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Like from '../../../models/like'; +import Post from '../../../models/post'; +import User from '../../../models/user'; +import notify from '../../../common/notify'; +import event from '../../../event'; +import serializeUser from '../../../serializers/user'; +import serializePost from '../../../serializers/post'; + +/** + * Like a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + let postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get likee + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Myself + if (post.user_id.equals(user._id)) { + return rej('-need-translate-'); + } + + // Check arleady liked + const exist = await Like.findOne({ + post_id: post._id, + user_id: user._id, + deleted_at: { $exists: false } + }); + + if (exist !== null) { + return rej('already liked'); + } + + // Create like + const inserted = await Like.insert({ + created_at: new Date(), + post_id: post._id, + user_id: user._id + }); + + const like = inserted.ops[0]; + + // Send response + res(); + + // Increment likes count + Post.updateOne({ _id: post._id }, { + $inc: { + likes_count: 1 + } + }); + + // Increment user likes count + User.updateOne({ _id: user._id }, { + $inc: { + likes_count: 1 + } + }); + + // Increment user liked count + User.updateOne({ _id: post.user_id }, { + $inc: { + liked_count: 1 + } + }); + + // Notify + notify(post.user_id, user._id, 'like', { + post_id: post._id + }); +}); diff --git a/src/api/endpoints/posts/likes/delete.js b/src/api/endpoints/posts/likes/delete.js new file mode 100644 index 0000000000..b60df63af5 --- /dev/null +++ b/src/api/endpoints/posts/likes/delete.js @@ -0,0 +1,80 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Like from '../../../models/like'; +import Post from '../../../models/post'; +import User from '../../../models/user'; +// import event from '../../../event'; + +/** + * Unlike a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + let postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get likee + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Check arleady liked + const exist = await Like.findOne({ + post_id: post._id, + user_id: user._id, + deleted_at: { $exists: false } + }); + + if (exist === null) { + return rej('already not liked'); + } + + // Delete like + await Like.updateOne({ + _id: exist._id + }, { + $set: { + deleted_at: new Date() + } + }); + + // Send response + res(); + + // Decrement likes count + Post.updateOne({ _id: post._id }, { + $inc: { + likes_count: -1 + } + }); + + // Decrement user likes count + User.updateOne({ _id: user._id }, { + $inc: { + likes_count: -1 + } + }); + + // Decrement user liked count + User.updateOne({ _id: post.user_id }, { + $inc: { + liked_count: -1 + } + }); +}); diff --git a/src/api/endpoints/posts/mentions.js b/src/api/endpoints/posts/mentions.js new file mode 100644 index 0000000000..6358e1f4a9 --- /dev/null +++ b/src/api/endpoints/posts/mentions.js @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import getFriends from '../../common/get-friends'; +import serialize from '../../serializers/post'; + +/** + * Get mentions of myself + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'following' parameter + const following = params.following === 'true'; + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Construct query + const query = { + mentions: user._id + }; + + const sort = { + _id: -1 + }; + + if (following) { + const followingIds = await getFriends(user._id); + + query.user_id = { + $in: followingIds + }; + } + + if (since) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const mentions = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(mentions.map(async mention => + await serialize(mention, user) + ))); +}); diff --git a/src/api/endpoints/posts/replies.js b/src/api/endpoints/posts/replies.js new file mode 100644 index 0000000000..5eab6f896f --- /dev/null +++ b/src/api/endpoints/posts/replies.js @@ -0,0 +1,73 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a replies of a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'sort' parameter + let sort = params.sort || 'desc'; + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found', 'POST_NOT_FOUND'); + } + + // Issue query + const replies = await Post + .find({ reply_to_id: post._id }, {}, { + limit: limit, + skip: offset, + sort: { + _id: sort == 'asc' ? 1 : -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(replies.map(async post => + await serialize(post, user)))); +}); diff --git a/src/api/endpoints/posts/reposts.js b/src/api/endpoints/posts/reposts.js new file mode 100644 index 0000000000..8b418a682f --- /dev/null +++ b/src/api/endpoints/posts/reposts.js @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a reposts of a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Lookup post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found', 'POST_NOT_FOUND'); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = { + repost_id: post._id + }; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const reposts = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(reposts.map(async post => + await serialize(post, user)))); +}); diff --git a/src/api/endpoints/posts/search.js b/src/api/endpoints/posts/search.js new file mode 100644 index 0000000000..0f214ef7ae --- /dev/null +++ b/src/api/endpoints/posts/search.js @@ -0,0 +1,138 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; +const escapeRegexp = require('escape-regexp'); + +/** + * Search a post + * + * @param {Object} params + * @param {Object} me + * @return {Promise} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'query' parameter + let query = params.query; + if (query === undefined || query === null || query.trim() === '') { + return rej('query is required'); + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'max' parameter + let max = params.max; + if (max !== undefined && max !== null) { + max = parseInt(max, 10); + + // From 1 to 30 + if (!(1 <= max && max <= 30)) { + return rej('invalid max range'); + } + } else { + max = 10; + } + + // If Elasticsearch is available, search by it + // If not, search by MongoDB + (config.elasticsearch.enable ? byElasticsearch : byNative) + (res, rej, me, query, offset, max); +}); + +// Search by MongoDB +async function byNative(res, rej, me, query, offset, max) { + const escapedQuery = escapeRegexp(query); + + // Search posts + const posts = await Post + .find({ + text: new RegExp(escapedQuery) + }, { + sort: { + _id: -1 + }, + limit: max, + skip: offset + }) + .toArray(); + + // Serialize + res(await Promise.all(posts.map(async post => + await serialize(post, me)))); +} + +// Search by Elasticsearch +async function byElasticsearch(res, rej, me, query, offset, max) { + const es = require('../../db/elasticsearch'); + + es.search({ + index: 'misskey', + type: 'post', + body: { + size: max, + from: offset, + query: { + simple_query_string: { + fields: ['text'], + query: query, + default_operator: 'and' + } + }, + sort: [ + { _doc: 'desc' } + ], + highlight: { + pre_tags: [''], + post_tags: [''], + encoder: 'html', + fields: { + text: {} + } + } + } + }, async (error, response) => { + if (error) { + console.error(error); + return res(500); + } + + if (response.hits.total === 0) { + return res([]); + } + + const hits = response.hits.hits.map(hit => new mongo.ObjectID(hit._id)); + + // Fetxh found posts + const posts = await Post + .find({ + _id: { + $in: hits + } + }, {}, { + sort: { + _id: -1 + } + }) + .toArray(); + + posts.map(post => { + post._highlight = response.hits.hits.filter(hit => post._id.equals(hit._id))[0].highlight.text[0]; + }); + + // Serialize + res(await Promise.all(posts.map(async post => + await serialize(post, me)))); + }); +} diff --git a/src/api/endpoints/posts/show.js b/src/api/endpoints/posts/show.js new file mode 100644 index 0000000000..19cdb74251 --- /dev/null +++ b/src/api/endpoints/posts/show.js @@ -0,0 +1,40 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import serialize from '../../serializers/post'; + +/** + * Show a post + * + * @param {Object} params + * @param {Object} user + * @return {Promise} + */ +module.exports = (params, user) => + new Promise(async (res, rej) => +{ + // Get 'post_id' parameter + const postId = params.post_id; + if (postId === undefined || postId === null) { + return rej('post_id is required'); + } + + // Get post + const post = await Post.findOne({ + _id: new mongo.ObjectID(postId) + }); + + if (post === null) { + return rej('post not found'); + } + + // Serialize + res(await serialize(post, user, { + serializeReplyTo: true, + includeIsLiked: true + })); +}); diff --git a/src/api/endpoints/posts/timeline.js b/src/api/endpoints/posts/timeline.js new file mode 100644 index 0000000000..489542da71 --- /dev/null +++ b/src/api/endpoints/posts/timeline.js @@ -0,0 +1,78 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import getFriends from '../../common/get-friends'; +import serialize from '../../serializers/post'; + +/** + * Get timeline of myself + * + * @param {Object} params + * @param {Object} user + * @param {Object} app + * @return {Promise} + */ +module.exports = (params, user, app) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // ID list of the user itself and other users who the user follows + const followingIds = await getFriends(user._id); + + // Construct query + const sort = { + _id: -1 + }; + const query = { + user_id: { + $in: followingIds + } + }; + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const timeline = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(timeline.map(async post => + await serialize(post, user) + ))); +}); diff --git a/src/api/endpoints/username/available.js b/src/api/endpoints/username/available.js new file mode 100644 index 0000000000..a93637bc1f --- /dev/null +++ b/src/api/endpoints/username/available.js @@ -0,0 +1,41 @@ +'use strict'; + +/** + * Module dependencies + */ +import User from '../../models/user'; +import { validateUsername } from '../../models/user'; + +/** + * Check available username + * + * @param {Object} params + * @return {Promise} + */ +module.exports = async (params) => + new Promise(async (res, rej) => +{ + // Get 'username' parameter + const username = params.username; + if (username == null || username == '') { + return rej('username-is-required'); + } + + // Validate username + if (!validateUsername(username)) { + return rej('invalid-username'); + } + + // Get exist + const exist = await User + .count({ + username_lower: username.toLowerCase() + }, { + limit: 1 + }); + + // Reply + res({ + available: exist === 0 + }); +}); diff --git a/src/api/endpoints/users.js b/src/api/endpoints/users.js new file mode 100644 index 0000000000..cd40cdf4e1 --- /dev/null +++ b/src/api/endpoints/users.js @@ -0,0 +1,67 @@ +'use strict'; + +/** + * Module dependencies + */ +import User from '../models/user'; +import serialize from '../serializers/user'; + +/** + * Lists all users + * + * @param {Object} params + * @param {Object} me + * @return {Promise} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Construct query + const sort = { + created_at: -1 + }; + const query = {}; + if (since !== null) { + sort.created_at = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + // Issue query + const users = await User + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(user, me)))); +}); diff --git a/src/api/endpoints/users/followers.js b/src/api/endpoints/users/followers.js new file mode 100644 index 0000000000..303f55e450 --- /dev/null +++ b/src/api/endpoints/users/followers.js @@ -0,0 +1,102 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import Following from '../../models/following'; +import serialize from '../../serializers/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get followers of a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Get 'iknow' parameter + const iknow = params.iknow === 'true'; + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'cursor' parameter + const cursor = params.cursor || null; + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const query = { + followee_id: user._id, + deleted_at: { $exists: false } + }; + + // ログインしていてかつ iknow フラグがあるとき + if (me && iknow) { + // Get my friends + const myFriends = await getFriends(me._id); + + query.follower_id = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: new mongo.ObjectID(cursor) + }; + } + + // Get followers + const following = await Following + .find(query, {}, { + limit: limit + 1, + sort: { _id: -1 } + }) + .toArray(); + + // 「次のページ」があるかどうか + const inStock = following.length === limit + 1; + if (inStock) { + following.pop(); + } + + // Serialize + const users = await Promise.all(following.map(async f => + await serialize(f.follower_id, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? following[following.length - 1]._id : null, + }); +}); diff --git a/src/api/endpoints/users/following.js b/src/api/endpoints/users/following.js new file mode 100644 index 0000000000..ec3954563a --- /dev/null +++ b/src/api/endpoints/users/following.js @@ -0,0 +1,102 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import Following from '../../models/following'; +import serialize from '../../serializers/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get following users of a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Get 'iknow' parameter + const iknow = params.iknow === 'true'; + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'cursor' parameter + const cursor = params.cursor || null; + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const query = { + follower_id: user._id, + deleted_at: { $exists: false } + }; + + // ログインしていてかつ iknow フラグがあるとき + if (me && iknow) { + // Get my friends + const myFriends = await getFriends(me._id); + + query.followee_id = { + $in: myFriends + }; + } + + // カーソルが指定されている場合 + if (cursor) { + query._id = { + $lt: new mongo.ObjectID(cursor) + }; + } + + // Get followers + const following = await Following + .find(query, {}, { + limit: limit + 1, + sort: { _id: -1 } + }) + .toArray(); + + // 「次のページ」があるかどうか + const inStock = following.length === limit + 1; + if (inStock) { + following.pop(); + } + + // Serialize + const users = await Promise.all(following.map(async f => + await serialize(f.followee_id, me, { detail: true }))); + + // Response + res({ + users: users, + next: inStock ? following[following.length - 1]._id : null, + }); +}); diff --git a/src/api/endpoints/users/posts.js b/src/api/endpoints/users/posts.js new file mode 100644 index 0000000000..6d6f8a6904 --- /dev/null +++ b/src/api/endpoints/users/posts.js @@ -0,0 +1,114 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../../models/post'; +import User from '../../models/user'; +import serialize from '../../serializers/post'; + +/** + * Get posts of a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + const userId = params.user_id; + if (userId === undefined || userId === null) { + return rej('user_id is required'); + } + + // Get 'with_replies' parameter + let withReplies = params.with_replies; + if (withReplies !== undefined && withReplies !== null && withReplies === 'true') { + withReplies = true; + } else { + withReplies = false; + } + + // Get 'with_media' parameter + let withMedia = params.with_media; + if (withMedia !== undefined && withMedia !== null && withMedia === 'true') { + withMedia = true; + } else { + withMedia = false; + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + const since = params.since_id || null; + const max = params.max_id || null; + + // Check if both of since_id and max_id is specified + if (since !== null && max !== null) { + return rej('cannot set since_id and max_id'); + } + + // Lookup user + const user = await User.findOne({ + _id: new mongo.ObjectID(userId) + }); + + if (user === null) { + return rej('user not found'); + } + + // Construct query + const sort = { + _id: -1 + }; + const query = { + user_id: user._id + }; + if (since !== null) { + sort._id = 1; + query._id = { + $gt: new mongo.ObjectID(since) + }; + } else if (max !== null) { + query._id = { + $lt: new mongo.ObjectID(max) + }; + } + + if (!withReplies) { + query.reply_to_id = null; + } + + if (withMedia) { + query.media_ids = { + $exists: true, + $ne: null + }; + } + + // Issue query + const posts = await Post + .find(query, {}, { + limit: limit, + sort: sort + }) + .toArray(); + + // Serialize + res(await Promise.all(posts.map(async (post) => + await serialize(post, me) + ))); +}); diff --git a/src/api/endpoints/users/recommendation.js b/src/api/endpoints/users/recommendation.js new file mode 100644 index 0000000000..9daab0ec57 --- /dev/null +++ b/src/api/endpoints/users/recommendation.js @@ -0,0 +1,61 @@ +'use strict'; + +/** + * Module dependencies + */ +import User from '../../models/user'; +import serialize from '../../serializers/user'; +import getFriends from '../../common/get-friends'; + +/** + * Get recommended users + * + * @param {Object} params + * @param {Object} me + * @return {Promise} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // ID list of the user itself and other users who the user follows + const followingIds = await getFriends(me._id); + + const users = await User + .find({ + _id: { + $nin: followingIds + } + }, {}, { + limit: limit, + skip: offset, + sort: { + followers_count: -1 + } + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(user, me, { detail: true })))); +}); diff --git a/src/api/endpoints/users/search.js b/src/api/endpoints/users/search.js new file mode 100644 index 0000000000..3a3fe677db --- /dev/null +++ b/src/api/endpoints/users/search.js @@ -0,0 +1,116 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import serialize from '../../serializers/user'; +const escapeRegexp = require('escape-regexp'); + +/** + * Search a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'query' parameter + let query = params.query; + if (query === undefined || query === null || query.trim() === '') { + return rej('query is required'); + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + // Get 'max' parameter + let max = params.max; + if (max !== undefined && max !== null) { + max = parseInt(max, 10); + + // From 1 to 30 + if (!(1 <= max && max <= 30)) { + return rej('invalid max range'); + } + } else { + max = 10; + } + + // If Elasticsearch is available, search by it + // If not, search by MongoDB + (config.elasticsearch.enable ? byElasticsearch : byNative) + (res, rej, me, query, offset, max); +}); + +// Search by MongoDB +async function byNative(res, rej, me, query, offset, max) { + const escapedQuery = escapeRegexp(query); + + // Search users + const users = await User + .find({ + $or: [{ + username_lower: new RegExp(escapedQuery.toLowerCase()) + }, { + name: new RegExp(escapedQuery) + }] + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(user, me, { detail: true })))); +} + +// Search by Elasticsearch +async function byElasticsearch(res, rej, me, query, offset, max) { + const es = require('../../db/elasticsearch'); + + es.search({ + index: 'misskey', + type: 'user', + body: { + size: max, + from: offset, + query: { + simple_query_string: { + fields: ['username', 'name', 'bio'], + query: query, + default_operator: 'and' + } + } + } + }, async (error, response) => { + if (error) { + console.error(error); + return res(500); + } + + if (response.hits.total === 0) { + return res([]); + } + + const hits = response.hits.hits.map(hit => new mongo.ObjectID(hit._id)); + + const users = await User + .find({ + _id: { + $in: hits + } + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(user, me, { detail: true })))); + }); +} diff --git a/src/api/endpoints/users/search_by_username.js b/src/api/endpoints/users/search_by_username.js new file mode 100644 index 0000000000..9e3efbd85c --- /dev/null +++ b/src/api/endpoints/users/search_by_username.js @@ -0,0 +1,65 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import serialize from '../../serializers/user'; + +/** + * Search a user by username + * + * @param {Object} params + * @param {Object} me + * @return {Promise} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'query' parameter + let query = params.query; + if (query === undefined || query === null || query.trim() === '') { + return rej('query is required'); + } + + query = query.trim(); + + if (!/^[a-zA-Z0-9-]+$/.test(query)) { + return rej('invalid query'); + } + + // Get 'limit' parameter + let limit = params.limit; + if (limit !== undefined && limit !== null) { + limit = parseInt(limit, 10); + + // From 1 to 100 + if (!(1 <= limit && limit <= 100)) { + return rej('invalid limit range'); + } + } else { + limit = 10; + } + + // Get 'offset' parameter + let offset = params.offset; + if (offset !== undefined && offset !== null) { + offset = parseInt(offset, 10); + } else { + offset = 0; + } + + const users = await User + .find({ + username_lower: new RegExp(query.toLowerCase()) + }, { + limit: limit, + skip: offset + }) + .toArray(); + + // Serialize + res(await Promise.all(users.map(async user => + await serialize(user, me, { detail: true })))); +}); diff --git a/src/api/endpoints/users/show.js b/src/api/endpoints/users/show.js new file mode 100644 index 0000000000..af475c6cb9 --- /dev/null +++ b/src/api/endpoints/users/show.js @@ -0,0 +1,49 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import User from '../../models/user'; +import serialize from '../../serializers/user'; + +/** + * Show a user + * + * @param {Object} params + * @param {Object} me + * @return {Promise} + */ +module.exports = (params, me) => + new Promise(async (res, rej) => +{ + // Get 'user_id' parameter + let userId = params.user_id; + if (userId === undefined || userId === null || userId === '') { + userId = null; + } + + // Get 'username' parameter + let username = params.username; + if (username === undefined || username === null || username === '') { + username = null; + } + + if (userId === null && username === null) { + return rej('user_id or username is required'); + } + + // Lookup user + const user = userId !== null + ? await User.findOne({ _id: new mongo.ObjectID(userId) }) + : await User.findOne({ username_lower: username.toLowerCase() }); + + if (user === null) { + return rej('user not found'); + } + + // Send response + res(await serialize(user, me, { + detail: true + })); +}); diff --git a/src/api/event.ts b/src/api/event.ts new file mode 100644 index 0000000000..584fc8e86c --- /dev/null +++ b/src/api/event.ts @@ -0,0 +1,36 @@ +import * as mongo from 'mongodb'; +import * as redis from 'redis'; + +type ID = string | mongo.ObjectID; + +class MisskeyEvent { + private redisClient: redis.RedisClient; + + constructor() { + // Connect to Redis + this.redisClient = redis.createClient( + config.redis.port, config.redis.host); + } + + private publish(channel: string, type: string, value?: Object): void { + const message = value == null ? + { type: type } : + { type: type, body: value }; + + this.redisClient.publish(`misskey:${channel}`, JSON.stringify(message)); + } + + public publishUserStream(userId: ID, type: string, value?: Object): void { + this.publish(`user-stream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + + public publishMessagingStream(userId: ID, otherpartyId: ID, type: string, value?: Object): void { + this.publish(`messaging-stream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); + } +} + +const ev = new MisskeyEvent(); + +export default ev.publishUserStream.bind(ev); + +export const publishMessagingStream = ev.publishMessagingStream.bind(ev); diff --git a/src/api/limitter.ts b/src/api/limitter.ts new file mode 100644 index 0000000000..9cc25675d8 --- /dev/null +++ b/src/api/limitter.ts @@ -0,0 +1,69 @@ +import * as Limiter from 'ratelimiter'; +import limiterDB from '../db/redis'; +import { IEndpoint } from './endpoints'; +import { IAuthContext } from './authenticate'; + +export default (endpoint: IEndpoint, ctx: IAuthContext) => new Promise((ok, reject) => { + const limitKey = endpoint.hasOwnProperty('limitKey') + ? endpoint.limitKey + : endpoint.name; + + const hasMinInterval = + endpoint.hasOwnProperty('minInterval'); + + const hasRateLimit = + endpoint.hasOwnProperty('limitDuration') && + endpoint.hasOwnProperty('limitMax'); + + if (hasMinInterval) { + min(); + } else if (hasRateLimit) { + max(); + } else { + ok(); + } + + // Short-term limit + function min(): void { + const minIntervalLimiter = new Limiter({ + id: `${ctx.user._id}:${limitKey}:min`, + duration: endpoint.minInterval, + max: 1, + db: limiterDB + }); + + minIntervalLimiter.get((limitErr, limit) => { + if (limitErr) { + reject('ERR'); + } else if (limit.remaining === 0) { + reject('BRIEF_REQUEST_INTERVAL'); + } else { + if (hasRateLimit) { + max(); + } else { + ok(); + } + } + }); + } + + // Long term limit + function max(): void { + const limiter = new Limiter({ + id: `${ctx.user._id}:${limitKey}`, + duration: endpoint.limitDuration, + max: endpoint.limitMax, + db: limiterDB + }); + + limiter.get((limitErr, limit) => { + if (limitErr) { + reject('ERR'); + } else if (limit.remaining === 0) { + reject('RATE_LIMIT_EXCEEDED'); + } else { + ok(); + } + }); + } +}); diff --git a/src/api/models/app.ts b/src/api/models/app.ts new file mode 100644 index 0000000000..221a53906a --- /dev/null +++ b/src/api/models/app.ts @@ -0,0 +1,7 @@ +const collection = global.db.collection('apps'); + +collection.createIndex('name_id'); +collection.createIndex('name_id_lower'); +collection.createIndex('secret'); + +export default collection; diff --git a/src/api/models/appdata.ts b/src/api/models/appdata.ts new file mode 100644 index 0000000000..2d471c4347 --- /dev/null +++ b/src/api/models/appdata.ts @@ -0,0 +1 @@ +export default global.db.collection('appdata'); diff --git a/src/api/models/auth-session.ts b/src/api/models/auth-session.ts new file mode 100644 index 0000000000..6dbe2fa70e --- /dev/null +++ b/src/api/models/auth-session.ts @@ -0,0 +1 @@ +export default global.db.collection('auth_sessions'); diff --git a/src/api/models/drive-file.ts b/src/api/models/drive-file.ts new file mode 100644 index 0000000000..06ebf02005 --- /dev/null +++ b/src/api/models/drive-file.ts @@ -0,0 +1,11 @@ +export default global.db.collection('drive_files'); + +export function validateFileName(name: string): boolean { + return ( + (name.trim().length > 0) && + (name.length <= 200) && + (name.indexOf('\\') === -1) && + (name.indexOf('/') === -1) && + (name.indexOf('..') === -1) + ); +} diff --git a/src/api/models/drive-folder.ts b/src/api/models/drive-folder.ts new file mode 100644 index 0000000000..f345b3c340 --- /dev/null +++ b/src/api/models/drive-folder.ts @@ -0,0 +1,8 @@ +export default global.db.collection('drive_folders'); + +export function isValidFolderName(name: string): boolean { + return ( + (name.trim().length > 0) && + (name.length <= 200) + ); +} diff --git a/src/api/models/drive-tag.ts b/src/api/models/drive-tag.ts new file mode 100644 index 0000000000..83c0a8f681 --- /dev/null +++ b/src/api/models/drive-tag.ts @@ -0,0 +1 @@ +export default global.db.collection('drive_tags'); diff --git a/src/api/models/favorite.ts b/src/api/models/favorite.ts new file mode 100644 index 0000000000..6d9e7c72b3 --- /dev/null +++ b/src/api/models/favorite.ts @@ -0,0 +1 @@ +export default global.db.collection('favorites'); diff --git a/src/api/models/following.ts b/src/api/models/following.ts new file mode 100644 index 0000000000..f9d8a41c5e --- /dev/null +++ b/src/api/models/following.ts @@ -0,0 +1 @@ +export default global.db.collection('following'); diff --git a/src/api/models/like.ts b/src/api/models/like.ts new file mode 100644 index 0000000000..aa3bd75c1c --- /dev/null +++ b/src/api/models/like.ts @@ -0,0 +1 @@ +export default global.db.collection('likes'); diff --git a/src/api/models/messaging-history.ts b/src/api/models/messaging-history.ts new file mode 100644 index 0000000000..3505e94b57 --- /dev/null +++ b/src/api/models/messaging-history.ts @@ -0,0 +1 @@ +export default global.db.collection('messaging_histories'); diff --git a/src/api/models/messaging-message.ts b/src/api/models/messaging-message.ts new file mode 100644 index 0000000000..0e900bda58 --- /dev/null +++ b/src/api/models/messaging-message.ts @@ -0,0 +1 @@ +export default global.db.collection('messaging_messages'); diff --git a/src/api/models/notification.ts b/src/api/models/notification.ts new file mode 100644 index 0000000000..1cb7b80838 --- /dev/null +++ b/src/api/models/notification.ts @@ -0,0 +1 @@ +export default global.db.collection('notifications'); diff --git a/src/api/models/post.ts b/src/api/models/post.ts new file mode 100644 index 0000000000..bea92a5f61 --- /dev/null +++ b/src/api/models/post.ts @@ -0,0 +1 @@ +export default global.db.collection('posts'); diff --git a/src/api/models/signin.ts b/src/api/models/signin.ts new file mode 100644 index 0000000000..896afaaf84 --- /dev/null +++ b/src/api/models/signin.ts @@ -0,0 +1 @@ +export default global.db.collection('signin'); diff --git a/src/api/models/user.ts b/src/api/models/user.ts new file mode 100644 index 0000000000..1742f5cafb --- /dev/null +++ b/src/api/models/user.ts @@ -0,0 +1,10 @@ +const collection = global.db.collection('users'); + +collection.createIndex('username'); +collection.createIndex('token'); + +export default collection; + +export function validateUsername(username: string): boolean { + return /^[a-zA-Z0-9\-]{3,20}$/.test(username); +} diff --git a/src/api/models/userkey.ts b/src/api/models/userkey.ts new file mode 100644 index 0000000000..204f283a28 --- /dev/null +++ b/src/api/models/userkey.ts @@ -0,0 +1,5 @@ +const collection = global.db.collection('userkeys'); + +collection.createIndex('key'); + +export default collection; diff --git a/src/api/private/signin.ts b/src/api/private/signin.ts new file mode 100644 index 0000000000..b68fc89aa0 --- /dev/null +++ b/src/api/private/signin.ts @@ -0,0 +1,57 @@ +import * as express from 'express'; +import * as bcrypt from 'bcrypt'; +import User from '../models/user'; +import Signin from '../models/signin'; +import serialize from '../serializers/signin'; +import event from '../event'; + + +export default async (req: express.Request, res: express.Response) => { + res.header('Access-Control-Allow-Credentials', 'true'); + + const username = req.body['username']; + const password = req.body['password']; + + // Fetch user + const user = await User.findOne({ + username_lower: username.toLowerCase() + }); + + if (user === null) { + res.status(404).send('user not found'); + return; + } + + // Compare password + const same = await bcrypt.compare(password, user.password); + + if (same) { + const expires = 1000 * 60 * 60 * 24 * 365; // One Year + res.cookie('i', user.token, { + path: '/', + domain: `.${config.host}`, + secure: config.url.substr(0, 5) === 'https', + httpOnly: false, + expires: new Date(Date.now() + expires), + maxAge: expires + }); + + res.sendStatus(204); + } else { + res.status(400).send('incorrect password'); + } + + // Append signin history + const inserted = await Signin.insert({ + created_at: new Date(), + user_id: user._id, + ip: req.ip, + headers: req.headers, + success: same + }); + + const record = inserted.ops[0]; + + // Publish signin event + event(user._id, 'signin', await serialize(record)); +}; diff --git a/src/api/private/signup.ts b/src/api/private/signup.ts new file mode 100644 index 0000000000..7df1f25b37 --- /dev/null +++ b/src/api/private/signup.ts @@ -0,0 +1,94 @@ +import * as express from 'express'; +import * as bcrypt from 'bcrypt'; +import rndstr from 'rndstr'; +const recaptcha = require('recaptcha-promise'); +import User from '../models/user'; +import { validateUsername } from '../models/user'; +import serialize from '../serializers/user'; + + +recaptcha.init({ + secret_key: config.recaptcha.secretKey +}); + +export default async (req: express.Request, res: express.Response) => { + // Verify recaptcha + const success = await recaptcha(req.body['g-recaptcha-response']); + + if (!success) { + res.status(400).send('recaptcha-failed'); + return; + } + + const username = req.body['username']; + const password = req.body['password']; + const name = '名無し'; + + // Validate username + if (!validateUsername(username)) { + res.sendStatus(400); + return; + } + + // Fetch exist user that same username + const usernameExist = await User + .count({ + username_lower: username.toLowerCase() + }, { + limit: 1 + }); + + // Check username already used + if (usernameExist !== 0) { + res.sendStatus(400); + return; + } + + // Generate hash of password + const salt = bcrypt.genSaltSync(14); + const hash = bcrypt.hashSync(password, salt); + + // Generate secret + const secret = rndstr('a-zA-Z0-9', 32); + + // Create account + const inserted = await User.insert({ + token: secret, + avatar_id: null, + banner_id: null, + birthday: null, + created_at: new Date(), + bio: null, + email: null, + followers_count: 0, + following_count: 0, + links: null, + location: null, + name: name, + password: hash, + posts_count: 0, + likes_count: 0, + liked_count: 0, + drive_capacity: 1073741824, // 1GB + username: username, + username_lower: username.toLowerCase() + }); + + const account = inserted.ops[0]; + + // Response + res.send(await serialize(account)); + + // Create search index + if (config.elasticsearch.enable) { + const es = require('../../db/elasticsearch'); + es.index({ + index: 'misskey', + type: 'user', + id: account._id.toString(), + body: { + username: username + } + }); + } +}; diff --git a/src/api/reply.ts b/src/api/reply.ts new file mode 100644 index 0000000000..e47fc85b9b --- /dev/null +++ b/src/api/reply.ts @@ -0,0 +1,13 @@ +import * as express from 'express'; + +export default (res: express.Response, x?: any, y?: any) => { + if (x === undefined) { + res.sendStatus(204); + } else if (typeof x === 'number') { + res.status(x).send({ + error: x === 500 ? 'INTERNAL_ERROR' : y + }); + } else { + res.send(x); + } +}; diff --git a/src/api/serializers/app.ts b/src/api/serializers/app.ts new file mode 100644 index 0000000000..23a12c977d --- /dev/null +++ b/src/api/serializers/app.ts @@ -0,0 +1,85 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +const deepcopy = require('deepcopy'); +import App from '../models/app'; +import User from '../models/user'; +import Userkey from '../models/userkey'; + +/** + * Serialize an app + * + * @param {Object} app + * @param {Object} me? + * @param {Object} options? + * @return {Promise} + */ +export default ( + app: any, + me?: any, + options?: { + includeSecret: boolean, + includeProfileImageIds: boolean + } +) => new Promise(async (resolve, reject) => { + const opts = options || { + includeSecret: false, + includeProfileImageIds: false + }; + + let _app: any; + + // Populate the app if 'app' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(app)) { + _app = await App.findOne({ + _id: app + }); + } else if (typeof app === 'string') { + _app = await User.findOne({ + _id: new mongo.ObjectID(app) + }); + } else { + _app = deepcopy(app); + } + + // Me + if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (typeof me === 'string') { + me = new mongo.ObjectID(me); + } else { + me = me._id; + } + } + + // Rename _id to id + _app.id = _app._id; + delete _app._id; + + delete _app.name_id_lower; + + // Visible by only owner + if (!opts.includeSecret) { + delete _app.secret; + } + + _app.icon_url = _app.icon != null + ? `${config.drive_url}/${_app.icon}` + : `${config.drive_url}/app-default.jpg`; + + if (me) { + // 既に連携しているか + const exist = await Userkey.count({ + app_id: _app.id, + user_id: me, + }, { + limit: 1 + }); + + _app.is_authorized = exist === 1; + } + + resolve(_app); +}); diff --git a/src/api/serializers/auth-session.ts b/src/api/serializers/auth-session.ts new file mode 100644 index 0000000000..786684b4e0 --- /dev/null +++ b/src/api/serializers/auth-session.ts @@ -0,0 +1,42 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +const deepcopy = require('deepcopy'); +import serializeApp from './app'; + +/** + * Serialize an auth session + * + * @param {Object} session + * @param {Object} me? + * @return {Promise} + */ +export default ( + session: any, + me?: any +) => new Promise(async (resolve, reject) => { + let _session: any; + + // TODO: Populate session if it ID + + _session = deepcopy(session); + + // Me + if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (typeof me === 'string') { + me = new mongo.ObjectID(me); + } else { + me = me._id; + } + } + + delete _session._id; + + // Populate app + _session.app = await serializeApp(_session.app_id, me); + + resolve(_session); +}); diff --git a/src/api/serializers/drive-file.ts b/src/api/serializers/drive-file.ts new file mode 100644 index 0000000000..635cf13867 --- /dev/null +++ b/src/api/serializers/drive-file.ts @@ -0,0 +1,63 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFile from '../models/drive-file'; +import serializeDriveTag from './drive-tag'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a drive file + * + * @param {Object} file + * @param {Object} options? + * @return {Promise} + */ +export default ( + file: any, + options?: { + includeTags: boolean + } +) => new Promise(async (resolve, reject) => { + const opts = options || { + includeTags: true + }; + + let _file: any; + + // Populate the file if 'file' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(file)) { + _file = await DriveFile.findOne({ + _id: file + }, { + data: false + }); + } else if (typeof file === 'string') { + _file = await DriveFile.findOne({ + _id: new mongo.ObjectID(file) + }, { + data: false + }); + } else { + _file = deepcopy(file); + } + + // Rename _id to id + _file.id = _file._id; + delete _file._id; + + delete _file.data; + + _file.url = `${config.drive_url}/${_file.id}/${encodeURIComponent(_file.name)}`; + + if (opts.includeTags && _file.tags) { + // Populate tags + _file.tags = await _file.tags.map(async (tag: any) => + await serializeDriveTag(tag) + ); + } + + resolve(_file); +}); diff --git a/src/api/serializers/drive-folder.ts b/src/api/serializers/drive-folder.ts new file mode 100644 index 0000000000..ee5a973e14 --- /dev/null +++ b/src/api/serializers/drive-folder.ts @@ -0,0 +1,52 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveFolder from '../models/drive-folder'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a drive folder + * + * @param {Object} folder + * @param {Object} options? + * @return {Promise} + */ +const self = ( + folder: any, + options?: { + includeParent: boolean + } +) => new Promise(async (resolve, reject) => { + const opts = options || { + includeParent: false + }; + + let _folder: any; + + // Populate the folder if 'folder' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(folder)) { + _folder = await DriveFolder.findOne({_id: folder}); + } else if (typeof folder === 'string') { + _folder = await DriveFolder.findOne({_id: new mongo.ObjectID(folder)}); + } else { + _folder = deepcopy(folder); + } + + // Rename _id to id + _folder.id = _folder._id; + delete _folder._id; + + if (opts.includeParent && _folder.parent_id) { + // Populate parent folder + _folder.parent = await self(_folder.parent_id, { + includeParent: true + }); + } + + resolve(_folder); +}); + +export default self; diff --git a/src/api/serializers/drive-tag.ts b/src/api/serializers/drive-tag.ts new file mode 100644 index 0000000000..182e9a66d4 --- /dev/null +++ b/src/api/serializers/drive-tag.ts @@ -0,0 +1,37 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import DriveTag from '../models/drive-tag'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a drive tag + * + * @param {Object} tag + * @return {Promise} + */ +const self = ( + tag: any +) => new Promise(async (resolve, reject) => { + let _tag: any; + + // Populate the tag if 'tag' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(tag)) { + _tag = await DriveTag.findOne({_id: tag}); + } else if (typeof tag === 'string') { + _tag = await DriveTag.findOne({_id: new mongo.ObjectID(tag)}); + } else { + _tag = deepcopy(tag); + } + + // Rename _id to id + _tag.id = _tag._id; + delete _tag._id; + + resolve(_tag); +}); + +export default self; diff --git a/src/api/serializers/messaging-message.ts b/src/api/serializers/messaging-message.ts new file mode 100644 index 0000000000..0855b25d16 --- /dev/null +++ b/src/api/serializers/messaging-message.ts @@ -0,0 +1,64 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Message from '../models/messaging-message'; +import serializeUser from './user'; +import serializeDriveFile from './drive-file'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a message + * + * @param {Object} message + * @param {Object} me? + * @param {Object} options? + * @return {Promise} + */ +export default ( + message: any, + me: any, + options?: { + populateRecipient: boolean + } +) => new Promise(async (resolve, reject) => { + const opts = options || { + populateRecipient: true + }; + + let _message: any; + + // Populate the message if 'message' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(message)) { + _message = await Message.findOne({ + _id: message + }); + } else if (typeof message === 'string') { + _message = await Message.findOne({ + _id: new mongo.ObjectID(message) + }); + } else { + _message = deepcopy(message); + } + + // Rename _id to id + _message.id = _message._id; + delete _message._id; + + // Populate user + _message.user = await serializeUser(_message.user_id, me); + + if (_message.file) { + // Populate file + _message.file = await serializeDriveFile(_message.file_id); + } + + if (opts.populateRecipient) { + // Populate recipient + _message.recipient = await serializeUser(_message.recipient_id, me); + } + + resolve(_message); +}); diff --git a/src/api/serializers/notification.ts b/src/api/serializers/notification.ts new file mode 100644 index 0000000000..56769f50d0 --- /dev/null +++ b/src/api/serializers/notification.ts @@ -0,0 +1,66 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Notification from '../models/notification'; +import serializeUser from './user'; +import serializePost from './post'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a notification + * + * @param {Object} notification + * @return {Promise} + */ +export default (notification: any) => new Promise(async (resolve, reject) => { + let _notification: any; + + // Populate the notification if 'notification' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(notification)) { + _notification = await Notification.findOne({ + _id: notification + }); + } else if (typeof notification === 'string') { + _notification = await Notification.findOne({ + _id: new mongo.ObjectID(notification) + }); + } else { + _notification = deepcopy(notification); + } + + // Rename _id to id + _notification.id = _notification._id; + delete _notification._id; + + // Rename notifier_id to user_id + _notification.user_id = _notification.notifier_id; + delete _notification.notifier_id; + + const me = _notification.notifiee_id; + delete _notification.notifiee_id; + + // Populate notifier + _notification.user = await serializeUser(_notification.user_id, me); + + switch (_notification.type) { + case 'follow': + // nope + break; + case 'mention': + case 'reply': + case 'repost': + case 'quote': + case 'like': + // Populate post + _notification.post = await serializePost(_notification.post_id, me); + break; + default: + console.error(`Unknown type: ${_notification.type}`); + break; + } + + resolve(_notification); +}); diff --git a/src/api/serializers/post.ts b/src/api/serializers/post.ts new file mode 100644 index 0000000000..a17aa9035b --- /dev/null +++ b/src/api/serializers/post.ts @@ -0,0 +1,103 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +import Post from '../models/post'; +import Like from '../models/like'; +import serializeUser from './user'; +import serializeDriveFile from './drive-file'; +const deepcopy = require('deepcopy'); + +/** + * Serialize a post + * + * @param {Object} post + * @param {Object} me? + * @param {Object} options? + * @return {Promise} + */ +const self = ( + post: any, + me?: any, + options?: { + serializeReplyTo: boolean, + serializeRepost: boolean, + includeIsLiked: boolean + } +) => new Promise(async (resolve, reject) => { + const opts = options || { + serializeReplyTo: true, + serializeRepost: true, + includeIsLiked: true + }; + + let _post: any; + + // Populate the post if 'post' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(post)) { + _post = await Post.findOne({ + _id: post + }); + } else if (typeof post === 'string') { + _post = await Post.findOne({ + _id: new mongo.ObjectID(post) + }); + } else { + _post = deepcopy(post); + } + + const id = _post._id; + + // Rename _id to id + _post.id = _post._id; + delete _post._id; + + delete _post.mentions; + + // Populate user + _post.user = await serializeUser(_post.user_id, me); + + if (_post.media_ids) { + // Populate media + _post.media = await Promise.all(_post.media_ids.map(async fileId => + await serializeDriveFile(fileId) + )); + } + + if (_post.reply_to_id && opts.serializeReplyTo) { + // Populate reply to post + _post.reply_to = await self(_post.reply_to_id, me, { + serializeReplyTo: false, + serializeRepost: false, + includeIsLiked: false + }); + } + + if (_post.repost_id && opts.serializeRepost) { + // Populate repost + _post.repost = await self(_post.repost_id, me, { + serializeReplyTo: _post.text == null, + serializeRepost: _post.text == null, + includeIsLiked: _post.text == null + }); + } + + // Check if it is liked + if (me && opts.includeIsLiked) { + const liked = await Like + .count({ + user_id: me._id, + post_id: id + }, { + limit: 1 + }); + + _post.is_liked = liked === 1; + } + + resolve(_post); +}); + +export default self; diff --git a/src/api/serializers/signin.ts b/src/api/serializers/signin.ts new file mode 100644 index 0000000000..d6d7a39471 --- /dev/null +++ b/src/api/serializers/signin.ts @@ -0,0 +1,25 @@ +'use strict'; + +/** + * Module dependencies + */ +const deepcopy = require('deepcopy'); + +/** + * Serialize a signin record + * + * @param {Object} record + * @return {Promise} + */ +export default ( + record: any +) => new Promise(async (resolve, reject) => { + + const _record = deepcopy(record); + + // Rename _id to id + _record.id = _record._id; + delete _record._id; + + resolve(_record); +}); diff --git a/src/api/serializers/user.ts b/src/api/serializers/user.ts new file mode 100644 index 0000000000..0585863950 --- /dev/null +++ b/src/api/serializers/user.ts @@ -0,0 +1,138 @@ +'use strict'; + +/** + * Module dependencies + */ +import * as mongo from 'mongodb'; +const deepcopy = require('deepcopy'); +import User from '../models/user'; +import Following from '../models/following'; +import getFriends from '../common/get-friends'; + +/** + * Serialize a user + * + * @param {Object} user + * @param {Object} me? + * @param {Object} options? + * @return {Promise} + */ +export default ( + user: any, + me?: any, + options?: { + detail: boolean, + includeSecrets: boolean + } +) => new Promise(async (resolve, reject) => { + + const opts = Object.assign({ + detail: false, + includeSecrets: false + }, options); + + let _user: any; + + // Populate the user if 'user' is ID + if (mongo.ObjectID.prototype.isPrototypeOf(user)) { + _user = await User.findOne({ + _id: user + }); + } else if (typeof user === 'string') { + _user = await User.findOne({ + _id: new mongo.ObjectID(user) + }); + } else { + _user = deepcopy(user); + } + + // Me + if (me && !mongo.ObjectID.prototype.isPrototypeOf(me)) { + if (typeof me === 'string') { + me = new mongo.ObjectID(me); + } else { + me = me._id; + } + } + + // Rename _id to id + _user.id = _user._id; + delete _user._id; + + // Remove private properties + delete _user.password; + delete _user.token; + delete _user.username_lower; + + // Visible via only the official client + if (!opts.includeSecrets) { + delete _user.data; + delete _user.email; + } + + _user.avatar_url = _user.avatar_id != null + ? `${config.drive_url}/${_user.avatar_id}` + : `${config.drive_url}/default-avatar.jpg`; + + _user.banner_url = _user.banner_id != null + ? `${config.drive_url}/${_user.banner_id}` + : null; + + if (!me || !me.equals(_user.id) || !opts.detail) { + delete _user.avatar_id; + delete _user.banner_id; + + delete _user.drive_capacity; + } + + if (me && !me.equals(_user.id)) { + // If the user is following + const follow = await Following.findOne({ + follower_id: me, + followee_id: _user.id, + deleted_at: { $exists: false } + }); + _user.is_following = follow !== null; + + // If the user is followed + const follow2 = await Following.findOne({ + follower_id: _user.id, + followee_id: me, + deleted_at: { $exists: false } + }); + _user.is_followed = follow2 !== null; + } + + if (me && !me.equals(_user.id) && opts.detail) { + const myFollowingIds = await getFriends(me); + + // Get following you know count + const followingYouKnowCount = await Following.count({ + followee_id: { $in: myFollowingIds }, + follower_id: _user.id, + deleted_at: { $exists: false } + }); + _user.following_you_know_count = followingYouKnowCount; + + // Get followers you know count + const followersYouKnowCount = await Following.count({ + followee_id: _user.id, + follower_id: { $in: myFollowingIds }, + deleted_at: { $exists: false } + }); + _user.followers_you_know_count = followersYouKnowCount; + } + + resolve(_user); +}); +/* +function img(url) { + return { + thumbnail: { + large: `${url}`, + medium: '', + small: '' + } + }; +} +*/ diff --git a/src/api/server.ts b/src/api/server.ts new file mode 100644 index 0000000000..78b0d0aea8 --- /dev/null +++ b/src/api/server.ts @@ -0,0 +1,52 @@ +/** + * API Server + */ + +import * as express from 'express'; +import * as bodyParser from 'body-parser'; +import * as cors from 'cors'; +import * as multer from 'multer'; + +import authenticate from './authenticate'; +import endpoints from './endpoints'; + +/** + * Init app + */ +const app = express(); + +app.disable('x-powered-by'); +app.set('etag', false); +app.use(bodyParser.urlencoded({ extended: true })); +app.use(cors({ + origin: true +})); + +/** + * Authetication + */ +/*app.post('*', async (req, res, next) => { + try { + ctx = await authenticate(req); + next(); + } catch (e) { + res.status(403).send('AUTHENTICATION_FAILED'); + } +}); +*/ +/** + * Register endpoint handlers + */ +endpoints.forEach(endpoint => + endpoint.withFile ? + app.post('/' + endpoint.name, + endpoint.withFile ? multer({ dest: 'uploads/' }).single('file') : null, + require('./api-handler').default.bind(null, endpoint)) : + app.post('/' + endpoint.name, + require('./api-handler').default.bind(null, endpoint)) +); + +app.post('/signup', require('./private/signup').default); +app.post('/signin', require('./private/signin').default); + +module.exports = app; diff --git a/src/api/stream/home.ts b/src/api/stream/home.ts new file mode 100644 index 0000000000..975bea4c60 --- /dev/null +++ b/src/api/stream/home.ts @@ -0,0 +1,10 @@ +import * as websocket from 'websocket'; +import * as redis from 'redis'; + +export default function homeStream(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + // Subscribe Home stream channel + subscriber.subscribe(`misskey:user-stream:${user._id}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); +} diff --git a/src/api/stream/messaging.ts b/src/api/stream/messaging.ts new file mode 100644 index 0000000000..4ec139b82b --- /dev/null +++ b/src/api/stream/messaging.ts @@ -0,0 +1,60 @@ +import * as mongodb from 'mongodb'; +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import Message from '../models/messaging-message'; +import { publishMessagingStream } from '../event'; + +export default function messagingStream(request: websocket.request, connection: websocket.connection, subscriber: redis.RedisClient, user: any): void { + const otherparty = request.resourceURL.query.otherparty; + + // Subscribe messaging stream + subscriber.subscribe(`misskey:messaging-stream:${user._id}-${otherparty}`); + subscriber.on('message', (_, data) => { + connection.send(data); + }); + + connection.on('message', async (data) => { + const msg = JSON.parse(data.utf8Data); + + switch (msg.type) { + case 'read': + if (!msg.id) { + return; + } + + const id = new mongodb.ObjectID(msg.id); + + // Fetch message + // SELECT _id, user_id, is_read + const message = await Message.findOne({ + _id: id, + recipient_id: user._id + }, { + fields: { + _id: true, + user_id: true, + is_read: true + } + }); + + if (message == null) { + return; + } + + if (message.is_read) { + return; + } + + // Update documents + await Message.update({ + _id: id + }, { + $set: { is_read: true } + }); + + // Publish event + publishMessagingStream(message.user_id, user._id, 'read', id.toString()); + break; + } + }); +} diff --git a/src/api/streaming.ts b/src/api/streaming.ts new file mode 100644 index 0000000000..38068d1e3d --- /dev/null +++ b/src/api/streaming.ts @@ -0,0 +1,69 @@ +import * as http from 'http'; +import * as websocket from 'websocket'; +import * as redis from 'redis'; +import User from './models/user'; + +import homeStream from './stream/home'; +import messagingStream from './stream/messaging'; + +module.exports = (server: http.Server) => { + /** + * Init websocket server + */ + const ws = new websocket.server({ + httpServer: server + }); + + ws.on('request', async (request) => { + const connection = request.accept(); + + const user = await authenticate(connection); + + // Connect to Redis + const subscriber = redis.createClient( + config.redis.port, config.redis.host); + + connection.on('close', () => { + subscriber.unsubscribe(); + subscriber.quit(); + }); + + const channel = + request.resourceURL.pathname === '/' ? homeStream : + request.resourceURL.pathname === '/messaging' ? messagingStream : + null; + + if (channel !== null) { + channel(request, connection, subscriber, user); + } else { + connection.close(); + } + }); +}; + +function authenticate(connection: websocket.connection): Promise { + return new Promise((resolve, reject) => { + // Listen first message + connection.once('message', async (data) => { + const msg = JSON.parse(data.utf8Data); + + // Fetch user + // SELECT _id + const user = await User + .findOne({ + token: msg.i + }, { + _id: true + }); + + if (user === null) { + connection.close(); + return; + } + + connection.send('authenticated'); + + resolve(user); + }); + }); +} diff --git a/src/common/text/elements/bold.js b/src/common/text/elements/bold.js new file mode 100644 index 0000000000..41a01399dd --- /dev/null +++ b/src/common/text/elements/bold.js @@ -0,0 +1,17 @@ +/** + * Bold + */ + +const regexp = /\*\*(.+?)\*\*/; + +module.exports = { + test: x => new RegExp('^' + regexp.source).test(x), + parse: text => { + const bold = text.match(new RegExp('^' + regexp.source))[0]; + return { + type: 'bold', + content: bold, + bold: bold.substr(2, bold.length - 4) + }; + } +}; diff --git a/src/common/text/elements/hashtag.js b/src/common/text/elements/hashtag.js new file mode 100644 index 0000000000..f04b782007 --- /dev/null +++ b/src/common/text/elements/hashtag.js @@ -0,0 +1,23 @@ +/** + * Hashtag + */ + +module.exports = { + test: (x, i) => + /^\s#[^\s]+/.test(x) || (i == 0 && /^#[^\s]+/.test(x)) + , + parse: text => { + const isHead = text[0] == '#'; + const hashtag = text.match(/^\s?#[^\s]+/)[0]; + const res = !isHead ? [{ + type: 'text', + content: text[0] + }] : []; + res.push({ + type: 'hashtag', + content: isHead ? hashtag : hashtag.substr(1), + hashtag: isHead ? hashtag.substr(1) : hashtag.substr(2) + }); + return res; + } +}; diff --git a/src/common/text/elements/mention.js b/src/common/text/elements/mention.js new file mode 100644 index 0000000000..b58786fd1e --- /dev/null +++ b/src/common/text/elements/mention.js @@ -0,0 +1,17 @@ +/** + * Mention + */ + +const regexp = /@[a-zA-Z0-9\-]+/; + +module.exports = { + test: x => new RegExp('^' + regexp.source).test(x), + parse: text => { + const mention = text.match(new RegExp('^' + regexp.source))[0]; + return { + type: 'mention', + content: mention, + username: mention.substr(1) + }; + } +}; diff --git a/src/common/text/elements/url.js b/src/common/text/elements/url.js new file mode 100644 index 0000000000..d02aef0800 --- /dev/null +++ b/src/common/text/elements/url.js @@ -0,0 +1,16 @@ +/** + * URL + */ + +const regexp = /https?:\/\/[\w\/:%#@\$&\?!\(\)\[\]~\.=\+\-]+/; + +module.exports = { + test: x => new RegExp('^' + regexp.source).test(x), + parse: text => { + const link = text.match(new RegExp('^' + regexp.source))[0]; + return { + type: 'link', + content: link + }; + } +}; diff --git a/src/common/text/index.js b/src/common/text/index.js new file mode 100644 index 0000000000..973e7c5236 --- /dev/null +++ b/src/common/text/index.js @@ -0,0 +1,67 @@ +/** + * Misskey Text Analyzer + */ + +const elements = [ + require('./elements/bold'), + require('./elements/url'), + require('./elements/mention'), + require('./elements/hashtag') +]; + +function analyze(source) { + + if (source == '') { + return null; + } + + const tokens = []; + + function push(token) { + if (token != null) { + tokens.push(token); + source = source.substr(token.content.length); + } + } + + let i = 0; + + // パース + while (source != '') { + const parsed = elements.some(el => { + if (el.test(source, i)) { + let tokens = el.parse(source); + if (!Array.isArray(tokens)) { + tokens = [tokens]; + } + tokens.forEach(push); + return true; + } + }); + + if (!parsed) { + push({ + type: 'text', + content: source[0] + }); + } + + i++; + } + + // テキストを纏める + tokens[0] = [tokens[0]]; + return tokens.reduce((a, b) => { + if (a[a.length - 1].type == 'text' && b.type == 'text') { + const tail = a.pop(); + return a.concat({ + type: 'text', + content: tail.content + b.content + }); + } else { + return a.concat(b); + } + }); +} + +module.exports = analyze; diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000000..2369b709fc --- /dev/null +++ b/src/config.ts @@ -0,0 +1,95 @@ +/** + * Config loader + */ + +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; + +/** + * ユーザーが設定する必要のある情報 + */ +interface ISource { + maintainer: string; + url: string; + secondary_url: string; + port: number; + https: { + enable: boolean; + key: string; + cert: string; + ca: string; + }; + mongodb: { + host: string; + port: number; + db: string; + user_id: string; + pass: string; + }; + redis: { + host: string; + port: number; + pass: string; + }; + elasticsearch: { + enable: boolean; + host: string; + port: number; + pass: string; + }; + recaptcha: { + siteKey: string; + secretKey: string; + }; +} + +/** + * Misskeyが自動的に(ユーザーが設定した情報から推論して)設定する情報 + */ +interface Mixin { + themeColor: string; + themeColorForeground: string; + host: string; + scheme: string; + secondary_host: string; + secondary_scheme: string; + api_url: string; + auth_url: string; + dev_url: string; + drive_url: string; + proxy_url: string; +} + +export type IConfig = ISource & Mixin; + +/** + * 設定を取得します + * @param {string} path 設定ファイルのパス + * @return {IConfig} 設定 + */ +export default (path: string) => { + const config = yaml.safeLoad(fs.readFileSync(path, 'utf8')) as ISource; + + const mixin: Mixin = {} as Mixin; + + config.url = normalizeUrl(config.url); + config.secondary_url = normalizeUrl(config.secondary_url); + + mixin.themeColor = '#f76d6c'; + mixin.themeColorForeground = '#fff'; + mixin.host = config.url.substr(config.url.indexOf('://') + 3); + mixin.scheme = config.url.substr(0, config.url.indexOf('://')); + mixin.secondary_host = config.secondary_url.substr(config.secondary_url.indexOf('://') + 3); + mixin.secondary_scheme = config.secondary_url.substr(0, config.secondary_url.indexOf('://')); + mixin.api_url = `${mixin.scheme}://api.${mixin.host}`; + mixin.auth_url = `${mixin.scheme}://auth.${mixin.host}`; + mixin.dev_url = `${mixin.scheme}://dev.${mixin.host}`; + mixin.drive_url = `${mixin.secondary_scheme}://file.${mixin.secondary_host}`; + mixin.proxy_url = `${mixin.secondary_scheme}://proxy.${mixin.secondary_host}`; + + return Object.assign(config || {}, mixin) as IConfig; +}; + +function normalizeUrl(url: string): string { + return url[url.length - 1] === '/' ? url.substr(0, url.length - 1) : url; +} diff --git a/src/db/elasticsearch.ts b/src/db/elasticsearch.ts new file mode 100644 index 0000000000..27040d102a --- /dev/null +++ b/src/db/elasticsearch.ts @@ -0,0 +1,21 @@ +import * as elasticsearch from 'elasticsearch'; + +// Init ElasticSearch connection +const client = new elasticsearch.Client({ + host: `${config.elasticsearch.host}:${config.elasticsearch.port}` +}); + +// Send a HEAD request +client.ping({ + // Ping usually has a 3000ms timeout + requestTimeout: Infinity, + + // Undocumented params are appended to the query string + hello: 'elasticsearch!' +}, error => { + if (error) { + console.error('elasticsearch is down!'); + } +}); + +export default client; diff --git a/src/db/mongodb.ts b/src/db/mongodb.ts new file mode 100644 index 0000000000..e2b2479b49 --- /dev/null +++ b/src/db/mongodb.ts @@ -0,0 +1,8 @@ +import * as mongodb from 'mongodb'; + +export default async function(): Promise { + const uri = config.mongodb.user && config.mongodb.pass + ? `mongodb://${config.mongodb.user}:${config.mongodb.pass}@${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}` + : `mongodb://${config.mongodb.host}:${config.mongodb.port}/${config.mongodb.db}`; + return await mongodb.MongoClient.connect(uri); +}; diff --git a/src/db/redis.ts b/src/db/redis.ts new file mode 100644 index 0000000000..ba4d647811 --- /dev/null +++ b/src/db/redis.ts @@ -0,0 +1,9 @@ +import * as redis from 'redis'; + +export default redis.createClient( + config.redis.port, + config.redis.host, + { + auth_pass: config.redis.pass + } +); diff --git a/src/file/resources/avatar.jpg b/src/file/resources/avatar.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3c803f568e6169d1e2ee2478ddc4b5ebc64d6cec GIT binary patch literal 1322 zcmex=_1P|rX?qqI0P zFI~aY%U!`Mz|~!$%*;qrN1?DZF( zz|FzI#>vag%gf8n!^0;aBFM)t%+JFkBq1a$Dkd&2&L=1-Eh#1?A|@^dGK3MNla-Z= zjg3o;pNC(JWbpq0gCGY(149EdqY?v?AS1INS$tk&M(nX+7 z5o7})Izgg>NY+3_Z!z#NGXmYiEXZKb@K*%x3M2;OWU=l|MvAVYr%vb8MjX>FyCHG# z{)g*K7ehpU?S7k+U3W<6mf*LoXZNS;XI@K8NK8EHE-lm*^hb0>qX_GEV}4;R+n6~~ zdH#n^UDtYZ^^ zIe^uA@j3annakIl>Cj&mx4n8@sq1&ws-J&s_>>zrB+ozc z)C(Yumj5g-WM4CR>9<04rcd;$IHUN+U-ta!?^P%H85K?{%zShCz3Z8&CDR*a^fb2~ zvUXO}dNO_XUGHbJzc)UBS^Vmkg6Buu+e>#HnRfqsqo=B@OzLsr?v20RXWYNGa(~#) znoC+>wVdyLI%_jqzCFKjdj6j-LH(D(x+xz+R(0P^x+-<=Ud@yL45#x8WNtu>yy>#ZB2puwEr6aXE=Uuf09#`jq%F#m%FCq90=M} z{M%@$x@y*^OvYrkc@rn*#fj+e7P=F8`h95L-PGSTwrvj>cx%0tDs~j_leqT8OM7bQ zlBWu$J0=G&_PF%s%+6(xwyl%?JZF#i=fg#gQ%h%Bnx(|*ZJl=ZRMF?$cjw)^OJkiw zt}NAg;WahxmBd?<)p5rkKRd4hbuYW?0U4{WJ;x_bnss=Q_xgWN_H+HMFR-(4Zr${C zrS_Y=9+RIpu3P?ct=oI=Qum^lQgu_E)^kfbzTL9y+NbGT|1${g3@uo-HMTIYMJTW< zT4j2Z5y)W1g==0~)vb0Gq>@KS2G?Ahm0qtmO}}hxbKZ@3IYlwrAE?|Cw^v4Ax%WYW-pT z?=NSM*Y5lDq3-Ku-E{WLY;&e;-0Jmsd-N}}tslGpGw4r{u>hLGb>4l(YVnqhVz;f6 z-cJ7f+V=YC{>kHS?I&g5 bt9Ijc;(8GHtl6T- znw^BkkbN@mcl4g~ob$fV`<`bx&mZ4&f4Fe9Odb)bpySfQpR#Ovn zb9c3MaJB)!cR0h)$24)1OKE)5hQ(NQaHTxOITQ$s6eHS@W;w{m!0JAd4c#0b#bC_A z$(pWZ)O4?(K`SF_D*HIzPUT_LjcB7=az6z|^WOyd6PK5FzwXw}r_YnS{d!nJSlPr9 z=5idt1%vR5@`D0#dRk5G^&xOhtqEE| z5=T*BFo(AQ0b*bv^|C8ig82zzOd`b}?G`G%cYN7GhQd`^ObJcB zcP>}8LY+X`($5H|r{F}`wdQHGyAJ`tz1{n6Pfj_0er{%R&UW6J_^UZ*&uPU~SV@R9 zzE}4MP79E_J;Y&)+1UXaw^mr0SEEzGO)zW>?8mpw&yLV%-(dLUza8x-M@I8!KGScz z$j8gT$ha(e=UKhfbhHxX?4;{NG=0e92TsU?Kmu-|vBT)aySV&^+(RnNF= z&Wr4|h6n$SJ<&TQQ+`W=^C8Lf0bXBNr`7MRLBb`W_M|Dcgb@Vo_3{Zy zy9EKjrR>m?Wh!)s-fB{gC{yPV>x@(AU>!zQDcE zZ(j;ei!3b;}2) z++wX<*0ZLv#&NRNe5U8UIe7?~Rz$>6cytK+65{rK*=i6b z^kMhUygw$K_)@P@)Sqp;$@h+YdAzAcvDi-`exXd!+sByMEfTekGPggT(Pa=4jhDX6 zc-cv7MC(+ZVV+VS(ji@2Y%w0w{%r>FI;2=IC80W=C0_6GZu^IsY7Y74s3~pwoTCPL zPuQ-=+9=P`KF^X?P0cR6)>V-C1yL9!pLOFhCfi$I(lD}sUB)X-v2LbPMwfVZVG1F-0(iwQAite#GfWz>)mJ)kh>#wO<_g zoRZphUFxeePpaKhmZz>prYAQ}2685RWJ{7yGETxK%@Oam$bbP?DBX^251|qc@)|f+a}m%%-!vRF}IXYtXvpM zztSz#&D0%misqE^sqz8G65A4N$@yXb;fCQm%kIlu%ZtNAFSiVIhD64?JVt)gi@xSu z&9$2Fnh~#_A4;FC7yTCK*FUZ^FO2nSu48`XuA_bs2Z+4_=cQy2=i-kzpOLd(>5RAj z?)20F-@9^u@+E7BeN94>eQxjN++%pLpOJRcvc4!K+hSMVJH;~JY(||fTHSGw9?5IU zYo2L1(e=bB&9DB4&>{hp!H!1U>psC=) zf{7iE-7}AjHCfb)tMMIdDc&h4Lrng|e3vZ$j-*-cpEZ4NKCkEEYH_0_LzpjPor=vt zMY}quaXjtj9~6lyzg9L)r|x!6XXNAZ->=2bOWyXrZFYONo?ovbT`Y4z3ez^ybaSG< zBfwH#NzrDfdx1u&MG2nepB35gqCt3Eu|WhofKA2Hku!G{b~=8?Z8c8%t#Xlc4_Lz9 z!DeCgq1>SrG)^=Ew5hbu>0}SY9H>1gc}R&<^$2e+N<+`P zo|_T_#WQBRcJ;BOxPnJ-6-FGZ+`a9J9i0uFt(S245PTs%)HiZVos`1n$$pf6`N2%n zV3Xp5X%G0kbK2STnY8Q4p4O$aqd9L;a|_)anLZcIWX<9O<-(t`rMy<24HHz=mprUD zoA08-$G&KSH4CAB?xiN(xwH^BiJq@pxv-Elf;(iH79={b? z(=om@@U6IK=YwbH@DKDRG*nkTk@s2@6E$EeeqiLS~tF&9k<>4u`R-Qk1Mb~zDyrqRxyb8H6u+3 zH>%fii9{Qsd`_P+H>ml`*I}hEsKdVFb@FfhDN_TZRY$+k)mVeDzUR-?#P6BNBo#bq z?{F#ob>poyr&ZElMnuPy{+q_ZCs9cj#cstfOSj52@N-ipx)%L`>wcKw;cr)}KQ1q7 zE#@t{6mOa}zPeKHsM7x_Vuh)MhZHl*V}ZHy&GVVR<9cjYW!J|p8d)pZ<(r*V#UrcF z&gE|SX9Syf82>IQs%{t(8y;vJ^9fjE4&&og_(puPRQE>Q*Tkt_J!svf@8`X-IPN<< zHH~xFF}$XaUUOz#!1DmQ4Rzw8qo=F$=&Q!Y*Yp^Jb20o{@@})Z5Z7Vca+=Q@w;Ly! z->b~NJ2w6zFko8$`?Ky3qaV7)Q-iD55sO-W;(hXsUd5+!G1-dG>WSYUlD22=B~Iu6m7kfRtE~7Xb<}TDo1E%oe$BkzPOT?X8Rhv? zyV_#2eeK(PocT(f+b`kGJ0qgiS4$L26*aMLzbCa`-E>{F+3Z+0xmfD?OJ`A`m-sn& z$C1$Q@pbF#Y4a?nwEJ6tvbd&;h_-6o8O00JbU6 zxB!5c6aYUg0g!(R0Lt~TMY9F~bfcFqsa*FRUK%?U%j3h_Lb9;6DbTFPr_tr?(J5}%i(k(PtE7k}761IA>QKR{D37sMb*itJTD z3J-$!NKg%+P0#aDa`%A-5~M{1j!>EbH3Lhs;MfN^Db4#CN+cFIpw%X?MFmSy#j~*c zMQ5i}P7Cl-GxK>UM*Gw>k_FQ~fT9@fQ*V$g`1S#I%5kV6P0BG_nlx0S|8DK4P{B~c zk&Rm)pmnyI1(*SClO(lwf>=@{)N4|-GGr~vf;&#XVnWnQA-DXC{yRD_(7;p;~@SK6@NwzsqX&yFk-k}cFd__C>qUYrbK> zb7^DRoaf@6&fY4M2t~o!^*C0K^dE$S zvbE`~&+Gwvl=jBkuY>@&`iSC!1R>!qEHGf|OQwdc|7RGbd=ZNPX!0aU4S*?@H~~07 z2Je%$K1@df&?LaGObZ0}R<IzY-|r;M=&)RY!gVW{4t|K=SCQmql* zQ*B86-S8hY_b=?{-=(-hRo`NQYN<>s2-Qsu`Zxc#kOZOx;7G&)G#MF!J`SeNBkscm zv0?vU_$ZoS52D5erIg^g0HP6Oka&5jh&Ekl5mkp!iNtg711zCMe==AP^8ykN?Tcta z_C6Iudyob?$Ds=%QV42hCrxNH4V4;*#P1vGWDJdlQ2{a%|0jbzRFg)kb!+^3e;h=( z$V}Cniy;i{>jl$x!>M{Uf-9Dw$rWkJ3}^WR2)ViL1N`vKKY;Lx`aWm3TT_C^Y<0P_+Cy%l(IVK2TeRFH+$?nk2~?;C+4e0$2J)+( h&Dv;o8ctyb3_~nSPiq&CLU#giSzY&1sjB6@{{pzCeCGfF literal 0 HcmV?d00001 diff --git a/src/file/resources/dummy.png b/src/file/resources/dummy.png new file mode 100644 index 0000000000000000000000000000000000000000..39332b0c1beeda1edb90d78d25c16e7372aff030 GIT binary patch literal 6285 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 { + res.send('yee haw'); +}); + +app.get('/default-avatar.jpg', (req, res) => { + const file = fs.readFileSync(__dirname + '/resources/avatar.jpg'); + send(file, 'image/jpeg', req, res); +}); + +app.get('/app-default.jpg', (req, res) => { + const file = fs.readFileSync(__dirname + '/resources/dummy.png'); + send(file, 'image/png', req, res); +}); + +async function raw(data: Buffer, type: string, download: boolean, res: express.Response): Promise { + res.header('Content-Type', type); + + if (download) { + res.header('Content-Disposition', 'attachment'); + } + + res.send(data); +} + +async function thumbnail(data: Buffer, type: string, resize: number, res: express.Response): Promise { + if (!/^image\/.*$/.test(type)) { + data = fs.readFileSync(__dirname + '/resources/dummy.png'); + } + + let g = gm(data); + + if (resize) { + g = g.resize(resize, resize); + } + + g + .compress('jpeg') + .quality(80) + .toBuffer('jpeg', (err, img) => { + if (err !== undefined && err !== null) { + console.error(err); + res.sendStatus(500); + return; + } + + res.header('Content-Type', 'image/jpeg'); + res.send(img); + }); +} + +function send(data: Buffer, type: string, req: express.Request, res: express.Response): void { + if (req.query.thumbnail !== undefined) { + thumbnail(data, type, req.query.size, res); + } else { + raw(data, type, req.query.download !== undefined, res); + } +} + +/** + * Routing + */ + +app.get('/:id', async (req, res): Promise => { + const file = await File.findOne({_id: new mongodb.ObjectID(req.params.id)}); + + if (file === null) { + res.status(404).sendFile(__dirname + '/resources/dummy.png'); + return; + } + + send(file.data.buffer, file.type, req, res); +}); + +app.get('/:id/:name', async (req, res): Promise => { + const file = await File.findOne({_id: new mongodb.ObjectID(req.params.id)}); + + if (file === null) { + res.status(404).sendFile(__dirname + '/resources/dummy.png'); + return; + } + + send(file.data.buffer, file.type, req, res); +}); + +module.exports = app; diff --git a/src/himasaku/resources/himasaku.png b/src/himasaku/resources/himasaku.png new file mode 100644 index 0000000000000000000000000000000000000000..25cd91e95423d5249d79d407080a5ab79220ab30 GIT binary patch literal 144018 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 CMb+#8 literal 0 HcmV?d00001 diff --git a/src/himasaku/resources/index.html b/src/himasaku/resources/index.html new file mode 100644 index 0000000000..f9e45d7a74 --- /dev/null +++ b/src/himasaku/resources/index.html @@ -0,0 +1,35 @@ + + + + + + + + ひまさく + + + + ひまさく + + diff --git a/src/himasaku/server.ts b/src/himasaku/server.ts new file mode 100644 index 0000000000..e4fb0ef177 --- /dev/null +++ b/src/himasaku/server.ts @@ -0,0 +1,23 @@ +/** + * Himasaku Server + */ + +import * as express from 'express'; + +/** + * Init app + */ +const app = express(); + +app.disable('x-powered-by'); +app.locals.cache = true; + +app.get('/himasaku.png', (req, res) => { + res.sendFile(__dirname + '/resources/himasaku.png'); +}); + +app.get('*', (req, res) => { + res.sendFile(__dirname + '/resources/index.html'); +}); + +module.exports = app; diff --git a/src/index.d.ts b/src/index.d.ts new file mode 100644 index 0000000000..0af8362122 --- /dev/null +++ b/src/index.d.ts @@ -0,0 +1,11 @@ +import * as mongodb from 'mongodb'; +import { IConfig } from './config'; + +declare var config: IConfig; + +declare module NodeJS { + interface Global { + config: IConfig; + db: mongodb.Db; + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000000..b80b2da576 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,223 @@ +/** + * Misskey Core Entory Point! + */ + +Error.stackTraceLimit = Infinity; + +/** + * Module dependencies + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as cluster from 'cluster'; +const prominence = require('prominence'); +import { logInfo, logDone, logWarn, logFailed } from 'log-cool'; +import * as chalk from 'chalk'; +const git = require('git-last-commit'); +const portUsed = require('tcp-port-used'); +import ProgressBar from './utils/cli/progressbar'; +import initdb from './db/mongodb'; +import checkDependencies from './utils/check-dependencies'; + +// Init babel +require('babel-core/register'); +require('babel-polyfill'); + +const env = process.env.NODE_ENV; +const IS_PRODUCTION = env === 'production'; +const IS_DEBUG = !IS_PRODUCTION; + +global.config = require('./config').default(`${__dirname}/../.config/config.yml`); + +/** + * Initialize state + */ +enum State { + success, + warn, + failed +} + +// Set process title +process.title = 'Misskey'; + +// Start app +main(); + +/** + * Init proccess + */ +function main(): void { + // Master + if (cluster.isMaster) { + master(); + } else { // Workers + worker(); + } +} + +/** + * Init master proccess + */ +async function master(): Promise { + let state: State; + + try { + // initialize app + state = await init(); + } catch (e) { + console.error(e); + process.exit(1); + } + + const res = (t: string, c: string) => + console.log(chalk.bold(`--> ${(chalk as any)[c](t)}\n`)); + + switch (state) { + case State.failed: + res('Fatal error occurred :(', 'red'); + process.exit(); + return; + case State.warn: + res('Some problem(s) :|', 'yellow'); + break; + case State.success: + res('OK :)', 'green'); + break; + } + + // Spawn workers + spawn(() => { + console.log(chalk.bold.green(`\nMisskey Core is now running. [port:${config.port}]`)); + + // Listen new workers + cluster.on('fork', worker => { + console.log(`Process forked: [${worker.id}]`); + }); + + // Listen online workers + cluster.on('online', worker => { + console.log(`Process is now online: [${worker.id}]`); + }); + + // Listen for dying workers + cluster.on('exit', worker => { + // Replace the dead worker, + // we're not sentimental + console.log(chalk.red(`[${worker.id}] died :(`)); + cluster.fork(); + }); + }); +} + +/** + * Init worker proccess + */ +function worker(): void { + // Register config + global.config = config; + + // Init mongo + initdb().then(db => { + global.db = db; + + // start server + require('./server'); + }, err => { + console.error(err); + process.exit(0); + }); +} + +/** + * Init app + */ +async function init(): Promise { + console.log('Welcome to Misskey!\n'); + + console.log(chalk.bold('Misskey Core ')); + + let warn = false; + + // Get commit info + try { + const commit = await prominence(git).getLastCommit(); + console.log(`commit: ${commit.shortHash} ${commit.author.name} <${commit.author.email}>`); + console.log(` ${new Date(parseInt(commit.committedOn, 10) * 1000)}`); + } catch (e) { + // noop + } + + console.log('\nInitializing...\n'); + + if (IS_DEBUG) { + logWarn('It is not in the Production mode. Do not use in the Production environment.'); + } + + logInfo(`environment: ${env}`); + + // Get machine info + const totalmem = (os.totalmem() / 1024 / 1024 / 1024).toFixed(1); + const freemem = (os.freemem() / 1024 / 1024 / 1024).toFixed(1); + logInfo(`MACHINE: ${os.hostname()}`); + logInfo(`MACHINE: CPU: ${os.cpus().length}core`); + logInfo(`MACHINE: MEM: ${totalmem}GB (available: ${freemem}GB)`); + + if (!fs.existsSync(`${__dirname}/../.config/config.yml`)) { + logFailed('Configuration not found'); + return State.failed; + } + + logDone('Success to load configuration'); + logInfo(`maintainer: ${config.maintainer}`); + + checkDependencies(); + + // Check if a port is being used + if (await portUsed.check(config.port)) { + logFailed(`Port: ${config.port} is already used!`); + return State.failed; + } + + // Try to connect to MongoDB + try { + const db = await initdb(config); + logDone('Success to connect to MongoDB'); + db.close(); + } catch (e) { + logFailed(`MongoDB: ${e}`); + return State.failed; + } + + return warn ? State.warn : State.success; +} + +/** + * Spawn workers + */ +function spawn(callback: any): void { + // Count the machine's CPUs + const cpuCount = os.cpus().length; + + const progress = new ProgressBar(cpuCount, 'Starting workers'); + + // Create a worker for each CPU + for (let i = 0; i < cpuCount; i++) { + const worker = cluster.fork(); + worker.on('message', message => { + if (message === 'ready') { + progress.increment(); + } + }); + } + + // On all workers started + progress.on('complete', () => { + callback(); + }); +} + +// Dying away... +process.on('exit', () => { + console.log('Bye.'); +}); diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000000..a327504b26 --- /dev/null +++ b/src/server.ts @@ -0,0 +1,49 @@ +/** + * Core Server + */ + +import * as fs from 'fs'; +import * as http from 'http'; +import * as https from 'https'; + +import * as express from 'express'; +const vhost = require('vhost'); + +/** + * Init app + */ +const app = express(); +app.disable('x-powered-by'); + +/** + * Register modules + */ +app.use(vhost(`api.${config.host}`, require('./api/server'))); +app.use(vhost(config.secondary_host, require('./himasaku/server'))); +app.use(vhost(`file.${config.secondary_host}`, require('./file/server'))); +app.use(vhost(`proxy.${config.secondary_host}`, require('./web/service/proxy/server'))); +app.use(require('./web/server')); + +/** + * Create server + */ +const server = config.https.enable ? + https.createServer({ + key: fs.readFileSync(config.https.key), + cert: fs.readFileSync(config.https.cert), + ca: fs.readFileSync(config.https.ca) + }, app) : + http.createServer(app); + +/** + * Steaming + */ +require('./api/streaming')(server); + +/** + * Server listen + */ +server.listen(config.port, () => { + // Send a 'ready' message to parent process + process.send('ready'); +}); diff --git a/src/utils/check-dependencies.ts b/src/utils/check-dependencies.ts new file mode 100644 index 0000000000..7bcb87a68f --- /dev/null +++ b/src/utils/check-dependencies.ts @@ -0,0 +1,23 @@ +import {logInfo, logDone, logWarn} from 'log-cool'; +import {exec} from 'shelljs'; + +export default function(): void { + checkDependency('Node.js', 'node -v', x => x.match(/^v(.*)\r?\n$/)[1]); + checkDependency('npm', 'npm -v', x => x.match(/^(.*)\r?\n$/)[1]); + checkDependency('MongoDB', 'mongo --version', x => x.match(/^MongoDB shell version: (.*)\r?\n$/)[1]); + checkDependency('Redis', 'redis-server --version', x => x.match(/v=([0-9\.]*)/)[1]); + logDone('Successfully checked external dependencies'); +} + +function checkDependency(serviceName: string, command: string, transform: (x: string) => string): void { + const code = { + success: 0, + notFound: 127 + }; + const x = exec(command, { silent: true }) as any; + if (x.code === code.success) { + logInfo(`DEPS: ${serviceName} ${transform(x.stdout)}`); + } else if (x.code === code.notFound) { + logWarn(`Unable to find ${serviceName}`); + } +} diff --git a/src/utils/cli/indicator.ts b/src/utils/cli/indicator.ts new file mode 100644 index 0000000000..3e23f9b274 --- /dev/null +++ b/src/utils/cli/indicator.ts @@ -0,0 +1,35 @@ +import * as readline from 'readline'; + +/** + * Indicator + */ +export default class { + private clock: NodeJS.Timer; + + constructor(text: string) { + let i = 0; // Dots counter + + draw(); + this.clock = setInterval(draw, 300); + + function draw(): void { + cll(); + i = (i + 1) % 4; + const dots = new Array(i + 1).join('.'); + process.stdout.write(text + dots); // Write text + } + } + + public end(): void { + clearInterval(this.clock); + cll(); + } +} + +/** + * Clear current line + */ +function cll(): void { + readline.clearLine(process.stdout, 0); // Clear current text + readline.cursorTo(process.stdout, 0, null); // Move cursor to the head of line +} diff --git a/src/utils/cli/progressbar.ts b/src/utils/cli/progressbar.ts new file mode 100644 index 0000000000..19852b3ea3 --- /dev/null +++ b/src/utils/cli/progressbar.ts @@ -0,0 +1,87 @@ +import * as ev from 'events'; +import * as readline from 'readline'; +import * as chalk from 'chalk'; + +/** + * Progress bar + */ +class ProgressBar extends ev.EventEmitter { + public max: number; + public value: number; + public text: string; + private indicator: number; + + constructor(max: number, text: string = null) { + super(); + this.max = max; + this.value = 0; + this.text = text; + this.indicator = 0; + this.draw(); + + const iclock = setInterval(() => { + this.indicator = (this.indicator + 1) % 4; + this.draw(); + }, 200); + + this.on('complete', () => { + clearInterval(iclock); + }); + } + + public increment(): void { + this.value++; + this.draw(); + + // Check if it is fulfilled + if (this.value === this.max) { + this.indicator = null; + + cll(); + process.stdout.write(`${this.render()} -> ${chalk.bold('Complete')}\n`); + + this.emit('complete'); + } + } + + public draw(): void { + const str = this.render(); + cll(); + process.stdout.write(str); + } + + private render(): string { + const width = 30; + const t = this.text ? this.text + ' ' : ''; + + const v = Math.floor((this.value / this.max) * width); + const vs = new Array(v + 1).join('*'); + + const p = width - v; + const ps = new Array(p + 1).join(' '); + + const percentage = Math.floor((this.value / this.max) * 100); + const percentages = chalk.gray(`(${percentage}%)`); + + let i: string; + switch (this.indicator) { + case 0: i = '-'; break; + case 1: i = '\\'; break; + case 2: i = '|'; break; + case 3: i = '/'; break; + case null: i = '+'; break; + } + + return `${i} ${t}[${vs}${ps}] ${this.value}/${this.max} ${percentages}`; + } +} + +export default ProgressBar; + +/** + * Clear current line + */ +function cll(): void { + readline.clearLine(process.stdout, 0); // Clear current text + readline.cursorTo(process.stdout, 0, null); // Move cursor to the head of line +} diff --git a/src/web/about/base.pug b/src/web/about/base.pug new file mode 100644 index 0000000000..a026c03f28 --- /dev/null +++ b/src/web/about/base.pug @@ -0,0 +1,39 @@ +doctype html + +html(lang='ja', dir='ltr') + + head + meta(charset='utf-8') + meta(name='application-name', content='Misskey') + meta(name='theme-color', content='#f76d6c') + meta(name='referrer', content='origin') + meta(name='viewport', content='width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no') + link(rel='stylesheet', href='/resources/style.css') + title + block title + + body + nav + ul + li + p API + ul + li: a(href='/api/getting-started') Getting Started + li + p Entities + ul + li: a(href='/api/entities/post') Post + li: a(href='/api/entities/user') User + li: a(href='/api/library') ライブラリ + li: a(href='/license') ライセンス + + main + article + block content + footer + p.contribution + | 間違いを見つけた、またはドキュメントに貢献したいですか? + a(href='https://github.com/syuilo/misskey/blob/master/src/web/about/pages/' + path + '.pug', target='_blank') Github 上でこのページを編集する + | か、 + a(href='https://github.com/syuilo/misskey/fork', target='_blank') Github からこのサイトを Fork してプルリクエストしましょう! + p.copyright (c) syuilo 2016 diff --git a/src/web/about/pages/api/entities/post.pug b/src/web/about/pages/api/entities/post.pug new file mode 100644 index 0000000000..ad53be9540 --- /dev/null +++ b/src/web/about/pages/api/entities/post.pug @@ -0,0 +1,149 @@ +extend ../../../base + +block title + | Entity: Post + +block content + h1 Post + p 投稿を表します。 + + section + h2 Properties + table.entity + thead: tr + td Name + td Type + td Description + tbody + tr.nullable.optional + td app + td: a(href='./app', target='_blank') App + td 投稿したアプリ + tr.nullable + td app_id + td ID + td 投稿したアプリのID + tr + td created_at + td Date + td 投稿日時 + tr + td id + td ID + td 投稿ID + tr.optional + td is_liked + td Boolean + td いいね したかどうか + tr + td likes_count + td Number + td いいね数 + tr.nullable.optional + td media_ids + td ID[] + td 添付されたメディアのIDの配列 + tr.nullable.optional + td media + td: a(href='./drive-file', target='_blank') DriveFile[] + td 添付されたメディアの配列 + tr + td replies_count + td Number + td 返信数 + tr.optional + td reply_to + td: a(href='./post', target='_blank') Post + td 返信先の投稿 + tr.nullable + td reply_to_id + td ID + td 返信先の投稿のID + tr.optional + td repost + td: a(href='./post', target='_blank') Post + td Repostした投稿 + tr + td repost_count + td Number + td Repostされた数 + tr.nullable + td repost_id + td ID + td Repostした投稿のID + tr.nullable + td text + td String + td 本文 + tr.optional + td user + td: a(href='./user', target='_blank') User + td 投稿者 + tr + td user_id + td ID + td 投稿者のID + + section + h2 Example + pre: code. + { + "created_at": "2016-12-10T00:28:50.114Z", + "media_ids": null, + "reply_to_id": "584a16b15860fc52320137e3", + "repost_id": null, + "text": "小日向美穂だぞ!", + "user_id": "5848bf7764e572683f4402f8", + "app_id": null, + "likes_count": 1, + "replies_count": 1, + "id": "584b4c42d8e5186f8f755d0c", + "user": { + "birthday": null, + "created_at": "2016-12-08T02:03:35.332Z", + "bio": "女が嫌いです、女性は好きです", + "followers_count": 11, + "following_count": 11, + "links": null, + "location": "", + "name": "女が嫌い", + "posts_count": 26, + "likes_count": 2, + "liked_count": 20, + "username": "onnnagakirai", + "id": "5848bf7764e572683f4402f8", + "avatar_url": "https://file.himasaku.net/5848c0ec64e572683f4402fc", + "banner_url": "https://file.himasaku.net/5848c12864e572683f4402fd", + "is_following": true, + "is_followed": true + }, + "reply_to": { + "created_at": "2016-12-09T02:28:01.563Z", + "media_ids": null, + "reply_to_id": "5849d35e547e4249be329884", + "repost_id": null, + "text": "アイコン小日向美穂?", + "user_id": "57d01a501fdf2d07be417afe", + "app_id": null, + "replies_count": 1, + "id": "584a16b15860fc52320137e3", + "user": { + "birthday": null, + "created_at": "2016-09-07T13:46:56.605Z", + "bio": "どうすれば君だけのために生きていけるの", + "followers_count": 51, + "following_count": 97, + "links": null, + "location": "川崎", + "name": "きな子", + "posts_count": 4813, + "username": "syuilo", + "likes_count": 3141, + "liked_count": 750, + "id": "57d01a501fdf2d07be417afe", + "avatar_url": "https://file.himasaku.net/583ddc6e64df272771f74c1a", + "banner_url": "https://file.himasaku.net/584bfc82d8e5186f8f755ec5" + } + }, + "is_liked": true + } diff --git a/src/web/about/pages/api/entities/user.pug b/src/web/about/pages/api/entities/user.pug new file mode 100644 index 0000000000..eef973fd63 --- /dev/null +++ b/src/web/about/pages/api/entities/user.pug @@ -0,0 +1,118 @@ +extend ../../../base + +block title + | Entity: User + +block content + h1 User + p ユーザーを表します。 + + section + h2 Properties + table.entity + thead: tr + td Name + td Type + td Description + tbody + tr.nullable.optional + td avatar_id + td ID + td アバターに設定しているドライブのファイルのID + tr.nullable + td avatar_url + td String + td アバターURL + tr.nullable.optional + td banner_id + td ID + td バナーに設定しているドライブのファイルのID + tr.nullable + td banner_url + td String + td バナーURL + tr.nullable + td bio + td String + td プロフィール + tr.nullable + td birthday + td String + td 誕生日 + tr + td created_at + td Date + td アカウント作成日時 + tr.optional + td drive_capacity + td Number + td ドライブの最大容量(byte単位) + tr + td followers_count + td Number + td フォロワー数 + tr + td following_count + td Number + td フォロー数 + tr + td id + td ID + td ユーザーID + tr.optional + td is_followed + td Boolean + td フォローされているか + tr.optional + td is_following + td Boolean + td フォローしているか + tr + td liked_count + td Number + td 投稿にいいねされた数 + tr + td likes_count + td Number + td 投稿にいいねした数 + tr.nullable + td location + td String + td 場所 + tr + td name + td String + td ニックネーム + tr + td posts_count + td Number + td 投稿数 + tr + td username + td String + td ユーザー名 + + section + h2 Example + pre: code. + { + "avatar_id": "583ddc6e64df272771f74c1a", + "avatar_url": "https://file.himasaku.net/583ddc6e64df272771f74c1a", + "banner_id": "584bfc82d8e5186f8f755ec5", + "banner_url": "https://file.himasaku.net/584bfc82d8e5186f8f755ec5", + "bio": "どうすれば君だけのために生きていけるの", + "birthday": null, + "created_at": "2016-09-07T13:46:56.605Z", + "drive_capacity": 1073741824, + "email": null, + "followers_count": 51, + "following_count": 97, + "id": "57d01a501fdf2d07be417afe", + "liked_count": 750, + "likes_count": 3130, + "links": null, + "location": "川崎", + "name": "きな子", + "posts_count": 4811, + "username": "syuilo" + } diff --git a/src/web/about/pages/api/getting-started.pug b/src/web/about/pages/api/getting-started.pug new file mode 100644 index 0000000000..974964e3eb --- /dev/null +++ b/src/web/about/pages/api/getting-started.pug @@ -0,0 +1,74 @@ +extend ../../base + +block title + | Getting Started + +block content + h1 Getting Started + + p MisskeyはREST APIやStreaming APIを提供しており、プログラムからMisskeyの全ての機能を利用することができます。 + p それらのAPIを利用するには、まずAPIを利用したいアカウントのアクセストークンを取得する必要があります: + + section + h2 自分のアクセストークンを取得したい場合 + p 自分自身のアクセストークンは、設定 > API で確認できます。 + p.tip + | アカウントを乗っ取られてしまう可能性があるため、トークンは第三者に教えないでください(アプリなどにも入力しないでください)。 + br + | 万が一トークンが漏れたりその可能性がある場合は トークンを再生成できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します) + + section + h2 他人のアクセストークンを取得する + p + | 不特定多数のユーザーからAPIを利用したい場合、アプリケーションを作成します。 + br + | アプリケーションを作成すると、ユーザーが連携を許可した時に、そのユーザーのアクセストークンを取得することができます。 + p アプリケーションを作成しアクセストークンを取得するまでの流れを見ていきます。 + + section + h3 アプリケーションを作成する + p まずはあなたのアプリケーションを作成しましょう。 + p + | デベロッパーセンターにアクセスし、アプリ > アプリ作成 に進みます。 + br + | 次に、フォームに必要事項を記入します: + dl + dt アプリケーション名 + dd あなたのアプリケーションの名前。 + dt Named ID + dd アプリを識別する/a-z-/で構成されたID。 + dt アプリの概要 + dd アプリの簡単な説明を入力してください。 + dt コールバックURL + dd あなたのアプリケーションがWebアプリケーションである場合、ユーザーが後述するフォームで認証を終えた際にリダイレクトするURLを設定できます。 + dt 権限 + dd アプリケーションが要求する権限。ここで要求した機能だけがAPIからアクセスできます。 + p.tip + | 権限はアプリ作成後も変更できますが、新たな権限を付与する場合、その時点で関連付けられているユーザーはすべて無効になります。 + p + | アプリケーションを作成すると、作ったアプリの管理ページに進みます。 + br + | アプリのシークレットキー(App Secret)が表示されていますので、メモしておいてください。 + p.tip + | アプリに成りすまされる可能性があるため、極力このシークレットキーは公開しないようにしてください。 + + section + h3 ユーザーに認証させる + p あなたのアプリを使ってもらうには、ユーザーにアカウントへアクセスすることを許可してもらい、Misskeyにそのユーザーのアクセストークンを発行してもらう必要があります。 + p 認証セッションを開始するには、#{config.api_url}/auth/session/generateへパラメータにapp_secretとしてApp Secretを含めたリクエストを送信します。 + p + | そうすると、レスポンスとして認証セッションのトークンや認証フォームのURLが取得できます。 + br + | この認証フォームのURLをブラウザで表示し、ユーザーにフォームを表示してください。 + section + h4 あなたのアプリがコールバックURLを設定している場合 + p ユーザーがアプリの連携を許可すると設定しているコールバックURLにtokenという名前でセッションのトークンが含まれたクエリを付けてリダイレクトします。 + section + h4 あなたのアプリがコールバックURLを設定していない場合 + p ユーザーがアプリの連携を許可したことを(何らかの方法で(たとえばボタンを押させるなど))確認出来るようにしてください。 + p + | 次に、#{config.api_url}/auth/session/userkeyapp_secretとしてApp Secretを、tokenとしてセッションのトークンをパラメータとして付与したリクエストを送信してください。 + br + | 上手くいけば、認証したユーザーのアクセストークンがレスポンスとして取得できます。おめでとうございます! + + p アクセストークンを取得できたら、あとは簡単です。REST APIなら、リクエストにアクセストークンを_userkey(「自分のアクセストークンを取得したい場合」の方法で取得したアクセストークンの場合はi)としてパラメータに含めるだけです。 diff --git a/src/web/about/pages/api/library.pug b/src/web/about/pages/api/library.pug new file mode 100644 index 0000000000..b1ed16e71d --- /dev/null +++ b/src/web/about/pages/api/library.pug @@ -0,0 +1,14 @@ +extend ../../base + +block title + | ライブラリ + +block content + h1 ライブラリ + + p Misskey APIを便利に利用するためのライブラリ一覧です。 + + section + h2 .NET + ul + li: strong: a(href='https://github.com/syuilo/Misq') Misq (公式) diff --git a/src/web/about/pages/license.pug b/src/web/about/pages/license.pug new file mode 100644 index 0000000000..7adda0bff4 --- /dev/null +++ b/src/web/about/pages/license.pug @@ -0,0 +1,9 @@ +extend ../base + +block title + | ライセンス + +block content + h1 ライセンス + + include ../../../../LICENSE diff --git a/src/web/about/resources/style.css b/src/web/about/resources/style.css new file mode 100644 index 0000000000..53d658fa6b --- /dev/null +++ b/src/web/about/resources/style.css @@ -0,0 +1,199 @@ +html { + font-family: sans-serif; +} + +body { + margin: 0; + color: #34495e; +} + +nav { + display: block; + float: left; + width: 210px; +} +nav ul { + display: block; + margin: 0 0 16px 0; + padding: 0 0 0 16px; + list-style: none; +} +nav ul li { + margin: 0; + padding: 0; +} +nav ul li p { + margin: 16px 0 0 0; +} +@media screen and (max-width: 910px) { + nav { + display: none; + } +} + +main { + float: left; + box-sizing: border-box; + padding: 32px; + width: 100%; + max-width: 700px; +} +@media screen and (max-width: 700px) { + main { + font-size: 8px; + } +} + +footer { + padding: 32px 0 0 0; + margin: 32px 0 0 0; + border-top: solid 1px #eee; +} + +footer .contribution { + margin: 0 0 16px 0; +} + +footer .copyright { + margin: 16px 0 0 0; + color: #aaa; +} + +a { + text-decoration: none; + color: #f76d6c; +} + a:hover { + text-decoration: underline; + } + +section { + margin: 32px 0; +} + +h1 { + margin: 0 0 24px 0; + padding: 16px 0; + font-size: 1.5em; + border-bottom: solid 2px #eee; +} + +h2 { + margin: 0 0 24px 0; + padding: 0 0 16px 0; + font-size: 1.4em; + border-bottom: solid 1px #eee; +} + +h3 { + margin: 0; + padding: 0; + font-size: 1.25em; +} + +h4 { + margin: 0; +} + +p { + margin: 1em 0; + line-height: 1.6em; +} + +p.tip { + position: relative; + padding: 12px 24px 12px 30px; + margin: 1em 0; + font-size: 0.9em; + border-left: 4px solid #f66; + background-color: #f8f8f8; + border-bottom-right-radius: 2px; + border-top-right-radius: 2px; +} + p.tip:before { + position: absolute; + top: 14px; + left: -12px; + background-color: #f66; + color: #fff; + content: "!"; + width: 20px; + height: 20px; + border-radius: 100%; + text-align: center; + line-height: 20px; + font-weight: bold; + font-family: 'Dosis', 'Source Sans Pro', 'Helvetica Neue', Arial, sans-serif; + font-size: 14px; + } + +table { + width: 100%; + border-spacing: 0; + border-collapse: collapse; +} + +table thead { + font-weight: bold; + border-bottom: solid 2px #eee; +} + +table tbody tr { + border-bottom: dashed 1px #eee; +} + +table th, table td { + padding: 8px 16px; +} + +table.entity tbody tr td:nth-child(1) { + font-family: Consolas, 'Courier New', Courier, Monaco, monospace; +} + +table.entity tbody tr td:nth-child(2) { + font-style: italic; +} + +table.entity tr td:nth-child(3):after { + margin-left: 8px; + opacity: 0.7; +} + +table.entity tr.nullable td:nth-child(2):after { + content: "?"; + opacity: 0.7; +} +table.entity tr.nullable td:nth-child(3):after { + content: "(Null許容)"; +} + +table.entity tr.optional { + opacity: 0.7; +} +table.entity tr.optional td:nth-child(3):after { + content: "(省略可能)"; +} + +table.entity tr.nullable.optional td:nth-child(3):after { + content: "(Null許容かつ省略可能)"; +} + +pre, code, var, samp, kbd { + font-family: Consolas, 'Courier New', Courier, Monaco, monospace; +} + +code { + display: inline-block; + margin: 0 4px; + padding: 0 8px; + color: #525252; + background: #f8f8f8; + border-radius: 2px; +} + +pre code { + display: block; + overflow: auto; + margin: 0; + padding: 32px; +} diff --git a/src/web/app/auth/resources/logo.svg b/src/web/app/auth/resources/logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..19b8a2737e4d2fccbf3b6a6be1294a46ab2465c4 GIT binary patch literal 646 zcmZuuT~C8R5PY_Y|6yHUTkejQ3XRyr))*6`CVs_73MZVU9D%d6|K2@{jWxZOJ7#Bw z+1<YKYGsz4@H znwbzi;o8r&lFhZYGH|^ySQb#pjwo8>aPxVH^+PQ{_1s=|UpZrqWB&ol~Q9et|(3csSZ4)+mn35ZfRJ@RwG! z)I)rwjI44+mu(xNB#xmO(^ULm?0Ee#_Z<&>PLcW}K?q#%(H?KB9ZsXz<1MqphpyG` Vd|cORcRs=;=MG0^H*Qx*_YZrqxSaq1 literal 0 HcmV?d00001 diff --git a/src/web/app/auth/script.js b/src/web/app/auth/script.js new file mode 100644 index 0000000000..9743415b12 --- /dev/null +++ b/src/web/app/auth/script.js @@ -0,0 +1,19 @@ +/** + * Authorize Form + */ + +const riot = require('riot'); +document.title = 'Misskey | アプリの連携'; +require('./tags.ls'); +const boot = require('../boot.ls'); + +/** + * Boot + */ +boot(me => { + mount(document.createElement('mk-index')); +}); + +function mount(content) { + riot.mount(document.getElementById('app').appendChild(content)); +} diff --git a/src/web/app/auth/style.styl b/src/web/app/auth/style.styl new file mode 100644 index 0000000000..046a5ff6ee --- /dev/null +++ b/src/web/app/auth/style.styl @@ -0,0 +1,14 @@ +@import "../base" + +html + background #eee + + @media (max-width 600px) + background #fff + +body + margin 0 + padding 32px 0 + + @media (max-width 600px) + padding 0 diff --git a/src/web/app/auth/tags.ls b/src/web/app/auth/tags.ls new file mode 100644 index 0000000000..5f618aaadf --- /dev/null +++ b/src/web/app/auth/tags.ls @@ -0,0 +1,2 @@ +require './tags/index.tag' +require './tags/form.tag' diff --git a/src/web/app/auth/tags/form.tag b/src/web/app/auth/tags/form.tag new file mode 100644 index 0000000000..f5b1555554 --- /dev/null +++ b/src/web/app/auth/tags/form.tag @@ -0,0 +1,126 @@ +mk-form + header + h1 + i { app.name } + | があなたの + b アカウント + | に + b アクセス + | することを + b 許可 + | しますか? + img(src={ app.icon_url + '?thumbnail&size=64' }) + div.app + section + h2 { app.name } + p.nid { app.name_id } + p.description { app.description } + section + h2 このアプリは次の権限を要求しています: + ul + virtual(each={ p in app.permission }) + li(if={ p == 'account-read' }) アカウントの情報を見る。 + li(if={ p == 'account-write' }) アカウントの情報を操作する。 + li(if={ p == 'post-write' }) 投稿する。 + li(if={ p == 'like-write' }) いいねしたりいいね解除する。 + li(if={ p == 'following-write' }) フォローしたりフォロー解除する。 + li(if={ p == 'drive-read' }) ドライブを見る。 + li(if={ p == 'drive-write' }) ドライブを操作する。 + li(if={ p == 'notification-read' }) 通知を見る。 + li(if={ p == 'notification-write' }) 通知を操作する。 + + div.action + button(onclick={ cancel }) キャンセル + button(onclick={ accept }) アクセスを許可 + +style. + display block + + > header + > h1 + margin 0 + padding 32px 32px 20px 32px + font-size 24px + font-weight normal + color #777 + + i + color #77aeca + + &:before + content '「' + + &:after + content '」' + + b + color #666 + + > img + display block + z-index 1 + width 84px + height 84px + margin 0 auto -38px auto + border solid 5px #fff + border-radius 100% + box-shadow 0 2px 2px rgba(0, 0, 0, 0.1) + + > .app + padding 44px 16px 0 16px + color #555 + background #eee + box-shadow 0 2px 2px rgba(0, 0, 0, 0.1) inset + + &:after + content '' + display block + clear both + + > section + float left + width 50% + padding 8px + text-align left + + > h2 + margin 0 + font-size 16px + color #777 + + > .action + padding 16px + + > button + margin 0 8px + + @media (max-width 600px) + > header + > img + box-shadow none + + > .app + box-shadow none + + @media (max-width 500px) + > header + > h1 + font-size 16px + +script. + @mixin \api + + @session = @opts.session + @app = @session.app + + @cancel = ~> + @api \auth/deny do + token: @session.token + .then ~> + @trigger \denied + + @accept = ~> + @api \auth/accept do + token: @session.token + .then ~> + @trigger \accepted diff --git a/src/web/app/auth/tags/index.tag b/src/web/app/auth/tags/index.tag new file mode 100644 index 0000000000..b7017daec6 --- /dev/null +++ b/src/web/app/auth/tags/index.tag @@ -0,0 +1,129 @@ +mk-index + main(if={ SIGNIN }) + p.fetching(if={ fetching }) + | 読み込み中 + mk-ellipsis + mk-form@form(if={ state == null && !fetching }, session={ session }) + div.denied(if={ state == 'denied' }) + h1 アプリケーションの連携をキャンセルしました。 + p このアプリがあなたのアカウントにアクセスすることはありません。 + div.accepted(if={ state == 'accepted' }) + h1 { session.app.is_authorized ? 'このアプリは既に連携済みです' : 'アプリケーションの連携を許可しました'} + p(if={ session.app.callback_url }) + | アプリケーションに戻っています + mk-ellipsis + p(if={ !session.app.callback_url }) アプリケーションに戻って、やっていってください。 + div.error(if={ state == 'fetch-session-error' }) + p セッションが存在しません。 + main.signin(if={ !SIGNIN }) + h1 サインインしてください + mk-signin + footer + img(src='/_/resources/auth/logo.svg', alt='Misskey') + +style. + display block + + > main + width 100% + max-width 500px + margin 0 auto + text-align center + background #fff + box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) + + > .fetching + margin 0 + padding 32px + color #555 + + > div + padding 64px + + > h1 + margin 0 0 8px 0 + padding 0 + font-size 20px + font-weight normal + + > p + margin 0 + color #555 + + &.denied > h1 + color #e65050 + + &.accepted > h1 + color #50bbe6 + + &.signin + padding 32px 32px 16px 32px + + > h1 + margin 0 0 22px 0 + padding 0 + font-size 20px + font-weight normal + color #555 + + @media (max-width 600px) + max-width none + box-shadow none + + @media (max-width 500px) + > div + > h1 + font-size 16px + + > footer + > img + display block + width 64px + height 64px + margin 0 auto + +script. + @mixin \i + @mixin \api + + @state = null + @fetching = true + + @token = window.location.href.split \/ .pop! + + @on \mount ~> + if not @SIGNIN then return + + # Fetch session + @api \auth/session/show do + token: @token + .then (session) ~> + @session = session + @fetching = false + + # 既に連携していた場合 + if @session.app.is_authorized + @api \auth/accept do + token: @session.token + .then ~> + @accepted! + else + @update! + + @refs.form.on \denied ~> + @state = \denied + @update! + + @refs.form.on \accepted @accepted + + .catch (error) ~> + @fetching = false + @state = \fetch-session-error + @update! + + @accepted = ~> + @state = \accepted + @update! + + if @session.app.callback_url + location.href = @session.app.callback_url + '?token=' + @session.token diff --git a/src/web/app/auth/view.pug b/src/web/app/auth/view.pug new file mode 100644 index 0000000000..a7b9f9263b --- /dev/null +++ b/src/web/app/auth/view.pug @@ -0,0 +1,6 @@ +extends ../base + +block head + meta(name='viewport', content='width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no') + link(rel='stylesheet', href='/_/resources/auth/style.css') + script(src='/_/resources/auth/script.js', async, defer) diff --git a/src/web/app/base.pug b/src/web/app/base.pug new file mode 100644 index 0000000000..805feaee6c --- /dev/null +++ b/src/web/app/base.pug @@ -0,0 +1,23 @@ +doctype html + +!= '\r\n\r\n' + +html(lang='ja', dir='ltr') + + head + meta(charset='utf-8') + meta(name='application-name', content='Misskey') + meta(name='theme-color', content= themeColor) + meta(name='referrer', content='origin') + title Misskey + style + include ./../../../built/web/resources/init.css + script(src='https://use.fontawesome.com/22aba0df4f.js', async) + block head + + body + noscript: div: p JavaScriptを有効にしてください + div#init: p + span . + span . + span . diff --git a/src/web/app/base.styl b/src/web/app/base.styl new file mode 100644 index 0000000000..5eab205480 --- /dev/null +++ b/src/web/app/base.styl @@ -0,0 +1,118 @@ +@charset 'utf-8' + +$theme-color = convert(themeColor) +$theme-color-foreground = convert(themeColorForeground) + +@import './reset' + +/* + ::selection + background $theme-color + color #fff +*/ + +* + tap-highlight-color rgba($theme-color, 0.7) + -webkit-tap-highlight-color rgba($theme-color, 0.7) + +html, body + margin 0 + padding 0 + scroll-behavior smooth + text-size-adjust 100% + font-family sans-serif + +html + &.progress + &, * + cursor progress !important + +#error + position fixed + z-index 32768 + top 0 + left 0 + width 100% + height 100% + background #00f + color #fff + + > p + text-align center + +#nprogress + pointer-events none + + position absolute + z-index 65536 + + .bar + background $theme-color + + position fixed + z-index 65537 + top 0 + left 0 + + width 100% + height 2px + + /* Fancy blur effect */ + .peg + display block + position absolute + right 0px + width 100px + height 100% + box-shadow 0 0 10px $theme-color, 0 0 5px $theme-color + opacity 1 + + transform rotate(3deg) translate(0px, -4px) + +#wait + display block + position fixed + z-index 65537 + top 15px + right 15px + + &:before + content "" + display block + width 18px + height 18px + box-sizing border-box + + border solid 2px transparent + border-top-color $theme-color + border-left-color $theme-color + border-radius 50% + + animation progress-spinner 400ms linear infinite + + @keyframes progress-spinner + 0% + transform rotate(0deg) + 100% + transform rotate(360deg) + +a + text-decoration none + color $theme-color + cursor pointer + + &:hover + text-decoration underline + + * + cursor pointer + +mk-locker + display block + position fixed + top 0 + left 0 + z-index 65536 + width 100% + height 100% + cursor wait diff --git a/src/web/app/boot.ls b/src/web/app/boot.ls new file mode 100644 index 0000000000..d1230f8f0b --- /dev/null +++ b/src/web/app/boot.ls @@ -0,0 +1,154 @@ +#================================ +# MISSKEY BOOT LOADER +# +# Misskeyを起動します。 +# 1. 初期化 +# 2. ユーザー取得(ログインしていれば) +# 3. アプリケーションをマウント +#================================ + +# LOAD DEPENDENCIES +#-------------------------------- + +riot = require \riot +require \velocity +log = require './common/scripts/log.ls' +api = require './common/scripts/api.ls' +signout = require './common/scripts/signout.ls' +generate-default-userdata = require './common/scripts/generate-default-userdata.ls' +mixins = require './common/mixins.ls' +check-for-update = require './common/scripts/check-for-update.ls' +require './common/tags.ls' + +# MISSKEY ENTORY POINT +#-------------------------------- + +# for subdomains +document.domain = CONFIG.host + +# ↓ iOS待ちPolyfill (SEE: http://caniuse.com/#feat=fetch) +require \fetch + +# ↓ NodeList、HTMLCollectionで forEach を使えるようにする +if NodeList.prototype.for-each == undefined + NodeList.prototype.for-each = Array.prototype.for-each +if HTMLCollection.prototype.for-each == undefined + HTMLCollection.prototype.for-each = Array.prototype.for-each + +# ↓ iOSでプライベートモードだとlocalStorageが使えないので既存のメソッドを上書きする +try + local-storage.set-item \kyoppie \yuppie +catch e + Storage.prototype.set-item = ~> # noop + +# MAIN PROCESS +#-------------------------------- + +log "Misskey (aoi) v:#{VERSION}" + +# Check for Update +check-for-update! + +# Get token from cookie +i = ((document.cookie.match /i=(\w+)/) || [null null]).1 + +if i? then log "ME: #{i}" + +# ユーザーをフェッチしてコールバックする +module.exports = (callback) ~> + # Get cached account data + cached-me = JSON.parse local-storage.get-item \me + + if cached-me?.data?.cache + fetched cached-me + + # 後から新鮮なデータをフェッチ + fetchme i, true, (fresh-data) ~> + Object.assign cached-me, fresh-data + cached-me.trigger \updated + else + # キャッシュ無効なのにキャッシュが残ってたら掃除 + if cached-me? + local-storage.remove-item \me + + fetchme i, false, fetched + + function fetched me + + if me? + riot.observable me + + if me.data.cache + local-storage.set-item \me JSON.stringify me + + me.on \updated ~> + # キャッシュ更新 + local-storage.set-item \me JSON.stringify me + + log "Fetched! Hello #{me.username}." + + # activate mixins + mixins me + + # destroy loading screen + init = document.get-element-by-id \init + init.parent-node.remove-child init + + # set main element + document.create-element \div + ..set-attribute \id \app + .. |> document.body.append-child + + # Call main proccess + try + callback me + catch error + panic error + +# ユーザーをフェッチしてコールバックする +function fetchme token, silent, cb + me = null + + # Return when not signed in + if not token? then return done! + + # Fetch user + fetch "#{CONFIG.api.url}/i" do + method: \POST + headers: + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + body: "i=#token" + .then (res) ~> + # When failed to authenticate user + if res.status != 200 then signout! + + i <~ res.json!.then + me := i + me.token = token + + # initialize it if user data is empty + if me.data? then done! else init! + .catch ~> + if not silent + info = document.create-element \mk-core-error + |> document.body.append-child + riot.mount info, do + retry: ~> fetchme token, false, cb + else + # noop + + function done + if cb? then cb me + + function init + data = generate-default-userdata! + + api token, \i/appdata/set do + data: JSON.stringify data + .then ~> + me.data = data + done! + +function panic e + console.error e + document.body.innerHTML = '

    致命的な問題が発生しました。

    ' diff --git a/src/web/app/client/script.js b/src/web/app/client/script.js new file mode 100644 index 0000000000..d8531e9cc8 --- /dev/null +++ b/src/web/app/client/script.js @@ -0,0 +1,40 @@ +const head = document.getElementsByTagName('head')[0]; +const ua = navigator.userAgent.toLowerCase(); +const isMobile = /mobile|iphone|ipad|android/.test(ua); + +if (isMobile) { + mountMobile(); +} else { + mountDesktop(); +} + +function mountDesktop() { + const style = document.createElement('link'); + style.setAttribute('href', '/_/resources/desktop/style.css'); + style.setAttribute('rel', 'stylesheet'); + head.appendChild(style); + + const script = document.createElement('script'); + script.setAttribute('src', '/_/resources/desktop/script.js'); + script.setAttribute('async', 'true'); + script.setAttribute('defer', 'true'); + head.appendChild(script); +} + +function mountMobile() { + const meta = document.createElement('meta'); + meta.setAttribute('name', 'viewport'); + meta.setAttribute('content', 'width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no'); + head.appendChild(meta); + + const style = document.createElement('link'); + style.setAttribute('href', '/_/resources/mobile/style.css'); + style.setAttribute('rel', 'stylesheet'); + head.appendChild(style); + + const script = document.createElement('script'); + script.setAttribute('src', '/_/resources/mobile/script.js'); + script.setAttribute('async', 'true'); + script.setAttribute('defer', 'true'); + head.appendChild(script); +} diff --git a/src/web/app/client/view.pug b/src/web/app/client/view.pug new file mode 100644 index 0000000000..6f0ff3cd5c --- /dev/null +++ b/src/web/app/client/view.pug @@ -0,0 +1,5 @@ +extends ../base + +block head + script + include ./../../../../built/web/resources/client/script.js diff --git a/src/web/app/common/mixins.ls b/src/web/app/common/mixins.ls new file mode 100644 index 0000000000..1320cacd1e --- /dev/null +++ b/src/web/app/common/mixins.ls @@ -0,0 +1,40 @@ +riot = require \riot + +module.exports = (me) ~> + i = if me? then me.token else null + + (require './scripts/i.ls') me + + riot.mixin \api do + api: (require './scripts/api.ls').bind null i + + riot.mixin \cropper do + Cropper: require \cropper + + riot.mixin \signout do + signout: require './scripts/signout.ls' + + riot.mixin \messaging-stream do + MessagingStreamConnection: require './scripts/messaging-stream.ls' + + riot.mixin \is-promise do + is-promise: require './scripts/is-promise.ls' + + riot.mixin \get-post-summary do + get-post-summary: require './scripts/get-post-summary.ls' + + riot.mixin \date-stringify do + date-stringify: require './scripts/date-stringify.ls' + + riot.mixin \text do + analyze: require 'misskey-text' + compile: require './scripts/text-compiler.js' + + riot.mixin \get-password-strength do + get-password-strength: require 'strength.js' + + riot.mixin \ui-progress do + Progress: require './scripts/loading.ls' + + riot.mixin \bytes-to-size do + bytes-to-size: require './scripts/bytes-to-size.js' diff --git a/src/web/app/common/pages/about/base.pug b/src/web/app/common/pages/about/base.pug new file mode 100644 index 0000000000..0bac19ee2b --- /dev/null +++ b/src/web/app/common/pages/about/base.pug @@ -0,0 +1,13 @@ +extends ../../../base + +block head + link(rel='stylesheet', href='/_/resources/common/pages/about/style.css') + script(src='/_/resources/common/pages/about/script.js', async, defer) + +block body + article + header + h1 + block header + div.body + block content diff --git a/src/web/app/common/pages/about/pages/staff.pug b/src/web/app/common/pages/about/pages/staff.pug new file mode 100644 index 0000000000..dfdf015a3d --- /dev/null +++ b/src/web/app/common/pages/about/pages/staff.pug @@ -0,0 +1,13 @@ +extends ../base + +block title + | スタッフ | Misskey + +block header + | スタッフ + +block content + div.members + div.member + p しゅいろ + p 統括、設計、グラフィックデザイン、プログラム \ No newline at end of file diff --git a/src/web/app/common/scripts/api.ls b/src/web/app/common/scripts/api.ls new file mode 100644 index 0000000000..0656a56168 --- /dev/null +++ b/src/web/app/common/scripts/api.ls @@ -0,0 +1,67 @@ +riot = require \riot + +spinner = null +pending = 0 + +net = riot.observable! + +riot.mixin \net do + net: net + +log = (riot.mixin \log).log + +module.exports = (i, endpoint, data) -> + pending++ + + if i? and typeof i == \object then i = i.token + + body = [] + + # append user token when signed in + if i? then body.push "i=#i" + + for k, v of data + if v != undefined + v = encodeURIComponent v + body.push "#k=#v" + + opts = + method: \POST + headers: + 'Content-Type': 'application/x-www-form-urlencoded; charset=utf-8' + body: body.join \& + + if endpoint == \signin + opts.credentials = \include + + ep = if (endpoint.index-of '://') > -1 + then endpoint + else "#{CONFIG.api.url}/#{endpoint}" + + if pending == 1 + spinner := document.create-element \div + ..set-attribute \id \wait + document.body.append-child spinner + + new Promise (resolve, reject) -> + timer = set-timeout -> + net.trigger \detected-slow-network + , 5000ms + + log "API: #{ep}" + + fetch ep, opts + .then (res) -> + pending-- + clear-timeout timer + if pending == 0 + spinner.parent-node.remove-child spinner + + if res.status == 200 + res.json!.then resolve + else if res.status == 204 + resolve! + else + res.json!.then (err) -> + reject err.error + .catch reject diff --git a/src/web/app/common/scripts/bytes-to-size.js b/src/web/app/common/scripts/bytes-to-size.js new file mode 100644 index 0000000000..717f9ad507 --- /dev/null +++ b/src/web/app/common/scripts/bytes-to-size.js @@ -0,0 +1,6 @@ +module.exports = function(bytes) { + var sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB']; + if (bytes == 0) return '0Byte'; + var i = parseInt(Math.floor(Math.log(bytes) / Math.log(1024))); + return Math.round(bytes / Math.pow(1024, i), 2) + sizes[i]; +} diff --git a/src/web/app/common/scripts/check-for-update.ls b/src/web/app/common/scripts/check-for-update.ls new file mode 100644 index 0000000000..48e250a4c7 --- /dev/null +++ b/src/web/app/common/scripts/check-for-update.ls @@ -0,0 +1,9 @@ +module.exports = -> + fetch \/api:meta + .then (res) ~> + meta <~ res.json!.then + if meta.commit.hash != VERSION + if window.confirm '新しいMisskeyのバージョンがあります。更新しますか?\r\n(このメッセージが繰り返し表示される場合は、サーバーにデータがまだ届いていない可能性があるので、少し時間を置いてから再度お試しください)' + location.reload true + .catch ~> + # ignore diff --git a/src/web/app/common/scripts/date-stringify.ls b/src/web/app/common/scripts/date-stringify.ls new file mode 100644 index 0000000000..9aa8b3e6c5 --- /dev/null +++ b/src/web/app/common/scripts/date-stringify.ls @@ -0,0 +1,14 @@ +module.exports = (date) -> + if typeof date == \string then date = new Date date + + text = + date.get-full-year! + \年 + + date.get-month! + \月 + + date.get-date! + \日 + + ' ' + + date.get-hours! + \時 + + date.get-minutes! + \分 + + ' ' + + "(#{[\日 \月 \火 \水 \木 \金 \土][date.get-day!]})" + + return text diff --git a/src/web/app/common/scripts/generate-default-userdata.ls b/src/web/app/common/scripts/generate-default-userdata.ls new file mode 100644 index 0000000000..de03e96151 --- /dev/null +++ b/src/web/app/common/scripts/generate-default-userdata.ls @@ -0,0 +1,27 @@ +uuid = require './uuid.js' + +home = + left: [ \profile \calendar \rss-reader \photo-stream ] + right: [ \broadcast \notifications \user-recommendation \donation \nav \tips ] + +module.exports = ~> + home-data = [] + + home.left.for-each (widget) ~> + home-data.push do + name: widget + id: uuid! + place: \left + + home.right.for-each (widget) ~> + home-data.push do + name: widget + id: uuid! + place: \right + + data = + cache: true + debug: false + home: home-data + + return data diff --git a/src/web/app/common/scripts/get-post-summary.ls b/src/web/app/common/scripts/get-post-summary.ls new file mode 100644 index 0000000000..0150d53004 --- /dev/null +++ b/src/web/app/common/scripts/get-post-summary.ls @@ -0,0 +1,26 @@ +get-post-summary = (post) ~> + summary = if post.text? then post.text else '' + + # メディアが添付されているとき + if post.media? + summary += " (#{post.media.length}枚の画像)" + + # 返信のとき + if post.reply_to_id? + if post.reply_to? + reply-summary = get-post-summary post.reply_to + summary += " RE: #{reply-summary}" + else + summary += " RE: ..." + + # Repostのとき + if post.repost_id? + if post.repost? + repost-summary = get-post-summary post.repost + summary += " RP: #{repost-summary}" + else + summary += " RP: ..." + + return summary.trim! + +module.exports = get-post-summary diff --git a/src/web/app/common/scripts/i.ls b/src/web/app/common/scripts/i.ls new file mode 100644 index 0000000000..5f3c016f8a --- /dev/null +++ b/src/web/app/common/scripts/i.ls @@ -0,0 +1,16 @@ +riot = require \riot + +module.exports = (me) -> + riot.mixin \i do + init: -> + @I = me + @SIGNIN = me? + + if @SIGNIN + @on \mount ~> me.on \updated @update + @on \unmount ~> me.off \updated @update + + update-i: (data) -> + if data? + Object.assign me, data + me.trigger \updated diff --git a/src/web/app/common/scripts/is-promise.ls b/src/web/app/common/scripts/is-promise.ls new file mode 100644 index 0000000000..e3c7adff85 --- /dev/null +++ b/src/web/app/common/scripts/is-promise.ls @@ -0,0 +1 @@ +module.exports = (x) -> typeof x.then == \function diff --git a/src/web/app/common/scripts/loading.ls b/src/web/app/common/scripts/loading.ls new file mode 100644 index 0000000000..ed791b21ac --- /dev/null +++ b/src/web/app/common/scripts/loading.ls @@ -0,0 +1,16 @@ +NProgress = require 'NProgress' +NProgress.configure do + trickle-speed: 500ms + show-spinner: false + +root = document.get-elements-by-tag-name \html .0 + +module.exports = + start: ~> + root.class-list.add \progress + NProgress.start! + done: ~> + root.class-list.remove \progress + NProgress.done! + set: (val) ~> + NProgress.set val diff --git a/src/web/app/common/scripts/log.ls b/src/web/app/common/scripts/log.ls new file mode 100644 index 0000000000..6e1e3735d8 --- /dev/null +++ b/src/web/app/common/scripts/log.ls @@ -0,0 +1,18 @@ +riot = require \riot + +logs = [] + +ev = riot.observable! + +function log(msg) + logs.push do + date: new Date! + message: msg + ev.trigger \log + +riot.mixin \log do + logs: logs + log: log + log-event: ev + +module.exports = log diff --git a/src/web/app/common/scripts/messaging-stream.ls b/src/web/app/common/scripts/messaging-stream.ls new file mode 100644 index 0000000000..298285dc93 --- /dev/null +++ b/src/web/app/common/scripts/messaging-stream.ls @@ -0,0 +1,34 @@ +# Stream +#================================ + +ReconnectingWebSocket = require 'reconnecting-websocket' +riot = require 'riot' + +class Connection + (me, otherparty) ~> + @event = riot.observable! + @me = me + host = CONFIG.api.url.replace \http \ws + @socket = new ReconnectingWebSocket "#{host}/messaging?otherparty=#{otherparty}" + + @socket.add-event-listener \open @on-open + @socket.add-event-listener \message @on-message + + on-open: ~> + @socket.send JSON.stringify do + i: @me.token + + on-message: (message) ~> + try + message = JSON.parse message.data + if message.type? + @event.trigger message.type, message.body + catch + # ignore + + close: ~> + @socket.remove-event-listener \open @on-open + @socket.remove-event-listener \message @on-message + @socket.close! + +module.exports = Connection diff --git a/src/web/app/common/scripts/signout.ls b/src/web/app/common/scripts/signout.ls new file mode 100644 index 0000000000..a647922678 --- /dev/null +++ b/src/web/app/common/scripts/signout.ls @@ -0,0 +1,4 @@ +module.exports = -> + local-storage.remove-item \me + document.cookie = "i=; domain=.#{CONFIG.host}; expires=Thu, 01 Jan 1970 00:00:01 GMT;" + location.href = \/ diff --git a/src/web/app/common/scripts/stream.ls b/src/web/app/common/scripts/stream.ls new file mode 100644 index 0000000000..534048248f --- /dev/null +++ b/src/web/app/common/scripts/stream.ls @@ -0,0 +1,42 @@ +# Stream +#================================ + +ReconnectingWebSocket = require \reconnecting-websocket +riot = require \riot + +module.exports = (me) ~> + state = \initializing + state-ev = riot.observable! + event = riot.observable! + + socket = new ReconnectingWebSocket CONFIG.api.url.replace \http \ws + + socket.onopen = ~> + state := \connected + state-ev.trigger \connected + socket.send JSON.stringify do + i: me.token + + socket.onclose = ~> + state := \reconnecting + state-ev.trigger \closed + + socket.onmessage = (message) ~> + try + message = JSON.parse message.data + if message.type? + event.trigger message.type, message.body + catch + # ignore + + get-state = ~> state + + event.on \i_updated (data) ~> + Object.assign me, data + me.trigger \updated + + { + state-ev + get-state + event + } diff --git a/src/web/app/common/scripts/text-compiler.js b/src/web/app/common/scripts/text-compiler.js new file mode 100644 index 0000000000..9915e3335f --- /dev/null +++ b/src/web/app/common/scripts/text-compiler.js @@ -0,0 +1,30 @@ +module.exports = function(tokens, canBreak, escape) { + if (canBreak == null) { + canBreak = true; + } + if (escape == null) { + escape = true; + } + return tokens.map(function(token) { + switch (token.type) { + case 'text': + if (escape) { + return token.content + .replace(/>/g, '>') + .replace(/' : ' '); + } else { + return token.content + .replace(/(\r\n|\n|\r)/g, canBreak ? '
    ' : ' '); + } + case 'bold': + return '' + token.bold + ''; + case 'link': + return ''; + case 'mention': + return '' + token.content + ''; + case 'hashtag': // TODO + return '' + token.content + ''; + } + }).join(''); +} diff --git a/src/web/app/common/scripts/uuid.js b/src/web/app/common/scripts/uuid.js new file mode 100644 index 0000000000..6161190d63 --- /dev/null +++ b/src/web/app/common/scripts/uuid.js @@ -0,0 +1,12 @@ +module.exports = function () { + var uuid = '', i, random; + for (i = 0; i < 32; i++) { + random = Math.random() * 16 | 0; + + if (i == 8 || i == 12 || i == 16 || i == 20) { + uuid += '-' + } + uuid += (i == 12 ? 4 : (i == 16 ? (random & 3 | 8) : random)).toString(16); + } + return uuid; +} diff --git a/src/web/app/common/tags.ls b/src/web/app/common/tags.ls new file mode 100644 index 0000000000..fe71a7bb37 --- /dev/null +++ b/src/web/app/common/tags.ls @@ -0,0 +1,16 @@ +require './tags/core-error.tag' +require './tags/url.tag' +require './tags/url-preview.tag' +require './tags/ripple-string.tag' +require './tags/time.tag' +require './tags/file-type-icon.tag' +require './tags/uploader.tag' +require './tags/ellipsis.tag' +require './tags/raw.tag' +require './tags/number.tag' +require './tags/special-message.tag' +require './tags/signin.tag' +require './tags/signup.tag' +require './tags/forkit.tag' +require './tags/introduction.tag' +require './tags/copyright.tag' diff --git a/src/web/app/common/tags/copyright.tag b/src/web/app/common/tags/copyright.tag new file mode 100644 index 0000000000..74acae4df7 --- /dev/null +++ b/src/web/app/common/tags/copyright.tag @@ -0,0 +1,5 @@ +mk-copyright + span (c) syuilo 2014-2016 + +style. + display block diff --git a/src/web/app/common/tags/core-error.tag b/src/web/app/common/tags/core-error.tag new file mode 100644 index 0000000000..19ef68bea6 --- /dev/null +++ b/src/web/app/common/tags/core-error.tag @@ -0,0 +1,63 @@ +mk-core-error + //i: i.fa.fa-times-circle + img(src='/_/resources/error.jpg', alt='') + h1: mk-ripple-string サーバーに接続できません + p.text + | インターネット回線に問題があるか、サーバーがダウンまたはメンテナンスしている可能性があります。しばらくしてから + a(onclick={ retry }) 再度お試し + | ください。 + p.thanks いつもMisskeyをご利用いただきありがとうございます。 + +style. + position fixed + z-index 16385 + top 0 + left 0 + width 100% + height 100% + text-align center + background #f8f8f8 + + > i + display block + margin-top 64px + font-size 5em + color #6998a0 + + > img + display block + height 200px + margin 64px auto 0 auto + pointer-events none + -ms-user-select none + -moz-user-select none + -webkit-user-select none + user-select none + + > h1 + display block + margin 32px auto 16px auto + font-size 1.5em + color #555 + + > .text + display block + margin 0 auto + max-width 600px + font-size 1em + color #666 + + > .thanks + display block + margin 32px auto 0 auto + padding 32px 0 32px 0 + max-width 600px + font-size 0.9em + font-style oblique + color #aaa + border-top solid 1px #eee + +script. + @retry = ~> + @unmount! + @opts.retry! diff --git a/src/web/app/common/tags/ellipsis.tag b/src/web/app/common/tags/ellipsis.tag new file mode 100644 index 0000000000..47eca62acd --- /dev/null +++ b/src/web/app/common/tags/ellipsis.tag @@ -0,0 +1,25 @@ +mk-ellipsis + span . + span . + span . + +style. + display inline + + > span + animation ellipsis 1.4s infinite ease-in-out both + + &:nth-child(1) + animation-delay 0s + + &:nth-child(2) + animation-delay 0.16s + + &:nth-child(3) + animation-delay 0.32s + + @keyframes ellipsis + 0%, 80%, 100% + opacity 1 + 40% + opacity 0 diff --git a/src/web/app/common/tags/file-type-icon.tag b/src/web/app/common/tags/file-type-icon.tag new file mode 100644 index 0000000000..68b8f95ad7 --- /dev/null +++ b/src/web/app/common/tags/file-type-icon.tag @@ -0,0 +1,9 @@ +mk-file-type-icon + i.fa.fa-file-image-o(if={ kind == 'image' }) + +style. + display inline + +script. + @file = @opts.file + @kind = @file.type.split \/ .0 diff --git a/src/web/app/common/tags/forkit.tag b/src/web/app/common/tags/forkit.tag new file mode 100644 index 0000000000..7205fbe76b --- /dev/null +++ b/src/web/app/common/tags/forkit.tag @@ -0,0 +1,37 @@ +mk-forkit + a(href='https://github.com/syuilo/misskey', target='_blank', title='View source on Github', aria-label='View source on Github') + svg(width='80', height='80', viewBox='0 0 250 250', aria-hidden) + path(d='M0,0 L115,115 L130,115 L142,142 L250,250 L250,0 Z') + path.octo-arm(d='M128.3,109.0 C113.8,99.7 119.0,89.6 119.0,89.6 C122.0,82.7 120.5,78.6 120.5,78.6 C119.2,72.0 123.4,76.3 123.4,76.3 C127.3,80.9 125.5,87.3 125.5,87.3 C122.9,97.6 130.6,101.9 134.4,103.2', fill='currentColor') + path(d='M115.0,115.0 C114.9,115.1 118.7,116.5 119.8,115.4 L133.7,101.6 C136.9,99.2 139.9,98.4 142.2,98.6 C133.8,88.0 127.5,74.4 143.8,58.0 C148.5,53.4 154.0,51.2 159.7,51.0 C160.3,49.4 163.2,43.6 171.4,40.1 C171.4,40.1 176.1,42.5 178.8,56.2 C183.1,58.6 187.2,61.8 190.9,65.4 C194.5,69.0 197.7,73.2 200.1,77.6 C213.8,80.2 216.3,84.9 216.3,84.9 C212.7,93.1 206.9,96.0 205.4,96.6 C205.1,102.4 203.0,107.8 198.3,112.5 C181.9,128.9 168.3,122.5 157.7,114.1 C157.9,116.9 156.7,120.9 152.7,124.9 L141.0,136.5 C139.8,137.7 141.6,141.9 141.8,141.8 Z', fill='currentColor') + +style. + display block + position absolute + top 0 + right 0 + + > a + display block + + > svg + display block + //fill #151513 + //color #fff + fill $theme-color + color $theme-color-foreground + + .octo-arm + transform-origin 130px 106px + + &:hover + .octo-arm + animation octocat-wave 560ms ease-in-out + + @keyframes octocat-wave + 0%, 100% + transform rotate(0) + 20%, 60% + transform rotate(-25deg) + 40%, 80% + transform rotate(10deg) diff --git a/src/web/app/common/tags/introduction.tag b/src/web/app/common/tags/introduction.tag new file mode 100644 index 0000000000..962f195cca --- /dev/null +++ b/src/web/app/common/tags/introduction.tag @@ -0,0 +1,22 @@ +mk-introduction + article + h1 Misskeyとは? +

    Misskeyみすきーは、syuiloが2014年くらいからオープンソースで開発・運営を行っている、ミニブログベースのSNSです。

    +

    Twitter, Facebook, LINE, Google+ などをパクって参考にしています。

    +

    無料で誰でも利用でき、広告なども一切ありません。

    +

    もっと知りたい方はこちら

    + +style. + display block + + h1 + margin 0 + text-align center + font-size 1.2em + + p + margin 16px 0 + + &:last-child + margin 0 + text-align center diff --git a/src/web/app/common/tags/number.tag b/src/web/app/common/tags/number.tag new file mode 100644 index 0000000000..589c747b35 --- /dev/null +++ b/src/web/app/common/tags/number.tag @@ -0,0 +1,15 @@ +mk-number + +style. + display inline + +script. + @on \mount ~> + # バグ? https://github.com/riot/riot/issues/2103 + #value = @opts.value + value = @opts.riot-value + max = @opts.max + + if max? then if value > max then value = max + + @root.innerHTML = value.to-locale-string! diff --git a/src/web/app/common/tags/raw.tag b/src/web/app/common/tags/raw.tag new file mode 100644 index 0000000000..131826e597 --- /dev/null +++ b/src/web/app/common/tags/raw.tag @@ -0,0 +1,7 @@ +mk-raw + +style. + display inline + +script. + @root.innerHTML = @opts.content diff --git a/src/web/app/common/tags/ripple-string.tag b/src/web/app/common/tags/ripple-string.tag new file mode 100644 index 0000000000..3be6903369 --- /dev/null +++ b/src/web/app/common/tags/ripple-string.tag @@ -0,0 +1,24 @@ +mk-ripple-string + + +style. + display inline + + > span + animation ripple-string 5s infinite ease-in-out both + + @keyframes ripple-string + 0%, 50%, 100% + opacity 1 + 25% + opacity 0.5 + +script. + @on \mount ~> + text = @root.innerHTML + @root.innerHTML = '' + (text.split '').for-each (c, i) ~> + ce = document.create-element \span + ce.innerHTML = c + ce.style.animation-delay = (i / 10) + 's' + @root.append-child ce diff --git a/src/web/app/common/tags/signin.tag b/src/web/app/common/tags/signin.tag new file mode 100644 index 0000000000..6f4013b1cb --- /dev/null +++ b/src/web/app/common/tags/signin.tag @@ -0,0 +1,136 @@ +mk-signin + form(onsubmit={ onsubmit }, class={ signing: signing }) + label.user-name + input@username( + type='text' + pattern='^[a-zA-Z0-9\-]+$' + placeholder='ユーザー名' + autofocus + required + oninput={ oninput }) + i.fa.fa-at + label.password + input@password( + type='password' + placeholder='パスワード' + required) + i.fa.fa-lock + button(type='submit', disabled={ signing }) { signing ? 'やっています...' : 'サインイン' } + +style. + display block + + > form + display block + z-index 2 + + &.signing + &, * + cursor wait !important + + label + display block + margin 12px 0 + + i + display block + pointer-events none + position absolute + bottom 0 + top 0 + left 0 + z-index 1 + margin auto + padding 0 16px + height 1em + color #898786 + + input[type=text] + input[type=password] + user-select text + display inline-block + cursor auto + padding 0 0 0 38px + margin 0 + width 100% + line-height 44px + font-size 1em + color rgba(0, 0, 0, 0.7) + background #fff + outline none + border solid 1px #eee + border-radius 4px + + &:hover + background rgba(255, 255, 255, 0.7) + border-color #ddd + + & + i + color #797776 + + &:focus + background #fff + border-color #ccc + + & + i + color #797776 + + [type=submit] + cursor pointer + padding 16px + margin -6px 0 0 0 + width 100% + font-size 1.2em + color rgba(0, 0, 0, 0.5) + outline none + border none + border-radius 0 + background transparent + transition all .5s ease + + &:hover + color $theme-color + transition all .2s ease + + &:focus + color $theme-color + transition all .2s ease + + &:active + color darken($theme-color, 30%) + transition all .2s ease + + &:disabled + opacity 0.7 + +script. + @mixin \api + + @user = null + @signing = false + + @oninput = ~> + @api \users/show do + username: @refs.username.value + .then (user) ~> + @user = user + @trigger \user user + @update! + + @onsubmit = (e) ~> + e.prevent-default! + + @signing = true + @update! + + @api \signin do + username: @refs.username.value + password: @refs.password.value + .then ~> + location.reload! + .catch ~> + alert 'something happened' + @signing = false + @update! + + false diff --git a/src/web/app/common/tags/signup.tag b/src/web/app/common/tags/signup.tag new file mode 100644 index 0000000000..730f00fb4e --- /dev/null +++ b/src/web/app/common/tags/signup.tag @@ -0,0 +1,352 @@ +mk-signup + form(onsubmit={ onsubmit }, autocomplete='off') + label.username + p.caption + i.fa.fa-at + | ユーザー名 + input@username( + type='text' + pattern='^[a-zA-Z0-9\-]{3,20}$' + placeholder='a~z、A~Z、0~9、-' + autocomplete='off' + required + onkeyup={ on-change-username }) + + p.profile-page-url-preview(if={ refs.username.value != '' && username-state != 'invalid-format' && username-state != 'min-range' && username-state != 'max-range' }) { CONFIG.url + '/' + refs.username.value } + + p.info(if={ username-state == 'wait' }, style='color:#999') + i.fa.fa-fw.fa-spinner.fa-pulse + | 確認しています... + p.info(if={ username-state == 'ok' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | 利用できます + p.info(if={ username-state == 'unavailable' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 既に利用されています + p.info(if={ username-state == 'error' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 通信エラー + p.info(if={ username-state == 'invalid-format' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | a~z、A~Z、0~9、-(ハイフン)が使えます + p.info(if={ username-state == 'min-range' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 3文字以上でお願いします! + p.info(if={ username-state == 'max-range' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 20文字以内でお願いします + + label.password + p.caption + i.fa.fa-lock + | パスワード + input@password( + type='password' + placeholder='8文字以上を推奨します' + autocomplete='off' + required + onkeyup={ on-change-password }) + + div.meter(if={ password-strength != '' }, data-strength={ password-strength }) + div.value@password-metar + + p.info(if={ password-strength == 'low' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 弱いパスワード + p.info(if={ password-strength == 'medium' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | まあまあのパスワード + p.info(if={ password-strength == 'high' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | 強いパスワード + + label.retype-password + p.caption + i.fa.fa-lock + | パスワード(再入力) + input@password-retype( + type='password' + placeholder='確認のため再入力してください' + autocomplete='off' + required + onkeyup={ on-change-password-retype }) + + p.info(if={ password-retype-state == 'match' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | 確認されました + p.info(if={ password-retype-state == 'not-match' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 一致していません + + label.recaptcha + p.caption + i.fa.fa-toggle-on(if={ recaptchaed }) + i.fa.fa-toggle-off(if={ !recaptchaed }) + | 認証 + div.g-recaptcha( + data-callback='onRecaptchaed' + data-expired-callback='onRecaptchaExpired' + data-sitekey={ CONFIG.recaptcha.site-key }) + + label.agree-tou + input( + name='agree-tou', + type='checkbox', + autocomplete='off', + required) + p + a() 利用規約 + | に同意する + + button(onclick={ onsubmit }) + | アカウント作成 + +style. + display block + min-width 302px + overflow hidden + + > form + + label + display block + margin 16px 0 + + > .caption + margin 0 0 4px 0 + color #828888 + font-size 0.95em + + > i + margin-right 0.25em + color #96adac + + > .info + display block + margin 4px 0 + font-size 0.8em + + > i + margin-right 0.3em + + &.username + .profile-page-url-preview + display block + margin 4px 8px 0 4px + font-size 0.8em + color #888 + + &:empty + display none + + &:not(:empty) + .info + margin-top 0 + + &.password + .meter + display block + margin-top 8px + width 100% + height 8px + + &[data-strength=''] + display none + + &[data-strength='low'] + > .value + background #d73612 + + &[data-strength='medium'] + > .value + background #d7ca12 + + &[data-strength='high'] + > .value + background #61bb22 + + > .value + display block + width 0% + height 100% + background transparent + border-radius 4px + transition all 0.1s ease + + [type=text], [type=password] + user-select text + display inline-block + cursor auto + padding 0 12px + margin 0 + width 100% + line-height 44px + font-size 1em + color #333 !important + background #fff !important + outline none + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + box-shadow 0 0 0 114514px #fff inset + transition all .3s ease + + &:hover + border-color rgba(0, 0, 0, 0.2) + transition all .1s ease + + &:focus + color $theme-color !important + border-color $theme-color + box-shadow 0 0 0 1024px #fff inset, 0 0 0 4px rgba($theme-color, 10%) + transition all 0s ease + + &:disabled + opacity 0.5 + + .agree-tou + padding 4px + border-radius 4px + + &:hover + background #f4f4f4 + + &:active + background #eee + + &, * + cursor pointer + + p + display inline + color #555 + + button + margin 0 0 32px 0 + padding 16px + width 100% + font-size 1em + color #fff + background $theme-color + border-radius 3px + + &:hover + background lighten($theme-color, 5%) + + &:active + background darken($theme-color, 5%) + +script. + @mixin \api + @mixin \get-password-strength + + @username-state = null + @password-strength = '' + @password-retype-state = null + @recaptchaed = false + + window.on-recaptchaed = ~> + @recaptchaed = true + @update! + + window.on-recaptcha-expired = ~> + @recaptchaed = false + @update! + + @on \mount ~> + head = (document.get-elements-by-tag-name \head).0 + script = document.create-element \script + ..set-attribute \src \https://www.google.com/recaptcha/api.js + head.append-child script + + @on-change-username = ~> + username = @refs.username.value + + if username == '' + @username-state = null + @update! + return + + err = switch + | not username.match /^[a-zA-Z0-9\-]+$/ => \invalid-format + | username.length < 3chars => \min-range + | username.length > 20chars => \max-range + | _ => null + + if err? + @username-state = err + @update! + else + @username-state = \wait + @update! + + @api \username/available do + username: username + .then (result) ~> + if result.available + @username-state = \ok + else + @username-state = \unavailable + @update! + .catch (err) ~> + @username-state = \error + @update! + + @on-change-password = ~> + password = @refs.password.value + + if password == '' + @password-strength = '' + return + + strength = @get-password-strength password + + if strength > 0.3 + @password-strength = \medium + if strength > 0.7 + @password-strength = \high + else + @password-strength = \low + + @update! + + @refs.password-metar.style.width = (strength * 100) + \% + + @on-change-password-retype = ~> + password = @refs.password.value + retyped-password = @refs.password-retype.value + + if retyped-password == '' + @password-retype-state = null + return + + if password == retyped-password + @password-retype-state = \match + else + @password-retype-state = \not-match + + @onsubmit = (e) ~> + e.prevent-default! + + username = @refs.username.value + password = @refs.password.value + + locker = document.body.append-child document.create-element \mk-locker + + @api \signup do + username: username + password: password + 'g-recaptcha-response': grecaptcha.get-response! + .then ~> + @api \signin do + username: username + password: password + .then ~> + location.href = CONFIG.url + .catch ~> + alert '何らかの原因によりアカウントの作成に失敗しました。再度お試しください。' + + grecaptcha.reset! + @recaptchaed = false + + locker.parent-node.remove-child locker + + false diff --git a/src/web/app/common/tags/special-message.tag b/src/web/app/common/tags/special-message.tag new file mode 100644 index 0000000000..5a6d5787ea --- /dev/null +++ b/src/web/app/common/tags/special-message.tag @@ -0,0 +1,24 @@ +mk-special-message + p(if={ m == 1 && d == 1 }) Happy New Year! + p(if={ m == 12 && d == 25 }) Merry Christmas! + +style. + display block + + &:empty + display none + + > p + margin 0 + padding 4px + text-align center + font-size 14px + font-weight bold + text-transform uppercase + color #fff + background #ff1036 + +script. + now = new Date! + @d = now.get-date! + @m = now.get-month! + 1 diff --git a/src/web/app/common/tags/time.tag b/src/web/app/common/tags/time.tag new file mode 100644 index 0000000000..56c3b8ecc3 --- /dev/null +++ b/src/web/app/common/tags/time.tag @@ -0,0 +1,43 @@ +mk-time + time(datetime={ opts.time }) + span(if={ mode == 'relative' }) { relative } + span(if={ mode == 'absolute' }) { absolute } + span(if={ mode == 'detail' }) { absolute } ({ relative }) + +script. + @time = new Date @opts.time + @mode = @opts.mode || \relative + @tickid = null + + @absolute = + @time.get-full-year! + \年 + + @time.get-month! + \月 + + @time.get-date! + \日 + + ' ' + + @time.get-hours! + \時 + + @time.get-minutes! + \分 + + @on \mount ~> + if @mode == \relative or @mode == \detail + @tick! + @tickid = set-interval @tick, 1000ms + + @on \unmount ~> + if @mode == \relative or @mode == \detail + clear-interval @tickid + + @tick = ~> + now = new Date! + ago = (now - @time) / 1000ms + @relative = switch + | ago >= 31536000s => ~~(ago / 31536000s) + '年前' + | ago >= 2592000s => ~~(ago / 2592000s) + 'ヶ月前' + | ago >= 604800s => ~~(ago / 604800s) + '週間前' + | ago >= 86400s => ~~(ago / 86400s) + '日前' + | ago >= 3600s => ~~(ago / 3600s) + '時間前' + | ago >= 60s => ~~(ago / 60s) + '分前' + | ago >= 10s => ~~(ago % 60s) + '秒前' + | ago >= 0s => 'たった今' + | ago < 0s => '未来' + | _ => 'なぞのじかん' + @update! diff --git a/src/web/app/common/tags/uploader.tag b/src/web/app/common/tags/uploader.tag new file mode 100644 index 0000000000..6d4e9b6363 --- /dev/null +++ b/src/web/app/common/tags/uploader.tag @@ -0,0 +1,201 @@ +mk-uploader + ol(if={ uploads.length > 0 }) + li(each={ uploads }) + div.img(style='background-image: url({ img })') + p.name + i.fa.fa-spinner.fa-pulse + | { name } + p.status + span.initing(if={ progress == undefined }) + | 待機中 + mk-ellipsis + span.kb(if={ progress != undefined }) + | { String(Math.floor(progress.value / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') } + i KB + = ' / ' + | { String(Math.floor(progress.max / 1024)).replace(/(\d)(?=(\d\d\d)+(?!\d))/g, '$1,') } + i KB + span.percentage(if={ progress != undefined }) { Math.floor((progress.value / progress.max) * 100) } + progress(if={ progress != undefined && progress.value != progress.max }, value={ progress.value }, max={ progress.max }) + div.progress.initing(if={ progress == undefined }) + div.progress.waiting(if={ progress != undefined && progress.value == progress.max }) + +style. + display block + overflow auto + + &:empty + display none + + > ol + display block + margin 0 + padding 0 + list-style none + + > li + display block + margin 8px 0 0 0 + padding 0 + height 36px + box-shadow 0 -1px 0 rgba($theme-color, 0.1) + border-top solid 8px transparent + + &:first-child + margin 0 + box-shadow none + border-top none + + > .img + display block + position absolute + top 0 + left 0 + width 36px + height 36px + background-size cover + background-position center center + + > .name + display block + position absolute + top 0 + left 44px + margin 0 + padding 0 + max-width 256px + font-size 0.8em + color rgba($theme-color, 0.7) + white-space nowrap + text-overflow ellipsis + overflow hidden + + > i + margin-right 4px + + > .status + display block + position absolute + top 0 + right 0 + margin 0 + padding 0 + font-size 0.8em + + > .initing + color rgba($theme-color, 0.5) + + > .kb + color rgba($theme-color, 0.5) + + > .percentage + display inline-block + width 48px + text-align right + + color rgba($theme-color, 0.7) + + &:after + content '%' + + > progress + display block + position absolute + bottom 0 + right 0 + margin 0 + width calc(100% - 44px) + height 8px + background transparent + border none + border-radius 4px + overflow hidden + + &::-webkit-progress-value + background $theme-color + + &::-webkit-progress-bar + background rgba($theme-color, 0.1) + + > .progress + display block + position absolute + bottom 0 + right 0 + margin 0 + width calc(100% - 44px) + height 8px + border none + border-radius 4px + background linear-gradient( + 45deg, + lighten($theme-color, 30%) 25%, + $theme-color 25%, + $theme-color 50%, + lighten($theme-color, 30%) 50%, + lighten($theme-color, 30%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation bg 1.5s linear infinite + + &.initing + opacity 0.3 + + @keyframes bg + from {background-position: 0 0;} + to {background-position: -64px 32px;} + +script. + @mixin \i + + @uploads = [] + + + @upload = (file, folder) ~> + id = Math.random! + + ctx = + id: id + name: file.name || \untitled + progress: undefined + + @uploads.push ctx + @trigger \change-uploads @uploads + @update! + + reader = new FileReader! + reader.onload = (e) ~> + ctx.img = e.target.result + @update! + reader.read-as-data-URL file + + data = new FormData! + data.append \i @I.token + data.append \file file + + if folder? + data.append \folder_id folder + + xhr = new XMLHttpRequest! + xhr.open \POST CONFIG.api.url + '/drive/files/create' true + xhr.onload = (e) ~> + drive-file = JSON.parse e.target.response + + @trigger \uploaded drive-file + + @uploads = @uploads.filter (x) -> x.id != id + @trigger \change-uploads @uploads + + @update! + + xhr.upload.onprogress = (e) ~> + if e.length-computable + if ctx.progress == undefined + ctx.progress = {} + ctx.progress.max = e.total + ctx.progress.value = e.loaded + @update! + + xhr.send data diff --git a/src/web/app/common/tags/url-preview.tag b/src/web/app/common/tags/url-preview.tag new file mode 100644 index 0000000000..605d26bc67 --- /dev/null +++ b/src/web/app/common/tags/url-preview.tag @@ -0,0 +1,105 @@ +mk-url-preview + a(href={ url }, target='_blank', title={ url }, if={ !loading }) + div.thumbnail(if={ thumbnail }, style={ 'background-image: url(' + thumbnail + ')' }) + article + header: h1 { title } + p { description } + footer + img.icon(if={ icon }, src={ icon }) + p { sitename } + +style. + display block + font-size 16px + + > a + display block + border solid 1px #eee + border-radius 4px + overflow hidden + + &:hover + text-decoration none + border-color #ddd + + > article > header > h1 + text-decoration underline + + > .thumbnail + position absolute + width 100px + height 100% + background-position center + background-size cover + + & + article + left 100px + width calc(100% - 100px) + + > article + padding 16px + + > header + margin-bottom 8px + + > h1 + margin 0 + font-size 1em + color #555 + + > p + margin 0 + color #777 + font-size 0.8em + + > footer + margin-top 8px + + > img + display inline-block + width 16px + heigth 16px + margin-right 4px + vertical-align bottom + + > p + display inline-block + margin 0 + color #666 + font-size 0.8em + line-height 16px + + @media (max-width 500px) + font-size 8px + + > a + border none + + > .thumbnail + width 70px + + & + article + left 70px + width calc(100% - 70px) + + > article + padding 8px + +script. + @mixin \api + + @url = @opts.url + @loading = true + + @on \mount ~> + fetch CONFIG.url + '/api:url?url=' + @url + .then (res) ~> + info <~ res.json!.then + @title = info.title + @description = info.description + @thumbnail = info.thumbnail + @icon = info.icon + @sitename = info.sitename + + @loading = false + @update! diff --git a/src/web/app/common/tags/url.tag b/src/web/app/common/tags/url.tag new file mode 100644 index 0000000000..18892e8108 --- /dev/null +++ b/src/web/app/common/tags/url.tag @@ -0,0 +1,50 @@ +mk-url + a(href={ url }, target={ opts.target }) + span.schema { schema }// + span.hostname { hostname } + span.port(if={ port != '' }) :{ port } + span.pathname(if={ pathname != '' }) { pathname } + span.query { query } + span.hash { hash } + +style. + > a + &:after + content "\f14c" + display inline-block + padding-left 2px + font-family FontAwesome + font-size .9em + font-weight 400 + font-style normal + + > .schema + opacity 0.5 + + > .hostname + font-weight bold + + > .pathname + opacity 0.8 + + > .query + opacity 0.5 + + > .hash + font-style italic + +script. + @url = @opts.href + + @on \before-mount ~> + parser = document.create-element \a + parser.href = @url + + @schema = parser.protocol + @hostname = parser.hostname + @port = parser.port + @pathname = parser.pathname + @query = parser.search + @hash = parser.hash + + @update! diff --git a/src/web/app/desktop/mixins.ls b/src/web/app/desktop/mixins.ls new file mode 100644 index 0000000000..debd89fbd4 --- /dev/null +++ b/src/web/app/desktop/mixins.ls @@ -0,0 +1,47 @@ +riot = require \riot + +module.exports = (me) ~> + riot.mixin \sortable do + Sortable: require \Sortable + + if me? + (require './scripts/stream.ls') me + + require './scripts/user-preview.ls' + + require './scripts/open-window.ls' + + riot.mixin \notify do + notify: require './scripts/notify.ls' + + dialog = require './scripts/dialog.ls' + + riot.mixin \dialog do + dialog: dialog + + riot.mixin \NotImplementedException do + NotImplementedException: ~> + dialog do + 'Not implemented yet' + '要求された操作は実装されていません。
    Misskeyの開発に参加する' + [ + text: \OK + ] + + riot.mixin \input-dialog do + input-dialog: require './scripts/input-dialog.ls' + + riot.mixin \update-avatar do + update-avatar: require './scripts/update-avatar.ls' + + riot.mixin \update-banner do + update-banner: require './scripts/update-banner.ls' + + riot.mixin \update-wallpaper do + update-wallpaper: require './scripts/update-wallpaper.ls' + + riot.mixin \autocomplete do + Autocomplete: require './scripts/autocomplete.ls' + + riot.mixin \follow-scroll do + Follower: require './scripts/follow-scroll.ls' diff --git a/src/web/app/desktop/resources/header-logo.svg b/src/web/app/desktop/resources/header-logo.svg new file mode 100644 index 0000000000000000000000000000000000000000..19b8a2737e4d2fccbf3b6a6be1294a46ab2465c4 GIT binary patch literal 646 zcmZuuT~C8R5PY_Y|6yHUTkejQ3XRyr))*6`CVs_73MZVU9D%d6|K2@{jWxZOJ7#Bw z+1<YKYGsz4@H znwbzi;o8r&lFhZYGH|^ySQb#pjwo8>aPxVH^+PQ{_1s=|UpZrqWB&ol~Q9et|(3csSZ4)+mn35ZfRJ@RwG! z)I)rwjI44+mu(xNB#xmO(^ULm?0Ee#_Z<&>PLcW}K?q#%(H?KB9ZsXz<1MqphpyG` Vd|cORcRs=;=MG0^H*Qx*_YZrqxSaq1 literal 0 HcmV?d00001 diff --git a/src/web/app/desktop/resources/remove.png b/src/web/app/desktop/resources/remove.png new file mode 100644 index 0000000000000000000000000000000000000000..8b1f4c06c9f480a5ce6933e00a7575bb3c1ef109 GIT binary patch literal 3115 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 FV1hfn>)-$Y literal 0 HcmV?d00001 diff --git a/src/web/app/desktop/router.ls b/src/web/app/desktop/router.ls new file mode 100644 index 0000000000..02a7e11816 --- /dev/null +++ b/src/web/app/desktop/router.ls @@ -0,0 +1,77 @@ +# Router +#================================ + +riot = require \riot +route = require \page +page = null + +module.exports = (me) ~> + + # Routing + #-------------------------------- + + route \/ index + route \/i>mentions mentions + route \/post::post post + route \/search::query search + route \/:user user.bind null \home + route \/:user/graphs user.bind null \graphs + route \/:user/:post post + route \* not-found + + # Handlers + #-------------------------------- + + function index + if me? then home! else entrance! + + function home + mount document.create-element \mk-home-page + + function entrance + mount document.create-element \mk-entrance + document.document-element.set-attribute \data-page \entrance + + function mentions + document.create-element \mk-home-page + ..set-attribute \mode \mentions + .. |> mount + + function search ctx + document.create-element \mk-search-page + ..set-attribute \query ctx.params.query + .. |> mount + + function user page, ctx + document.create-element \mk-user-page + ..set-attribute \user ctx.params.user + ..set-attribute \page page + .. |> mount + + function post ctx + document.create-element \mk-post-page + ..set-attribute \post ctx.params.post + .. |> mount + + function not-found + mount document.create-element \mk-not-found + + # Register mixin + #-------------------------------- + + riot.mixin \page do + page: route + + # Exec + #-------------------------------- + + route! + +# Mount +#================================ + +function mount content + document.document-element.remove-attribute \data-page + if page? then page.unmount! + body = document.get-element-by-id \app + page := riot.mount body.append-child content .0 diff --git a/src/web/app/desktop/script.js b/src/web/app/desktop/script.js new file mode 100644 index 0000000000..473797334f --- /dev/null +++ b/src/web/app/desktop/script.js @@ -0,0 +1,42 @@ +/** + * Desktop Client + */ + +require('chart.js'); +require('./tags.ls'); +const riot = require('riot'); +const boot = require('../boot.ls'); +const mixins = require('./mixins.ls'); +const route = require('./router.ls'); +const fuckAdBlock = require('./scripts/fuck-ad-block.ls'); + +/** + * Boot + */ +boot(me => { + /** + * Fuck AD Block + */ + fuckAdBlock(); + + /** + * Init Notification + */ + if ('Notification' in window) { + // 許可を得ていなかったらリクエスト + if (Notification.permission == 'default') { + Notification.requestPermission(); + } + } + + // Register mixins + mixins(me); + + // Debug + if (me != null && me.data.debug) { + riot.mount(document.body.appendChild(document.createElement('mk-log-window'))); + } + + // Start routing + route(me); +}); diff --git a/src/web/app/desktop/scripts/autocomplete.ls b/src/web/app/desktop/scripts/autocomplete.ls new file mode 100644 index 0000000000..636bb7f277 --- /dev/null +++ b/src/web/app/desktop/scripts/autocomplete.ls @@ -0,0 +1,108 @@ +# Autocomplete +#================================ + +get-caret-coordinates = require 'textarea-caret-position' +riot = require 'riot' + +# オートコンプリートを管理するクラスです。 +class Autocomplete + + @textarea = null + @suggestion = null + + # 対象のテキストエリアを与えてインスタンスを初期化します。 + (textarea) ~> + @textarea = textarea + + # このインスタンスにあるテキストエリアの入力のキャプチャを開始します。 + attach: ~> + @textarea.add-event-listener \input @on-input + + # このインスタンスにあるテキストエリアの入力のキャプチャを解除します。 + detach: ~> + @textarea.remove-event-listener \input @on-input + @close! + + # テキスト入力時 + on-input: ~> + @close! + + caret = @textarea.selection-start + text = @textarea.value.substr 0 caret + + mention-index = text.last-index-of \@ + + if mention-index == -1 + return + + username = text.substr mention-index + 1 + + if not username.match /^[a-zA-Z0-9-]+$/ + return + + @open \user username + + # サジェストを提示します。 + open: (type, q) ~> + # 既に開いているサジェストは閉じる + @close! + + # サジェスト要素作成 + suggestion = document.create-element \mk-autocomplete-suggestion + + # ~ サジェストを表示すべき位置を計算 ~ + + caret-position = get-caret-coordinates @textarea, @textarea.selection-start + + rect = @textarea.get-bounding-client-rect! + + x = rect.left + window.page-x-offset + caret-position.left + y = rect.top + window.page-y-offset + caret-position.top + + suggestion.style.left = x + \px + suggestion.style.top = y + \px + + # 要素追加 + el = document.body.append-child suggestion + + # マウント + mounted = riot.mount el, do + textarea: @textarea + complete: @complete + close: @close + type: type + q: q + + @suggestion = mounted.0 + + # サジェストを閉じます。 + close: ~> + if !@suggestion? + return + + @suggestion.unmount! + @suggestion = null + + @textarea.focus! + + # オートコンプリートする + complete: (user) ~> + @close! + value = user.username + + caret = @textarea.selection-start + source = @textarea.value + + before = source.substr 0 caret + trimed-before = before.substring 0 before.last-index-of \@ + after = source.substr caret + + # 結果を挿入する + @textarea.value = trimed-before + \@ + value + ' ' + after + + # キャレットを戻す + @textarea.focus! + pos = caret + value.length + @textarea.set-selection-range pos, pos + +module.exports = Autocomplete diff --git a/src/web/app/desktop/scripts/dialog.ls b/src/web/app/desktop/scripts/dialog.ls new file mode 100644 index 0000000000..f3dd6cea1b --- /dev/null +++ b/src/web/app/desktop/scripts/dialog.ls @@ -0,0 +1,17 @@ +# Dialog +#================================ + +riot = require 'riot' + +module.exports = (title, text, buttons, can-through, on-through) ~> + dialog = document.body.append-child document.create-element \mk-dialog + controller = riot.observable! + riot.mount dialog, do + controller: controller + title: title + text: text + buttons: buttons + can-through: can-through + on-through: on-through + controller.trigger \open + return controller diff --git a/src/web/app/desktop/scripts/follow-scroll.ls b/src/web/app/desktop/scripts/follow-scroll.ls new file mode 100644 index 0000000000..5072e9c583 --- /dev/null +++ b/src/web/app/desktop/scripts/follow-scroll.ls @@ -0,0 +1,56 @@ +class Follower + (el) -> + @follower = el + @last-scroll-top = window.scroll-y + @initial-follower-top = @follower.get-bounding-client-rect!.top + @page-top = 48 + + follow: -> + window-height = window.inner-height + follower-height = @follower.offset-height + + scroll-top = window.scroll-y + scroll-bottom = scroll-top + window-height + + follower-top = @follower.get-bounding-client-rect!.top + scroll-top + follower-bottom = follower-top + follower-height + + height-delta = Math.abs window-height - follower-height + scroll-delta = @last-scroll-top - scroll-top + + is-scrolling-down = (scroll-top > @last-scroll-top) + is-window-larger = (window-height > follower-height) + + console.log @initial-follower-top + + if (is-window-larger && scroll-top > @initial-follower-top) || (!is-window-larger && scroll-top > @initial-follower-top + height-delta) + @follower.class-list.add \fixed + else if !is-scrolling-down && scroll-top + @page-top <= @initial-follower-top + @follower.class-list.remove \fixed + @follower.style.top = 0 + return + + drag-bottom-down = (follower-bottom <= scroll-bottom && is-scrolling-down) + drag-top-up = (follower-top >= scroll-top + @page-top && !is-scrolling-down) + + if drag-bottom-down + console.log \down + @follower.style.top = if is-window-larger then 0 else -height-delta + \px + else if drag-top-up + console.log \up + @follower.style.top = @page-top + \px + else if @follower.class-list.contains \fixed + console.log \- + current-top = parse-int @follower.style.top, 10 + + min-top = -height-delta + scrolled-top = current-top + scroll-delta + + is-page-at-bottom = (scroll-top + window-height >= document.body.offset-height) + new-top = if is-page-at-bottom then min-top else scrolled-top + + @follower.style.top = new-top + \px + + @last-scroll-top = scroll-top + +module.exports = Follower diff --git a/src/web/app/desktop/scripts/fuck-ad-block.ls b/src/web/app/desktop/scripts/fuck-ad-block.ls new file mode 100644 index 0000000000..55431fcd00 --- /dev/null +++ b/src/web/app/desktop/scripts/fuck-ad-block.ls @@ -0,0 +1,19 @@ +# FUCK AD BLOCK +#================================ + +require 'fuck-adblock' +dialog = require './dialog.ls' + +module.exports = ~> + if fuck-ad-block == undefined + ad-block-detected! + else + fuck-ad-block.on-detected ad-block-detected + +function ad-block-detected + dialog do + '広告ブロッカーを無効にしてください' + 'Misskeyは広告を掲載していませんが、広告をブロックする機能が有効だと一部の機能が利用できなかったり、不具合が発生する場合があります。' + [ + text: \OK + ] diff --git a/src/web/app/desktop/scripts/input-dialog.ls b/src/web/app/desktop/scripts/input-dialog.ls new file mode 100644 index 0000000000..f75b12dd01 --- /dev/null +++ b/src/web/app/desktop/scripts/input-dialog.ls @@ -0,0 +1,13 @@ +# Input Dialog +#================================ + +riot = require 'riot' + +module.exports = (title, placeholder, default-value, on-ok, on-cancel) ~> + dialog = document.body.append-child document.create-element \mk-input-dialog + riot.mount dialog, do + title: title + placeholder: placeholder + default: default-value + on-ok: on-ok + on-cancel: on-cancel diff --git a/src/web/app/desktop/scripts/notify.ls b/src/web/app/desktop/scripts/notify.ls new file mode 100644 index 0000000000..919bbc3dcf --- /dev/null +++ b/src/web/app/desktop/scripts/notify.ls @@ -0,0 +1,6 @@ +riot = require \riot + +module.exports = (message) ~> + notification = document.body.append-child document.create-element \mk-ui-notification + riot.mount notification, do + message: message diff --git a/src/web/app/desktop/scripts/open-window.ls b/src/web/app/desktop/scripts/open-window.ls new file mode 100644 index 0000000000..4388272ecf --- /dev/null +++ b/src/web/app/desktop/scripts/open-window.ls @@ -0,0 +1,8 @@ +riot = require \riot + +function open(name, opts) + window = document.body.append-child document.create-element name + riot.mount window, opts + +riot.mixin \open-window do + open-window: open diff --git a/src/web/app/desktop/scripts/stream.ls b/src/web/app/desktop/scripts/stream.ls new file mode 100644 index 0000000000..f84d6097a7 --- /dev/null +++ b/src/web/app/desktop/scripts/stream.ls @@ -0,0 +1,38 @@ +# Stream +#================================ + +stream = require '../../common/scripts/stream.ls' +get-post-summary = require '../../common/scripts/get-post-summary.ls' +riot = require \riot + +module.exports = (me) ~> + s = stream me + + s.event.on \drive_file_created (file) ~> + n = new Notification 'ファイルがアップロードされました' do + body: file.name + icon: file.url + '?thumbnail&size=64' + set-timeout (n.close.bind n), 5000ms + + s.event.on \mention (post) ~> + n = new Notification "#{post.user.name}さんから:" do + body: get-post-summary post + icon: post.user.avatar_url + '?thumbnail&size=64' + set-timeout (n.close.bind n), 6000ms + + s.event.on \reply (post) ~> + n = new Notification "#{post.user.name}さんから返信:" do + body: get-post-summary post + icon: post.user.avatar_url + '?thumbnail&size=64' + set-timeout (n.close.bind n), 6000ms + + s.event.on \quote (post) ~> + n = new Notification "#{post.user.name}さんが引用:" do + body: get-post-summary post + icon: post.user.avatar_url + '?thumbnail&size=64' + set-timeout (n.close.bind n), 6000ms + + riot.mixin \stream do + stream: s.event + get-stream-state: s.get-state + stream-state-ev: s.state-ev diff --git a/src/web/app/desktop/scripts/update-avatar.ls b/src/web/app/desktop/scripts/update-avatar.ls new file mode 100644 index 0000000000..513a59074c --- /dev/null +++ b/src/web/app/desktop/scripts/update-avatar.ls @@ -0,0 +1,81 @@ +# Update Avatar +#================================ + +riot = require 'riot' +dialog = require './dialog.ls' +api = require '../../common/scripts/api.ls' + +module.exports = (I, cb, file = null) ~> + + @file-selected = (file) ~> + cropper = document.body.append-child document.create-element \mk-crop-window + cropper = riot.mount cropper, do + file: file + title: 'アバターとして表示する部分を選択' + aspect-ratio: 1 / 1 + .0 + cropper.on \cropped (blob) ~> + data = new FormData! + data.append \i I.token + data.append \file blob, file.name + '.cropped.png' + api I, \drive/folders/find do + name: 'アイコン' + .then (icon-folder) ~> + if icon-folder.length == 0 + api I, \drive/folders/create do + name: 'アイコン' + .then (icon-folder) ~> + @uplaod data, icon-folder + else + @uplaod data, icon-folder.0 + cropper.on \skiped ~> + @set file + + @uplaod = (data, folder) ~> + + progress = document.body.append-child document.create-element \mk-progress-dialog + progress = riot.mount progress, do + title: '新しいアバターをアップロードしています' + .0 + + if folder? + data.append \folder_id folder.id + + xhr = new XMLHttpRequest! + xhr.open \POST CONFIG.api.url + \/drive/files/create true + xhr.onload = (e) ~> + file = JSON.parse e.target.response + progress.close! + @set file + + xhr.upload.onprogress = (e) ~> + if e.length-computable + progress.update-progress e.loaded, e.total + + xhr.send data + + @set = (file) ~> + api I, \i/update do + avatar_id: file.id + .then (i) ~> + dialog do + 'アバターを更新しました' + '新しいアバターが反映されるまで時間がかかる場合があります。' + [ + text: \わかった + ] + if cb? then cb i + .catch (err) ~> + console.error err + #@opts.ui.trigger \notification 'Error!' + + if file? + @file-selected file + else + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + browser = riot.mount browser, do + multiple: false + title: 'アバターにする画像を選択' + .0 + browser.one \selected (file) ~> + @file-selected file diff --git a/src/web/app/desktop/scripts/update-banner.ls b/src/web/app/desktop/scripts/update-banner.ls new file mode 100644 index 0000000000..5754cdcdb1 --- /dev/null +++ b/src/web/app/desktop/scripts/update-banner.ls @@ -0,0 +1,81 @@ +# Update Banner +#================================ + +riot = require 'riot' +dialog = require './dialog.ls' +api = require '../../common/scripts/api.ls' + +module.exports = (I, cb, file = null) ~> + + @file-selected = (file) ~> + cropper = document.body.append-child document.create-element \mk-crop-window + cropper = riot.mount cropper, do + file: file + title: 'バナーとして表示する部分を選択' + aspect-ratio: 16 / 9 + .0 + cropper.on \cropped (blob) ~> + data = new FormData! + data.append \i I.token + data.append \file blob, file.name + '.cropped.png' + api I, \drive/folders/find do + name: 'バナー' + .then (banner-folder) ~> + if banner-folder.length == 0 + api I, \drive/folders/create do + name: 'バナー' + .then (banner-folder) ~> + @uplaod data, banner-folder + else + @uplaod data, banner-folder.0 + cropper.on \skiped ~> + @set file + + @uplaod = (data, folder) ~> + + progress = document.body.append-child document.create-element \mk-progress-dialog + progress = riot.mount progress, do + title: '新しいバナーをアップロードしています' + .0 + + if folder? + data.append \folder_id folder.id + + xhr = new XMLHttpRequest! + xhr.open \POST CONFIG.api.url + \/drive/files/create true + xhr.onload = (e) ~> + file = JSON.parse e.target.response + progress.close! + @set file + + xhr.upload.onprogress = (e) ~> + if e.length-computable + progress.update-progress e.loaded, e.total + + xhr.send data + + @set = (file) ~> + api I, \i/update do + banner_id: file.id + .then (i) ~> + dialog do + 'バナーを更新しました' + '新しいバナーが反映されるまで時間がかかる場合があります。' + [ + text: \わかりました。 + ] + if cb? then cb i + .catch (err) ~> + console.error err + #@opts.ui.trigger \notification 'Error!' + + if file? + @file-selected file + else + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + browser = riot.mount browser, do + multiple: false + title: 'バナーにする画像を選択' + .0 + browser.one \selected (file) ~> + @file-selected file diff --git a/src/web/app/desktop/scripts/update-wallpaper.ls b/src/web/app/desktop/scripts/update-wallpaper.ls new file mode 100644 index 0000000000..49632400c0 --- /dev/null +++ b/src/web/app/desktop/scripts/update-wallpaper.ls @@ -0,0 +1,35 @@ +# Update Wallpaper +#================================ + +riot = require 'riot' +dialog = require './dialog.ls' +api = require '../../common/scripts/api.ls' + +module.exports = (I, cb, file = null) ~> + + @set = (file) ~> + api I, \i/appdata/set do + data: JSON.stringify do + wallpaper: file.id + .then (i) ~> + dialog do + '壁紙を更新しました' + '新しい壁紙が反映されるまで時間がかかる場合があります。' + [ + text: \はい + ] + if cb? then cb i + .catch (err) ~> + console.error err + #@opts.ui.trigger \notification 'Error!' + + if file? + @set file + else + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + browser = riot.mount browser, do + multiple: false + title: '壁紙にする画像を選択' + .0 + browser.one \selected (file) ~> + @set file diff --git a/src/web/app/desktop/scripts/user-preview.ls b/src/web/app/desktop/scripts/user-preview.ls new file mode 100644 index 0000000000..0c5a67aedb --- /dev/null +++ b/src/web/app/desktop/scripts/user-preview.ls @@ -0,0 +1,74 @@ +# User Preview +#================================ + +riot = require \riot + +riot.mixin \user-preview do + init: -> + @on \mount ~> + scan.call @ + @on \updated ~> + scan.call @ + + function scan + elems = @root.query-selector-all '[data-user-preview]:not([data-user-preview-attached])' + elems.for-each attach.bind @ + +function attach el + el.set-attribute \data-user-preview-attached true + user = el.get-attribute \data-user-preview + + tag = null + + show-timer = null + hide-timer = null + + el.add-event-listener \mouseover ~> + clear-timeout show-timer + clear-timeout hide-timer + show-timer := set-timeout ~> + show! + , 500ms + + el.add-event-listener \mouseleave ~> + clear-timeout show-timer + clear-timeout hide-timer + hide-timer := set-timeout ~> + close! + , 500ms + + @on \unmount ~> + clear-timeout show-timer + clear-timeout hide-timer + close! + + function show + if tag? + return + + preview = document.create-element \mk-user-preview + + rect = el.get-bounding-client-rect! + x = rect.left + el.offset-width + window.page-x-offset + y = rect.top + window.page-y-offset + + preview.style.top = y + \px + preview.style.left = x + \px + + preview.add-event-listener \mouseover ~> + clear-timeout hide-timer + + preview.add-event-listener \mouseleave ~> + clear-timeout show-timer + hide-timer := set-timeout ~> + close! + , 500ms + + tag := riot.mount (document.body.append-child preview), do + user: user + .0 + + function close + if tag? + tag.close! + tag := null diff --git a/src/web/app/desktop/style.styl b/src/web/app/desktop/style.styl new file mode 100644 index 0000000000..fa50f6ce31 --- /dev/null +++ b/src/web/app/desktop/style.styl @@ -0,0 +1,114 @@ +@import "../base" +@import "../../../../node_modules/cropperjs/dist/cropper.css" + +*::input-placeholder + color #D8CBC5 + +* + &:focus + outline none + + &::scrollbar + width 5px + background transparent + + &:horizontal + height 5px + + &::scrollbar-button + width 0 + height 0 + background rgba(0, 0, 0, 0.2) + + &::scrollbar-piece + background transparent + + &:start + background transparent + + &::scrollbar-thumb + background rgba(0, 0, 0, 0.2) + + &:hover + background rgba(0, 0, 0, 0.4) + + &:active + background $theme-color + + &::scrollbar-corner + background rgba(0, 0, 0, 0.2) + +html + background #fdfdfd + + // ↓ workaround of https://github.com/riot/riot/issues/2134 + &[data-page='entrance'] + #wait + right auto + left 15px + +html[theme='dark'] + background #100f0f + +button + font-family sans-serif + + * + pointer-events none + + &.style-normal + &.style-primary + display block + cursor pointer + padding 0 16px + margin 0 + min-width 100px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + &.style-normal + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + &.style-primary + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color diff --git a/src/web/app/desktop/tags.ls b/src/web/app/desktop/tags.ls new file mode 100644 index 0000000000..f78d367341 --- /dev/null +++ b/src/web/app/desktop/tags.ls @@ -0,0 +1,103 @@ +require './tags/contextmenu.tag' +require './tags/dialog.tag' +require './tags/window.tag' +require './tags/input-dialog.tag' +require './tags/follow-button.tag' +require './tags/drive/base-contextmenu.tag' +require './tags/drive/file-contextmenu.tag' +require './tags/drive/folder-contextmenu.tag' +require './tags/drive/file.tag' +require './tags/drive/folder.tag' +require './tags/drive/nav-folder.tag' +require './tags/drive/browser-window.tag' +require './tags/drive/browser.tag' +require './tags/select-file-from-drive-window.tag' +require './tags/crop-window.tag' +require './tags/settings.tag' +require './tags/settings-window.tag' +require './tags/analog-clock.tag' +require './tags/go-top.tag' +require './tags/ui-header.tag' +require './tags/ui-header-account.tag' +require './tags/ui-header-notifications.tag' +require './tags/ui-header-clock.tag' +require './tags/ui-header-nav.tag' +require './tags/ui-header-post-button.tag' +require './tags/ui-header-search.tag' +require './tags/notifications.tag' +require './tags/post-form-window.tag' +require './tags/post-form.tag' +require './tags/timeline-post.tag' +require './tags/post-preview.tag' +require './tags/repost-form-window.tag' +require './tags/home-widgets/user-recommendation.tag' +require './tags/home-widgets/timeline.tag' +require './tags/home-widgets/mentions.tag' +require './tags/home-widgets/calendar.tag' +require './tags/home-widgets/donation.tag' +require './tags/home-widgets/tips.tag' +require './tags/home-widgets/nav.tag' +require './tags/home-widgets/profile.tag' +require './tags/home-widgets/notifications.tag' +require './tags/home-widgets/rss-reader.tag' +require './tags/home-widgets/photo-stream.tag' +require './tags/home-widgets/broadcast.tag' +require './tags/stream-indicator.tag' +require './tags/timeline.tag' +require './tags/messaging/window.tag' +require './tags/messaging/room.tag' +require './tags/messaging/room-window.tag' +require './tags/messaging/message.tag' +require './tags/messaging/index.tag' +require './tags/messaging/form.tag' +require './tags/following-setuper.tag' +require './tags/ellipsis-icon.tag' +require './tags/ui.tag' +require './tags/home.tag' +require './tags/detect-slow-internet-connection-notice.tag' +require './tags/user-header.tag' +require './tags/user-profile.tag' +require './tags/user-timeline.tag' +require './tags/user.tag' +require './tags/user-home.tag' +require './tags/user-graphs.tag' +require './tags/user-photos.tag' +require './tags/big-follow-button.tag' +require './tags/pages/entrance.tag' +require './tags/pages/entrance/signin.tag' +require './tags/pages/entrance/signup.tag' +require './tags/pages/home.tag' +require './tags/pages/user.tag' +require './tags/pages/post.tag' +require './tags/pages/search.tag' +require './tags/pages/not-found.tag' +require './tags/autocomplete-suggestion.tag' +require './tags/progress-dialog.tag' +require './tags/user-preview.tag' +require './tags/post-detail.tag' +require './tags/post-detail-sub.tag' +require './tags/search.tag' +require './tags/search-posts.tag' +require './tags/set-avatar-suggestion.tag' +require './tags/set-banner-suggestion.tag' +require './tags/repost-form.tag' +require './tags/timeline-post-sub.tag' +require './tags/sub-post-content.tag' +require './tags/images-viewer.tag' +require './tags/image-dialog.tag' +require './tags/donation.tag' +require './tags/user-posts-graph.tag' +require './tags/user-friends-graph.tag' +require './tags/user-likes-graph.tag' +require './tags/post-status-graph.tag' +require './tags/debugger.tag' +require './tags/users-list.tag' +require './tags/user-following.tag' +require './tags/user-followers.tag' +require './tags/user-following-window.tag' +require './tags/user-followers-window.tag' +require './tags/list-user.tag' +require './tags/ui-notification.tag' +require './tags/signin-history.tag' +require './tags/log.tag' +require './tags/log-window.tag' diff --git a/src/web/app/desktop/tags/analog-clock.tag b/src/web/app/desktop/tags/analog-clock.tag new file mode 100644 index 0000000000..a4cfe5726c --- /dev/null +++ b/src/web/app/desktop/tags/analog-clock.tag @@ -0,0 +1,102 @@ +mk-analog-clock + canvas@canvas(width='256', height='256') + +style. + > canvas + display block + width 256px + height 256px + +script. + @on \mount ~> + @draw! + @clock = set-interval @draw, 1000ms + + @on \unmount ~> + clear-interval @clock + + @draw = ~> + now = new Date! + s = now.get-seconds! + m = now.get-minutes! + h = now.get-hours! + + vec2 = (x, y) -> + @x = x + @y = y + + ctx = @refs.canvas.get-context \2d + canv-w = @refs.canvas.width + canv-h = @refs.canvas.height + ctx.clear-rect 0, 0, canv-w, canv-h + + # 背景 + center = (Math.min (canv-w / 2), (canv-h / 2)) + line-start = center * 0.90 + line-end-short = center * 0.87 + line-end-long = center * 0.84 + for i from 0 to 59 by 1 + angle = Math.PI * i / 30 + uv = new vec2 (Math.sin angle), (-Math.cos angle) + ctx.begin-path! + ctx.line-width = 1 + ctx.move-to do + (canv-w / 2) + uv.x * line-start + (canv-h / 2) + uv.y * line-start + if i % 5 == 0 + ctx.stroke-style = 'rgba(255, 255, 255, 0.2)' + ctx.line-to do + (canv-w / 2) + uv.x * line-end-long + (canv-h / 2) + uv.y * line-end-long + else + ctx.stroke-style = 'rgba(255, 255, 255, 0.1)' + ctx.line-to do + (canv-w / 2) + uv.x * line-end-short + (canv-h / 2) + uv.y * line-end-short + ctx.stroke! + + # 分 + angle = Math.PI * (m + s / 60) / 30 + length = (Math.min canv-w, canv-h) / 2.6 + uv = new vec2 (Math.sin angle), (-Math.cos angle) + ctx.begin-path! + ctx.stroke-style = \#ffffff + ctx.line-width = 2 + ctx.move-to do + (canv-w / 2) - uv.x * length / 5 + (canv-h / 2) - uv.y * length / 5 + ctx.line-to do + (canv-w / 2) + uv.x * length + (canv-h / 2) + uv.y * length + ctx.stroke! + + # 時 + angle = Math.PI * (h % 12 + m / 60) / 6 + length = (Math.min canv-w, canv-h) / 4 + uv = new vec2 (Math.sin angle), (-Math.cos angle) + ctx.begin-path! + #ctx.stroke-style = \#ffffff + ctx.stroke-style = CONFIG.theme-color + ctx.line-width = 2 + ctx.move-to do + (canv-w / 2) - uv.x * length / 5 + (canv-h / 2) - uv.y * length / 5 + ctx.line-to do + (canv-w / 2) + uv.x * length + (canv-h / 2) + uv.y * length + ctx.stroke! + + # 秒 + angle = Math.PI * s / 30 + length = (Math.min canv-w, canv-h) / 2.6 + uv = new vec2 (Math.sin angle), (-Math.cos angle) + ctx.begin-path! + ctx.stroke-style = 'rgba(255, 255, 255, 0.5)' + ctx.line-width = 1 + ctx.move-to do + (canv-w / 2) - uv.x * length / 5 + (canv-h / 2) - uv.y * length / 5 + ctx.line-to do + (canv-w / 2) + uv.x * length + (canv-h / 2) + uv.y * length + ctx.stroke! diff --git a/src/web/app/desktop/tags/autocomplete-suggestion.tag b/src/web/app/desktop/tags/autocomplete-suggestion.tag new file mode 100644 index 0000000000..13d9df6914 --- /dev/null +++ b/src/web/app/desktop/tags/autocomplete-suggestion.tag @@ -0,0 +1,182 @@ +mk-autocomplete-suggestion + ol.users@users(if={ users.length > 0 }) + li(each={ users }, onclick={ parent.on-click }, onkeydown={ parent.on-keydown }, tabindex='-1') + img.avatar(src={ avatar_url + '?thumbnail&size=32' }, alt='') + span.name { name } + span.username @{ username } + +style. + display block + position absolute + z-index 65535 + margin-top calc(1em + 8px) + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + + > .users + display block + margin 0 + padding 4px 0 + max-height 190px + max-width 500px + overflow auto + list-style none + + > li + display block + padding 4px 12px + white-space nowrap + overflow hidden + font-size 0.9em + color rgba(0, 0, 0, 0.8) + cursor default + + &, * + user-select none + + &:hover + &[data-selected='true'] + color #fff + background $theme-color + + .name + color #fff + + .username + color #fff + + &:active + color #fff + background darken($theme-color, 10%) + + .name + color #fff + + .username + color #fff + + .avatar + vertical-align middle + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 100% + + .name + margin 0 8px 0 0 + /*font-weight bold*/ + font-weight normal + color rgba(0, 0, 0, 0.8) + + .username + font-weight normal + color rgba(0, 0, 0, 0.3) + +script. + @mixin \api + + @q = @opts.q + @textarea = @opts.textarea + @loading = true + @users = [] + @select = -1 + + @on \mount ~> + @textarea.add-event-listener \keydown @on-keydown + + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.add-event-listener \mousedown @mousedown + + @api \users/search_by_username do + query: @q + limit: 30users + .then (users) ~> + @users = users + @loading = false + @update! + .catch (err) ~> + console.error err + + @on \unmount ~> + @textarea.remove-event-listener \keydown @on-keydown + + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.remove-event-listener \mousedown @mousedown + + @mousedown = (e) ~> + if (!contains @root, e.target) and (@root != e.target) + @close! + + @on-click = (e) ~> + @complete e.item + + @on-keydown = (e) ~> + key = e.which + switch (key) + | 10, 13 => # Key[ENTER] + if @select != -1 + e.prevent-default! + e.stop-propagation! + @complete @users[@select] + else + @close! + | 27 => # Key[ESC] + e.prevent-default! + e.stop-propagation! + @close! + | 38 => # Key[↑] + if @select != -1 + e.prevent-default! + e.stop-propagation! + @select-prev! + else + @close! + | 9, 40 => # Key[TAB] or Key[↓] + e.prevent-default! + e.stop-propagation! + @select-next! + | _ => + @close! + + @select-next = ~> + @select++ + + if @select >= @users.length + @select = 0 + + @apply-select! + + @select-prev = ~> + @select-- + + if @select < 0 + @select = @users.length - 1 + + @apply-select! + + @apply-select = ~> + @refs.users.children.for-each (el) ~> + el.remove-attribute \data-selected + + @refs.users.children[@select].set-attribute \data-selected \true + @refs.users.children[@select].focus! + + @complete = (user) ~> + @opts.complete user + + @close = ~> + @opts.close! + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/big-follow-button.tag b/src/web/app/desktop/tags/big-follow-button.tag new file mode 100644 index 0000000000..636853407c --- /dev/null +++ b/src/web/app/desktop/tags/big-follow-button.tag @@ -0,0 +1,134 @@ +mk-big-follow-button + button(if={ !init }, class={ wait: wait, follow: !user.is_following, unfollow: user.is_following }, + onclick={ onclick }, + disabled={ wait }, + title={ user.is_following ? 'フォロー解除' : 'フォローする' }) + span(if={ !wait && user.is_following }) + i.fa.fa-minus + | フォロー解除 + span(if={ !wait && !user.is_following }) + i.fa.fa-plus + | フォロー + i.fa.fa-spinner.fa-pulse.fa-fw(if={ wait }) + div.init(if={ init }): i.fa.fa-spinner.fa-pulse.fa-fw + +style. + display block + + > button + > .init + display block + cursor pointer + padding 0 + margin 0 + width 100% + line-height 38px + font-size 1em + outline none + border-radius 4px + + * + pointer-events none + + i + margin-right 8px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &.follow + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + &.unfollow + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + &.wait + cursor wait !important + opacity 0.7 + +script. + @mixin \api + @mixin \is-promise + @mixin \stream + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + @init = true + @wait = false + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @init = false + @update! + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + @on \unmount ~> + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + @on-stream-follow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @on-stream-unfollow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @onclick = ~> + @wait = true + if @user.is_following + @api \following/delete do + user_id: @user.id + .then ~> + @user.is_following = false + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! + else + @api \following/create do + user_id: @user.id + .then ~> + @user.is_following = true + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! diff --git a/src/web/app/desktop/tags/contextmenu.tag b/src/web/app/desktop/tags/contextmenu.tag new file mode 100644 index 0000000000..7c3c7b8a24 --- /dev/null +++ b/src/web/app/desktop/tags/contextmenu.tag @@ -0,0 +1,138 @@ +mk-contextmenu + | + +style. + $width = 240px + $item-height = 38px + $padding = 10px + + display none + position fixed + top 0 + left 0 + z-index 4096 + width $width + font-size 0.8em + background #fff + border-radius 0 4px 4px 4px + box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2) + + ul + display block + margin 0 + padding $padding 0 + list-style none + + li + display block + + &.separator + margin-top $padding + padding-top $padding + border-top solid 1px #eee + + &.has-child + > p + cursor default + + > i:last-child + position absolute + top 0 + right 8px + line-height $item-height + + &:hover > ul + visibility visible + + &:active + > p, a + background $theme-color + + > p, a + display block + z-index 1 + margin 0 + padding 0 32px 0 38px + line-height $item-height + color #868C8C + text-decoration none + cursor pointer + + &:hover + text-decoration none + + * + pointer-events none + + > i + width 28px + margin-left -28px + text-align center + + &:hover + > p, a + text-decoration none + background $theme-color + color $theme-color-foreground + + &:active + > p, a + text-decoration none + background darken($theme-color, 10%) + color $theme-color-foreground + + li > ul + visibility hidden + position absolute + top 0 + left $width + margin-top -($padding) + width $width + background #fff + border-radius 0 4px 4px 4px + box-shadow 2px 2px 8px rgba(0, 0, 0, 0.2) + transition visibility 0s linear 0.2s + +script. + + @root.add-event-listener \contextmenu (e) ~> + e.prevent-default! + + @mousedown = (e) ~> + e.prevent-default! + if (!contains @root, e.target) and (@root != e.target) + @close! + return false + + @open = (pos) ~> + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.add-event-listener \mousedown @mousedown + @root.style.display = \block + @root.style.left = pos.x + \px + @root.style.top = pos.y + \px + + Velocity @root, \finish true + Velocity @root, { opacity: 0 } 0ms + Velocity @root, { + opacity: 1 + } { + queue: false + duration: 100ms + easing: \linear + } + + @close = ~> + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.remove-event-listener \mousedown @mousedown + @trigger \closed + @unmount! + + function contains(parent, child) + node = child.parent-node + while (node != null) + if (node == parent) + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/crop-window.tag b/src/web/app/desktop/tags/crop-window.tag new file mode 100644 index 0000000000..16e1a72b3a --- /dev/null +++ b/src/web/app/desktop/tags/crop-window.tag @@ -0,0 +1,189 @@ +mk-crop-window + mk-window@window(is-modal={ true }, width={ '800px' }) + + i.fa.fa-crop + | { parent.title } + + + div.body + img@img(src={ parent.image.url + '?thumbnail&quality=80' }, alt='') + div.action + button.skip(onclick={ parent.skip }) クロップをスキップ + button.cancel(onclick={ parent.cancel }) キャンセル + button.ok(onclick={ parent.ok }) 決定 + + +style. + display block + + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + + > .body + > img + width 100% + max-height 400px + + .cropper-modal { + opacity: 0.8; + } + + .cropper-view-box { + outline-color: $theme-color; + } + + .cropper-line, .cropper-point { + background-color: $theme-color; + } + + .cropper-bg { + animation: cropper-bg 0.5s linear infinite; + } + + @-webkit-keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } + } + + @-moz-keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } + } + + @-ms-keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } + } + + @keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } + } + + > .action + height 72px + background lighten($theme-color, 95%) + + .ok + .cancel + .skip + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + .ok + .cancel + width 120px + + .ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + .cancel + .skip + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + .cancel + right 148px + + .skip + left 16px + width 150px + +script. + @mixin \cropper + + @image = @opts.file + @title = @opts.title + @aspect-ratio = @opts.aspect-ratio + @cropper = null + + @on \mount ~> + @img = @refs.window.refs.img + @cropper = new @Cropper @img, do + aspect-ratio: @aspect-ratio + highlight: no + view-mode: 1 + + @ok = ~> + @cropper.get-cropped-canvas!.to-blob (blob) ~> + @trigger \cropped blob + @refs.window.close! + + @skip = ~> + @trigger \skiped + @refs.window.close! + + @cancel = ~> + @trigger \canceled + @refs.window.close! diff --git a/src/web/app/desktop/tags/debugger.tag b/src/web/app/desktop/tags/debugger.tag new file mode 100644 index 0000000000..e2b522cb00 --- /dev/null +++ b/src/web/app/desktop/tags/debugger.tag @@ -0,0 +1,87 @@ +mk-debugger + mk-window@window(is-modal={ false }, width={ '700px' }, height={ '550px' }) + + i.fa.fa-wrench + | Debugger + + + section.progress-dialog + h1 progress-dialog + button.style-normal(onclick={ parent.progress-dialog }): i.fa.fa-play + button.style-normal(onclick={ parent.progress-dialog-destroy }): i.fa.fa-stop + label + p TITLE: + input@progress-title(value='Title') + label + p VAL: + input@progress-value(type='number', oninput={ parent.progress-change }, value=0) + label + p MAX: + input@progress-max(type='number', oninput={ parent.progress-change }, value=100) + + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + overflow auto + + > section + padding 32px + + // & + section + // margin-top 16px + + > h1 + display block + margin 0 + padding 0 0 8px 0 + font-size 1em + color #555 + border-bottom solid 1px #eee + + > label + display block + + > p + display inline + margin 0 + + > .progress-dialog + button + display inline-block + margin 8px + +script. + @mixin \open-window + + @on \mount ~> + @progress-title = @tags['mk-window'].progress-title + @progress-value = @tags['mk-window'].progress-value + @progress-max = @tags['mk-window'].progress-max + + @refs.window.on \closed ~> + @unmount! + + ################################ + + @progress-controller = riot.observable! + + @progress-dialog = ~> + @open-window \mk-progress-dialog do + title: @progress-title.value + value: @progress-value.value + max: @progress-max.value + controller: @progress-controller + + @progress-change = ~> + @progress-controller.trigger do + \update + @progress-value.value + @progress-max.value + + @progress-dialog-destroy = ~> + @progress-controller.trigger \close diff --git a/src/web/app/desktop/tags/detect-slow-internet-connection-notice.tag b/src/web/app/desktop/tags/detect-slow-internet-connection-notice.tag new file mode 100644 index 0000000000..f11a0c0857 --- /dev/null +++ b/src/web/app/desktop/tags/detect-slow-internet-connection-notice.tag @@ -0,0 +1,56 @@ +mk-detect-slow-internet-connection-notice + i: i.fa.fa-exclamation + div: p インターネット回線が遅いようです。 + +style. + display block + pointer-events none + position fixed + z-index 16384 + top 64px + right 16px + margin 0 + padding 0 + width 298px + font-size 0.9em + background #fff + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + opacity 0 + + > i + display block + width 48px + line-height 48px + margin-right 0.25em + text-align center + color $theme-color-foreground + font-size 1.5em + background $theme-color + + > div + display block + position absolute + top 0 + left 48px + margin 0 + width 250px + height 48px + color #666 + + > p + display block + margin 0 + padding 8px + +script. + @mixin \net + + @net.on \detected-slow-network ~> + Velocity @root, { + opacity: 1 + } 200ms \linear + set-timeout ~> + Velocity @root, { + opacity: 0 + } 200ms \linear + , 10000ms diff --git a/src/web/app/desktop/tags/dialog.tag b/src/web/app/desktop/tags/dialog.tag new file mode 100644 index 0000000000..88a461db84 --- /dev/null +++ b/src/web/app/desktop/tags/dialog.tag @@ -0,0 +1,141 @@ +mk-dialog + div.bg@bg(onclick={ bg-click }) + div.main@main + header@header + div.body@body + div.buttons + virtual(each={ opts.buttons }) + button(onclick={ _onclick }) { text } + +style. + display block + + > .bg + display block + position fixed + z-index 8192 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + opacity 0 + pointer-events none + + > .main + display block + position fixed + z-index 8192 + top 20% + left 0 + right 0 + margin 0 auto 0 auto + padding 32px 42px + width 480px + background #fff + + > header + margin 1em 0 + color $theme-color + // color #43A4EC + font-weight bold + + > i + margin-right 0.5em + + > .body + margin 1em 0 + color #888 + + > .buttons + > button + display inline-block + float right + margin 0 + padding 10px 10px + font-size 1.1em + font-weight normal + text-decoration none + color #888 + background transparent + outline none + border none + border-radius 0 + cursor pointer + transition color 0.1s ease + + i + margin 0 0.375em + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + +script. + @can-through = if opts.can-through? then opts.can-through else true + @opts.buttons.for-each (button) ~> + button._onclick = ~> + if button.onclick? + button.onclick! + @close! + + @on \mount ~> + @refs.header.innerHTML = @opts.title + @refs.body.innerHTML = @opts.text + + @refs.bg.style.pointer-events = \auto + Velocity @refs.bg, \finish true + Velocity @refs.bg, { + opacity: 1 + } { + queue: false + duration: 100ms + easing: \linear + } + + Velocity @refs.main, { + opacity: 0 + scale: 1.2 + } { + duration: 0 + } + Velocity @refs.main, { + opacity: 1 + scale: 1 + } { + duration: 300ms + easing: [ 0, 0.5, 0.5, 1 ] + } + + @close = ~> + @refs.bg.style.pointer-events = \none + Velocity @refs.bg, \finish true + Velocity @refs.bg, { + opacity: 0 + } { + queue: false + duration: 300ms + easing: \linear + } + + @refs.main.style.pointer-events = \none + Velocity @refs.main, \finish true + Velocity @refs.main, { + opacity: 0 + scale: 0.8 + } { + queue: false + duration: 300ms + easing: [ 0.5, -0.5, 1, 0.5 ] + complete: ~> + @unmount! + } + + @bg-click = ~> + if @can-through + if @opts.on-through? + @opts.on-through! + @close! diff --git a/src/web/app/desktop/tags/donation.tag b/src/web/app/desktop/tags/donation.tag new file mode 100644 index 0000000000..9f8a1a6729 --- /dev/null +++ b/src/web/app/desktop/tags/donation.tag @@ -0,0 +1,63 @@ +mk-donation + button.close(onclick={ close }) 閉じる x + div.message + p 利用者の皆さま、 + p + | 今日は、日本の皆さまにお知らせがあります。 + | Misskeyの援助をお願いいたします。 + | 私は独立性を守るため、一切の広告を掲載いたしません。 + | 平均で約¥1,500の寄付をいただき、運営しております。 + | 援助をしてくださる利用者はほんの少数です。 + | お願いいたします。 + | 今日、利用者の皆さまが¥300ご援助くだされば、募金活動を一時間で終了することができます。 + | コーヒー1杯ほどの金額です。 + | Misskeyを活用しておられるのでしたら、広告を掲載せずにもう1年活動できるよう、どうか1分だけお時間をください。 + | 私は小さな非営利個人ですが、サーバー、プログラム、人件費など、世界でトップクラスのウェブサイト同等のコストがかかります。 + | 利用者は何億人といますが、他の大きなサイトに比べてほんの少額の費用で運営しているのです。 + | 人間の可能性、自由、そして機会。知識こそ、これらの基盤を成すものです。 + | 私は、誰もが無料かつ制限なく知識に触れられるべきだと信じています。 + | 募金活動を終了し、Misskeyの改善に戻れるようご援助ください。 + | よろしくお願いいたします。 + +style. + display block + color #fff + background #03072C + + > .close + position absolute + top 16px + right 16px + z-index 1 + + > .message + padding 32px + font-size 1.4em + font-family serif + + > p + display block + margin 0 auto + max-width 1200px + + > p:first-child + margin-bottom 16px + +script. + @mixin \api + @mixin \i + + @close = (e) ~> + e.prevent-default! + e.stop-propagation! + + @I.data.no_donation = true + @api \i/appdata/set do + data: JSON.stringify do + no_donation: @I.data.no_donation + .then ~> + @update-i! + + @unmount! + + @parent.parent.set-root-layout! diff --git a/src/web/app/desktop/tags/drive/base-contextmenu.tag b/src/web/app/desktop/tags/drive/base-contextmenu.tag new file mode 100644 index 0000000000..c8b51009ea --- /dev/null +++ b/src/web/app/desktop/tags/drive/base-contextmenu.tag @@ -0,0 +1,28 @@ +mk-drive-browser-base-contextmenu + mk-contextmenu@ctx + ul + li(onclick={ parent.create-folder }): p + i.fa.fa-folder-o + | フォルダーを作成 + li(onclick={ parent.upload }): p + i.fa.fa-upload + | ファイルをアップロード + +script. + @browser = @opts.browser + + @on \mount ~> + @refs.ctx.on \closed ~> + @trigger \closed + @unmount! + + @open = (pos) ~> + @refs.ctx.open pos + + @create-folder = ~> + @browser.create-folder! + @refs.ctx.close! + + @upload = ~> + @browser.select-local-file! + @refs.ctx.close! diff --git a/src/web/app/desktop/tags/drive/browser-window.tag b/src/web/app/desktop/tags/drive/browser-window.tag new file mode 100644 index 0000000000..b3a5fc9a47 --- /dev/null +++ b/src/web/app/desktop/tags/drive/browser-window.tag @@ -0,0 +1,29 @@ +mk-drive-browser-window + mk-window@window(is-modal={ false }, width={ '800px' }, height={ '500px' }) + + i.fa.fa-cloud + | ドライブ + + + mk-drive-browser(multiple={ true }, folder={ parent.folder }) + + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + > mk-drive-browser + height 100% + +script. + @folder = if @opts.folder? then @opts.folder else null + + @on \mount ~> + @refs.window.on \closed ~> + @unmount! + + @close = ~> + @refs.window.close! diff --git a/src/web/app/desktop/tags/drive/browser.tag b/src/web/app/desktop/tags/drive/browser.tag new file mode 100644 index 0000000000..62e6425fe5 --- /dev/null +++ b/src/web/app/desktop/tags/drive/browser.tag @@ -0,0 +1,634 @@ +mk-drive-browser + nav + div.path(oncontextmenu={ path-oncontextmenu }) + mk-drive-browser-nav-folder(class={ current: folder == null }, folder={ null }) + virtual(each={ folder in hierarchy-folders }) + span.separator: i.fa.fa-angle-right + mk-drive-browser-nav-folder(folder={ folder }) + span.separator(if={ folder != null }): i.fa.fa-angle-right + span.folder.current(if={ folder != null }) + | { folder.name } + input.search(type='search', placeholder!=' 検索') + div.main@main(class={ uploading: uploads.length > 0, loading: loading }, onmousedown={ onmousedown }, ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop }, oncontextmenu={ oncontextmenu }) + div.selection@selection + div.contents@contents + div.folders@folders-container(if={ folders.length > 0 }) + virtual(each={ folder in folders }) + mk-drive-browser-folder.folder(folder={ folder }) + button(if={ more-folders }) + | もっと読み込む + div.files@files-container(if={ files.length > 0 }) + virtual(each={ file in files }) + mk-drive-browser-file.file(file={ file }) + button(if={ more-files }) + | もっと読み込む + div.empty(if={ files.length == 0 && folders.length == 0 && !loading }) + p(if={ draghover }) + | ドロップですか?いいですよ、ボクはカワイイですからね + p(if={ !draghover && folder == null }) + strong ドライブには何もありません。 + br + | 右クリックして「ファイルをアップロード」を選んだり、ファイルをドラッグ&ドロップすることでもアップロードできます。 + p(if={ !draghover && folder != null }) + | このフォルダーは空です + div.loading(if={ loading }). +
    +
    +
    +
    + div.dropzone(if={ draghover }) + mk-uploader@uploader + input@file-input(type='file', accept='*/*', multiple, tabindex='-1', onchange={ change-file-input }) + +style. + display block + + > nav + display block + z-index 2 + width 100% + overflow auto + font-size 0.9em + color #555 + background #fff + //border-bottom 1px solid #dfdfdf + box-shadow 0 1px 0 rgba(0, 0, 0, 0.05) + + &, * + user-select none + + > .path + display inline-block + vertical-align bottom + margin 0 + padding 0 8px + width calc(100% - 200px) + line-height 38px + white-space nowrap + + > * + display inline-block + margin 0 + padding 0 8px + line-height 38px + cursor pointer + + i + margin-right 4px + + * + pointer-events none + + &:hover + text-decoration underline + + &.current + font-weight bold + cursor default + + &:hover + text-decoration none + + &.separator + margin 0 + padding 0 + opacity 0.5 + cursor default + + > i + margin 0 + + > .search + display inline-block + vertical-align bottom + user-select text + cursor auto + margin 0 + padding 0 18px + width 200px + font-size 1em + line-height 38px + background transparent + outline none + //border solid 1px #ddd + border none + border-radius 0 + box-shadow none + transition color 0.5s ease, border 0.5s ease + font-family FontAwesome, sans-serif + + &[data-active='true'] + background #fff + + &::-webkit-input-placeholder, + &:-ms-input-placeholder, + &:-moz-placeholder + color $ui-controll-foreground-color + + > .main + padding 8px + height calc(100% - 38px) + overflow auto + + &, * + user-select none + + &.loading + cursor wait !important + + * + pointer-events none + + > .contents + opacity 0.5 + + &.uploading + height calc(100% - 38px - 100px) + + > .selection + display none + position absolute + z-index 128 + top 0 + left 0 + border solid 1px $theme-color + background rgba($theme-color, 0.5) + pointer-events none + + > .contents + + > .folders + &:after + content "" + display block + clear both + + > .folder + float left + + > .files + &:after + content "" + display block + clear both + + > .file + float left + + > .empty + padding 16px + text-align center + color #999 + pointer-events none + + > p + margin 0 + + > .loading + .spinner + margin 100px auto + width 40px + height 40px + text-align center + + animation sk-rotate 2.0s infinite linear + + .dot1, .dot2 + width 60% + height 60% + display inline-block + position absolute + top 0 + background-color rgba(0, 0, 0, 0.3) + border-radius 100% + + animation sk-bounce 2.0s infinite ease-in-out + + .dot2 + top auto + bottom 0 + animation-delay -1.0s + + @keyframes sk-rotate { 100% { transform: rotate(360deg); }} + + @keyframes sk-bounce { + 0%, 100% { + transform: scale(0.0); + } 50% { + transform: scale(1.0); + } + } + + > .dropzone + position absolute + left 0 + top 38px + width 100% + height calc(100% - 38px) + border dashed 2px rgba($theme-color, 0.5) + pointer-events none + + > mk-uploader + height 100px + padding 16px + background #fff + + > input + display none + +script. + @mixin \api + @mixin \dialog + @mixin \input-dialog + @mixin \stream + + @files = [] + @folders = [] + @hierarchy-folders = [] + + @uploads = [] + + # 現在の階層(フォルダ) + # * null でルートを表す + @folder = null + + @multiple = if @opts.multiple? then @opts.multiple else false + + # ドロップされようとしているか + @draghover = false + + # 自信の所有するアイテムがドラッグをスタートさせたか + # (自分自身の階層にドロップできないようにするためのフラグ) + @is-drag-source = false + + @on \mount ~> + @refs.uploader.on \uploaded (file) ~> + @add-file file, true + + @refs.uploader.on \change-uploads (uploads) ~> + @uploads = uploads + @update! + + @stream.on \drive_file_created @on-stream-drive-file-created + @stream.on \drive_file_updated @on-stream-drive-file-updated + @stream.on \drive_folder_created @on-stream-drive-folder-created + @stream.on \drive_folder_updated @on-stream-drive-folder-updated + + # Riotのバグでnullを渡しても""になる + # https://github.com/riot/riot/issues/2080 + #if @opts.folder? + if @opts.folder? and @opts.folder != '' + @move @opts.folder + else + @load! + + @on \unmount ~> + @stream.off \drive_file_created @on-stream-drive-file-created + @stream.off \drive_file_updated @on-stream-drive-file-updated + @stream.off \drive_folder_created @on-stream-drive-folder-created + @stream.off \drive_folder_updated @on-stream-drive-folder-updated + + @on-stream-drive-file-created = (file) ~> + @add-file file, true + + @on-stream-drive-file-updated = (file) ~> + current = if @folder? then @folder.id else null + if current != file.folder_id + @remove-file file + else + @add-file file, true + + @on-stream-drive-folder-created = (folder) ~> + @add-folder folder, true + + @on-stream-drive-folder-updated = (folder) ~> + current = if @folder? then @folder.id else null + if current != folder.parent_id + @remove-folder folder + else + @add-folder folder, true + + @onmousedown = (e) ~> + if (contains @refs.folders-container, e.target) or (contains @refs.files-container, e.target) + return true + + rect = @refs.main.get-bounding-client-rect! + + left = e.page-x + @refs.main.scroll-left - rect.left - window.page-x-offset + top = e.page-y + @refs.main.scroll-top - rect.top - window.page-y-offset + + move = (e) ~> + @refs.selection.style.display = \block + + cursor-x = e.page-x + @refs.main.scroll-left - rect.left - window.page-x-offset + cursor-y = e.page-y + @refs.main.scroll-top - rect.top - window.page-y-offset + w = cursor-x - left + h = cursor-y - top + + if w > 0 + @refs.selection.style.width = w + \px + @refs.selection.style.left = left + \px + else + @refs.selection.style.width = -w + \px + @refs.selection.style.left = cursor-x + \px + + if h > 0 + @refs.selection.style.height = h + \px + @refs.selection.style.top = top + \px + else + @refs.selection.style.height = -h + \px + @refs.selection.style.top = cursor-y + \px + + up = (e) ~> + document.document-element.remove-event-listener \mousemove move + document.document-element.remove-event-listener \mouseup up + + @refs.selection.style.display = \none + + document.document-element.add-event-listener \mousemove move + document.document-element.add-event-listener \mouseup up + + @path-oncontextmenu = (e) ~> + e.prevent-default! + e.stop-immediate-propagation! + return false + + @ondragover = (e) ~> + e.prevent-default! + e.stop-propagation! + + # ドラッグ元が自分自身の所有するアイテムかどうか + if !@is-drag-source + # ドラッグされてきたものがファイルだったら + if e.data-transfer.effect-allowed == \all + e.data-transfer.drop-effect = \copy + else + e.data-transfer.drop-effect = \move + @draghover = true + else + # 自分自身にはドロップさせない + e.data-transfer.drop-effect = \none + return false + + @ondragenter = (e) ~> + e.prevent-default! + if !@is-drag-source + @draghover = true + + @ondragleave = (e) ~> + @draghover = false + + @ondrop = (e) ~> + e.prevent-default! + e.stop-propagation! + + @draghover = false + + # ドロップされてきたものがファイルだったら + if e.data-transfer.files.length > 0 + Array.prototype.for-each.call e.data-transfer.files, (file) ~> + @upload file, @folder + return false + + # データ取得 + data = e.data-transfer.get-data 'text' + if !data? + return false + + # パース + obj = JSON.parse data + + # (ドライブの)ファイルだったら + if obj.type == \file + file = obj.id + if (@files.some (f) ~> f.id == file) + return false + @remove-file file + @api \drive/files/update do + file_id: file + folder_id: if @folder? then @folder.id else \null + .then ~> + # something + .catch (err, text-status) ~> + console.error err + + # (ドライブの)フォルダーだったら + else if obj.type == \folder + folder = obj.id + # 移動先が自分自身ならreject + if @folder? and folder == @folder.id + return false + if (@folders.some (f) ~> f.id == folder) + return false + @remove-folder folder + @api \drive/folders/update do + folder_id: folder + parent_id: if @folder? then @folder.id else \null + .then ~> + # something + .catch (err) ~> + if err == 'detected-circular-definition' + @dialog do + '操作を完了できません' + '移動先のフォルダーは、移動するフォルダーのサブフォルダーです。' + [ + text: \OK + ] + + return false + + @oncontextmenu = (e) ~> + e.prevent-default! + e.stop-immediate-propagation! + + ctx = document.body.append-child document.create-element \mk-drive-browser-base-contextmenu + ctx = riot.mount ctx, do + browser: @ + ctx = ctx.0 + ctx.open do + x: e.page-x - window.page-x-offset + y: e.page-y - window.page-y-offset + + return false + + @select-local-file = ~> + @refs.file-input.click! + + @create-folder = ~> + name <~ @input-dialog do + 'フォルダー作成' + 'フォルダー名' + null + + @api \drive/folders/create do + name: name + folder_id: if @folder? then @folder.id else undefined + .then (folder) ~> + @add-folder folder, true + @update! + .catch (err) ~> + console.error err + + @change-file-input = ~> + files = @refs.file-input.files + for i from 0 to files.length - 1 + file = files.item i + @upload file, @folder + + @upload = (file, folder) ~> + if folder? and typeof folder == \object + folder = folder.id + @refs.uploader.upload file, folder + + @get-selection = ~> + @files.filter (file) -> file._selected + + @new-window = (folder-id) ~> + browser = document.body.append-child document.create-element \mk-drive-browser-window + riot.mount browser, do + folder: folder-id + + @move = (target-folder) ~> + if target-folder? and typeof target-folder == \object + target-folder = target-folder.id + + if target-folder == null + @go-root! + return + + @loading = true + @update! + + @api \drive/folders/show do + folder_id: target-folder + .then (folder) ~> + @folder = folder + @hierarchy-folders = [] + + x = (f) ~> + @hierarchy-folders.unshift f + if f.parent? + x f.parent + + if folder.parent? + x folder.parent + + @update! + @load! + .catch (err, text-status) -> + console.error err + + @add-folder = (folder, unshift = false) ~> + current = if @folder? then @folder.id else null + if current != folder.parent_id + return + + if (@folders.some (f) ~> f.id == folder.id) + exist = (@folders.map (f) -> f.id).index-of folder.id + @folders[exist] = folder + @update! + return + + if unshift + @folders.unshift folder + else + @folders.push folder + + @update! + + @add-file = (file, unshift = false) ~> + current = if @folder? then @folder.id else null + if current != file.folder_id + return + + if (@files.some (f) ~> f.id == file.id) + exist = (@files.map (f) -> f.id).index-of file.id + @files[exist] = file + @update! + return + + if unshift + @files.unshift file + else + @files.push file + + @update! + + @remove-folder = (folder) ~> + if typeof folder == \object + folder = folder.id + @folders = @folders.filter (f) -> f.id != folder + @update! + + @remove-file = (file) ~> + if typeof file == \object + file = file.id + @files = @files.filter (f) -> f.id != file + @update! + + @go-root = ~> + if @folder != null + @folder = null + @hierarchy-folders = [] + @update! + @load! + + @load = ~> + @folders = [] + @files = [] + @more-folders = false + @more-files = false + @loading = true + @update! + + load-folders = null + load-files = null + + folders-max = 30 + files-max = 30 + + # フォルダ一覧取得 + @api \drive/folders do + folder_id: if @folder? then @folder.id else null + limit: folders-max + 1 + .then (folders) ~> + if folders.length == folders-max + 1 + @more-folders = true + folders.pop! + load-folders := folders + complete! + .catch (err, text-status) ~> + console.error err + + # ファイル一覧取得 + @api \drive/files do + folder_id: if @folder? then @folder.id else null + limit: files-max + 1 + .then (files) ~> + if files.length == files-max + 1 + @more-files = true + files.pop! + load-files := files + complete! + .catch (err, text-status) ~> + console.error err + + flag = false + complete = ~> + if flag + load-folders.for-each (folder) ~> + @add-folder folder + load-files.for-each (file) ~> + @add-file file + @loading = false + @update! + else + flag := true + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/drive/file-contextmenu.tag b/src/web/app/desktop/tags/drive/file-contextmenu.tag new file mode 100644 index 0000000000..7d7dca6c92 --- /dev/null +++ b/src/web/app/desktop/tags/drive/file-contextmenu.tag @@ -0,0 +1,97 @@ +mk-drive-browser-file-contextmenu + mk-contextmenu@ctx: ul + li(onclick={ parent.rename }): p + i.fa.fa-i-cursor + | 名前を変更 + li(onclick={ parent.copy-url }): p + i.fa.fa-link + | URLをコピー + li: a(href={ parent.file.url + '?download' }, download={ parent.file.name }, onclick={ parent.download }) + i.fa.fa-download + | ダウンロード + li.separator + li(onclick={ parent.delete }): p + i.fa.fa-trash-o + | 削除 + li.separator + li.has-child + p + | その他... + i.fa.fa-caret-right + ul + li(onclick={ parent.set-avatar }): p + | アバターに設定 + li(onclick={ parent.set-banner }): p + | バナーに設定 + li(onclick={ parent.set-wallpaper }): p + | 壁紙に設定 + li.has-child + p + | アプリで開く... + i.fa.fa-caret-right + ul + li(onclick={ parent.add-app }): p + | アプリを追加... + +script. + @mixin \api + @mixin \i + @mixin \update-avatar + @mixin \update-banner + @mixin \update-wallpaper + @mixin \input-dialog + @mixin \NotImplementedException + + @browser = @opts.browser + @file = @opts.file + + @on \mount ~> + @refs.ctx.on \closed ~> + @trigger \closed + @unmount! + + @open = (pos) ~> + @refs.ctx.open pos + + @rename = ~> + @refs.ctx.close! + + name <~ @input-dialog do + 'ファイル名の変更' + '新しいファイル名を入力してください' + @file.name + + @api \drive/files/update do + file_id: @file.id + name: name + .then ~> + # something + .catch (err) ~> + console.error err + + @copy-url = ~> + @NotImplementedException! + + @download = ~> + @refs.ctx.close! + + @set-avatar = ~> + @refs.ctx.close! + @update-avatar @I, (i) ~> + @update-i i + , @file + + @set-banner = ~> + @refs.ctx.close! + @update-banner @I, (i) ~> + @update-i i + , @file + + @set-wallpaper = ~> + @refs.ctx.close! + @update-wallpaper @I, (i) ~> + @update-i i + , @file + + @add-app = ~> + @NotImplementedException! diff --git a/src/web/app/desktop/tags/drive/file.tag b/src/web/app/desktop/tags/drive/file.tag new file mode 100644 index 0000000000..1702bb6501 --- /dev/null +++ b/src/web/app/desktop/tags/drive/file.tag @@ -0,0 +1,207 @@ +mk-drive-browser-file(data-is-selected={ (file._selected || false).toString() }, data-is-contextmenu-showing={ is-contextmenu-showing.toString() }, onclick={ onclick }, oncontextmenu={ oncontextmenu }, draggable='true', ondragstart={ ondragstart }, ondragend={ ondragend }, title={ title }) + div.label(if={ I.avatar_id == file.id }) + img(src='/_/resources/label.svg') + p アバター + div.label(if={ I.banner_id == file.id }) + img(src='/_/resources/label.svg') + p バナー + div.label(if={ I.data.wallpaper == file.id }) + img(src='/_/resources/label.svg') + p 壁紙 + div.thumbnail: img(src={ file.url + '?thumbnail&size=128' }, alt='') + p.name + span { file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name } + span.ext(if={ file.name.lastIndexOf('.') != -1 }) { file.name.substr(file.name.lastIndexOf('.')) } + +style. + display block + margin 4px + padding 8px 0 0 0 + width 144px + height 180px + border-radius 4px + + &, * + cursor pointer + + &:hover + background rgba(0, 0, 0, 0.05) + + > .label + &:before + &:after + background #0b65a5 + + &:active + background rgba(0, 0, 0, 0.1) + + > .label + &:before + &:after + background #0b588c + + &[data-is-selected='true'] + background $theme-color + + &:hover + background lighten($theme-color, 10%) + + &:active + background darken($theme-color, 10%) + + > .label + &:before + &:after + display none + + > .name + color $theme-color-foreground + + &[data-is-contextmenu-showing='true'] + &:after + content "" + pointer-events none + position absolute + top -4px + right -4px + bottom -4px + left -4px + border 2px dashed rgba($theme-color, 0.3) + border-radius 4px + + > .label + position absolute + top 0 + left 0 + pointer-events none + + &:before + content "" + display block + position absolute + z-index 1 + top 0 + left 57px + width 28px + height 8px + background #0c7ac9 + + &:after + content "" + display block + position absolute + z-index 1 + top 57px + left 0 + width 8px + height 28px + background #0c7ac9 + + > img + position absolute + z-index 2 + top 0 + left 0 + + > p + position absolute + z-index 3 + top 19px + left -28px + width 120px + margin 0 + text-align center + line-height 28px + color #fff + transform rotate(-45deg) + + > .thumbnail + width 128px + height 128px + left 8px + + > img + display block + position absolute + top 0 + left 0 + right 0 + bottom 0 + margin auto + max-width 128px + max-height 128px + pointer-events none + + > .name + display block + margin 4px 0 0 0 + font-size 0.8em + text-align center + word-break break-all + color #444 + overflow hidden + + > .ext + opacity 0.5 + +script. + @mixin \i + @mixin \bytes-to-size + + @file = @opts.file + @browser = @parent + + @title = @file.name + '\n' + @file.type + ' ' + (@bytes-to-size @file.datasize) + + @is-contextmenu-showing = false + + @onclick = ~> + if @browser.multiple + if @file._selected? + @file._selected = !@file._selected + else + @file._selected = true + @browser.trigger \change-selection @browser.get-selection! + else + if @file._selected + @browser.trigger \selected @file + else + @browser.files.for-each (file) ~> + file._selected = false + @file._selected = true + @browser.trigger \change-selection @file + + @oncontextmenu = (e) ~> + e.prevent-default! + e.stop-immediate-propagation! + + @is-contextmenu-showing = true + @update! + ctx = document.body.append-child document.create-element \mk-drive-browser-file-contextmenu + ctx = riot.mount ctx, do + browser: @browser + file: @file + ctx = ctx.0 + ctx.open do + x: e.page-x - window.page-x-offset + y: e.page-y - window.page-y-offset + ctx.on \closed ~> + @is-contextmenu-showing = false + @update! + return false + + @ondragstart = (e) ~> + e.data-transfer.effect-allowed = \move + e.data-transfer.set-data 'text' JSON.stringify do + type: \file + id: @file.id + file: @file + @is-dragging = true + + # 親ブラウザに対して、ドラッグが開始されたフラグを立てる + # (=あなたの子供が、ドラッグを開始しましたよ) + @browser.is-drag-source = true + + @ondragend = (e) ~> + @is-dragging = false + @browser.is-drag-source = false diff --git a/src/web/app/desktop/tags/drive/folder-contextmenu.tag b/src/web/app/desktop/tags/drive/folder-contextmenu.tag new file mode 100644 index 0000000000..67fb1047b7 --- /dev/null +++ b/src/web/app/desktop/tags/drive/folder-contextmenu.tag @@ -0,0 +1,62 @@ +mk-drive-browser-folder-contextmenu + mk-contextmenu@ctx: ul + li(onclick={ parent.move }): p + i.fa.fa-arrow-right + | このフォルダへ移動 + li(onclick={ parent.new-window }): p + i.fa.fa-share-square-o + | 新しいウィンドウで表示 + li.separator + li(onclick={ parent.rename }): p + i.fa.fa-i-cursor + | 名前を変更 + li.separator + li(onclick={ parent.delete }): p + i.fa.fa-trash-o + | 削除 + +script. + @mixin \api + @mixin \input-dialog + + @browser = @opts.browser + @folder = @opts.folder + + @open = (pos) ~> + @refs.ctx.open pos + + @refs.ctx.on \closed ~> + @trigger \closed + @unmount! + + @move = ~> + @browser.move @folder.id + @refs.ctx.close! + + @new-window = ~> + @browser.new-window @folder.id + @refs.ctx.close! + + @create-folder = ~> + @browser.create-folder! + @refs.ctx.close! + + @upload = ~> + @browser.select-lcoal-file! + @refs.ctx.close! + + @rename = ~> + @refs.ctx.close! + + name <~ @input-dialog do + 'フォルダ名の変更' + '新しいフォルダ名を入力してください' + @folder.name + + @api \drive/folders/update do + folder_id: @folder.id + name: name + .then ~> + # something + .catch (err) ~> + console.error err diff --git a/src/web/app/desktop/tags/drive/folder.tag b/src/web/app/desktop/tags/drive/folder.tag new file mode 100644 index 0000000000..0f3b06d543 --- /dev/null +++ b/src/web/app/desktop/tags/drive/folder.tag @@ -0,0 +1,183 @@ +mk-drive-browser-folder(data-is-contextmenu-showing={ is-contextmenu-showing.toString() }, data-draghover={ draghover.toString() }, onclick={ onclick }, onmouseover={ onmouseover }, onmouseout={ onmouseout }, ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop }, oncontextmenu={ oncontextmenu }, draggable='true', ondragstart={ ondragstart }, ondragend={ ondragend }, title={ title }) + p.name + i.fa.fa-fw(class={ fa-folder-o: !hover, fa-folder-open-o: hover }) + | { folder.name } + +style. + display block + margin 4px + padding 8px + width 144px + height 64px + background lighten($theme-color, 95%) + border-radius 4px + + &, * + cursor pointer + + * + pointer-events none + + &:hover + background lighten($theme-color, 90%) + + &:active + background lighten($theme-color, 85%) + + &[data-is-contextmenu-showing='true'] + &[data-draghover='true'] + &:after + content "" + pointer-events none + position absolute + top -4px + right -4px + bottom -4px + left -4px + border 2px dashed rgba($theme-color, 0.3) + border-radius 4px + + &[data-draghover='true'] + background lighten($theme-color, 90%) + + > .name + margin 0 + font-size 0.9em + color darken($theme-color, 30%) + + > i + margin-right 4px + margin-left 2px + text-align left + +script. + @mixin \api + @mixin \dialog + + @folder = @opts.folder + @browser = @parent + + @title = @folder.name + @hover = false + @draghover = false + @is-contextmenu-showing = false + + @onclick = ~> + @browser.move @folder + + @onmouseover = ~> + @hover = true + + @onmouseout = ~> + @hover = false + + @ondragover = (e) ~> + e.prevent-default! + e.stop-propagation! + + # 自分自身がドラッグされていない場合 + if !@is-dragging + # ドラッグされてきたものがファイルだったら + if e.data-transfer.effect-allowed == \all + e.data-transfer.drop-effect = \copy + else + e.data-transfer.drop-effect = \move + else + # 自分自身にはドロップさせない + e.data-transfer.drop-effect = \none + return false + + @ondragenter = ~> + if !@is-dragging + @draghover = true + + @ondragleave = ~> + @draghover = false + + @ondrop = (e) ~> + e.stop-propagation! + @draghover = false + + # ファイルだったら + if e.data-transfer.files.length > 0 + Array.prototype.for-each.call e.data-transfer.files, (file) ~> + @browser.upload file, @folder + return false + + # データ取得 + data = e.data-transfer.get-data 'text' + if !data? + return false + + # パース + obj = JSON.parse data + + # (ドライブの)ファイルだったら + if obj.type == \file + file = obj.id + @browser.remove-file file + @api \drive/files/update do + file_id: file + folder_id: @folder.id + .then ~> + # something + .catch (err, text-status) ~> + console.error err + + # (ドライブの)フォルダーだったら + else if obj.type == \folder + folder = obj.id + # 移動先が自分自身ならreject + if folder == @folder.id + return false + @browser.remove-folder folder + @api \drive/folders/update do + folder_id: folder + parent_id: @folder.id + .then ~> + # something + .catch (err) ~> + if err == 'detected-circular-definition' + @dialog do + '操作を完了できません' + '移動先のフォルダーは、移動するフォルダーのサブフォルダーです。' + [ + text: \OK + ] + + return false + + @ondragstart = (e) ~> + e.data-transfer.effect-allowed = \move + e.data-transfer.set-data 'text' JSON.stringify do + type: \folder + id: @folder.id + @is-dragging = true + + # 親ブラウザに対して、ドラッグが開始されたフラグを立てる + # (=あなたの子供が、ドラッグを開始しましたよ) + @browser.is-drag-source = true + + @ondragend = (e) ~> + @is-dragging = false + @browser.is-drag-source = false + + @oncontextmenu = (e) ~> + e.prevent-default! + e.stop-immediate-propagation! + + @is-contextmenu-showing = true + @update! + ctx = document.body.append-child document.create-element \mk-drive-browser-folder-contextmenu + ctx = riot.mount ctx, do + browser: @browser + folder: @folder + ctx = ctx.0 + ctx.open do + x: e.page-x - window.page-x-offset + y: e.page-y - window.page-y-offset + ctx.on \closed ~> + @is-contextmenu-showing = false + @update! + + return false diff --git a/src/web/app/desktop/tags/drive/nav-folder.tag b/src/web/app/desktop/tags/drive/nav-folder.tag new file mode 100644 index 0000000000..398a26a80b --- /dev/null +++ b/src/web/app/desktop/tags/drive/nav-folder.tag @@ -0,0 +1,96 @@ +mk-drive-browser-nav-folder(data-draghover={ draghover }, onclick={ onclick }, ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop }) + i.fa.fa-cloud(if={ folder == null }) + span { folder == null ? 'ドライブ' : folder.name } + +style. + &[data-draghover] + background #eee + +script. + @mixin \api + + # Riotのバグでnullを渡しても""になる + # https://github.com/riot/riot/issues/2080 + #@folder = @opts.folder + @folder = if @opts.folder? and @opts.folder != '' then @opts.folder else null + @browser = @parent + + @hover = false + + @onclick = ~> + @browser.move @folder + + @onmouseover = ~> + @hover = true + + @onmouseout = ~> + @hover = false + + @ondragover = (e) ~> + e.prevent-default! + e.stop-propagation! + + # このフォルダがルートかつカレントディレクトリならドロップ禁止 + if @folder == null and @browser.folder == null + e.data-transfer.drop-effect = \none + # ドラッグされてきたものがファイルだったら + else if e.data-transfer.effect-allowed == \all + e.data-transfer.drop-effect = \copy + else + e.data-transfer.drop-effect = \move + return false + + @ondragenter = ~> + if @folder != null or @browser.folder != null + @draghover = true + + @ondragleave = ~> + if @folder != null or @browser.folder != null + @draghover = false + + @ondrop = (e) ~> + e.stop-propagation! + @draghover = false + + # ファイルだったら + if e.data-transfer.files.length > 0 + Array.prototype.for-each.call e.data-transfer.files, (file) ~> + @browser.upload file, @folder + return false + + # データ取得 + data = e.data-transfer.get-data 'text' + if !data? + return false + + # パース + obj = JSON.parse data + + # (ドライブの)ファイルだったら + if obj.type == \file + file = obj.id + @browser.remove-file file + @api \drive/files/update do + file_id: file + folder_id: if @folder? then @folder.id else null + .then ~> + # something + .catch (err, text-status) ~> + console.error err + + # (ドライブの)フォルダーだったら + else if obj.type == \folder + folder = obj.id + # 移動先が自分自身ならreject + if @folder? and folder == @folder.id + return false + @browser.remove-folder folder + @api \drive/folders/update do + folder_id: folder + parent_id: if @folder? then @folder.id else null + .then ~> + # something + .catch (err, text-status) ~> + console.error err + + return false diff --git a/src/web/app/desktop/tags/ellipsis-icon.tag b/src/web/app/desktop/tags/ellipsis-icon.tag new file mode 100644 index 0000000000..5d18bc0473 --- /dev/null +++ b/src/web/app/desktop/tags/ellipsis-icon.tag @@ -0,0 +1,34 @@ +mk-ellipsis-icon + div + div + div + +style. + display block + width 70px + margin 0 auto + text-align center + + > div + display inline-block + width 18px + height 18px + background-color rgba(0, 0, 0, 0.3) + border-radius 100% + animation bounce 1.4s infinite ease-in-out both + + &:nth-child(1) + animation-delay 0s + + &:nth-child(2) + margin 0 6px + animation-delay 0.16s + + &:nth-child(3) + animation-delay 0.32s + + @keyframes bounce + 0%, 80%, 100% + transform scale(0) + 40% + transform scale(1) diff --git a/src/web/app/desktop/tags/follow-button.tag b/src/web/app/desktop/tags/follow-button.tag new file mode 100644 index 0000000000..41bd1f0e12 --- /dev/null +++ b/src/web/app/desktop/tags/follow-button.tag @@ -0,0 +1,127 @@ +mk-follow-button + button(if={ !init }, class={ wait: wait, follow: !user.is_following, unfollow: user.is_following }, + onclick={ onclick }, + disabled={ wait }, + title={ user.is_following ? 'フォロー解除' : 'フォローする' }) + i.fa.fa-minus(if={ !wait && user.is_following }) + i.fa.fa-plus(if={ !wait && !user.is_following }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ wait }) + div.init(if={ init }): i.fa.fa-spinner.fa-pulse.fa-fw + +style. + display block + + > button + > .init + display block + cursor pointer + padding 0 + margin 0 + width 32px + height 32px + font-size 1em + outline none + border-radius 4px + + * + pointer-events none + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &.follow + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + &.unfollow + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + &.wait + cursor wait !important + opacity 0.7 + +script. + @mixin \api + @mixin \is-promise + @mixin \stream + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + @init = true + @wait = false + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @init = false + @update! + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + @on \unmount ~> + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + @on-stream-follow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @on-stream-unfollow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @onclick = ~> + @wait = true + if @user.is_following + @api \following/delete do + user_id: @user.id + .then ~> + @user.is_following = false + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! + else + @api \following/create do + user_id: @user.id + .then ~> + @user.is_following = true + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! diff --git a/src/web/app/desktop/tags/following-setuper.tag b/src/web/app/desktop/tags/following-setuper.tag new file mode 100644 index 0000000000..9b75a251e0 --- /dev/null +++ b/src/web/app/desktop/tags/following-setuper.tag @@ -0,0 +1,163 @@ +mk-following-setuper + p.title 気になるユーザーをフォロー: + div.users(if={ !loading && users.length > 0 }) + div.user(each={ users }) + a.avatar-anchor(href={ CONFIG.url + '/' + username }) + img.avatar(src={ avatar_url + '?thumbnail&size=42' }, alt='', data-user-preview={ id }) + div.body + a.name(href={ CONFIG.url + '/' + username }, target='_blank', data-user-preview={ id }) { name } + p.username @{ username } + mk-follow-button(user={ this }) + p.empty(if={ !loading && users.length == 0 }) + | おすすめのユーザーは見つかりませんでした。 + p.loading(if={ loading }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + a.refresh(onclick={ refresh }) もっと見る + button.close(onclick={ close }, title='閉じる'): i.fa.fa-times + +style. + display block + padding 24px + background #fff + + > .title + margin 0 0 12px 0 + font-size 1em + font-weight bold + color #888 + + > .users + &:after + content "" + display block + clear both + + > .user + padding 16px + width 238px + float left + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 42px + height 42px + margin 0 + border-radius 8px + vertical-align bottom + + > .body + float left + width calc(100% - 54px) + + > .name + margin 0 + font-size 16px + line-height 24px + color #555 + + > .username + margin 0 + font-size 15px + line-height 16px + color #ccc + + > mk-follow-button + position absolute + top 16px + right 16px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + > .refresh + display block + margin 0 8px 0 0 + text-align right + font-size 0.9em + color #999 + + > .close + cursor pointer + display block + position absolute + top 6px + right 6px + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + background transparent + + &:hover + color #555 + + &:active + color #222 + + > i + padding 14px + +script. + @mixin \api + @mixin \user-preview + + @users = null + @loading = true + + @limit = 6users + @page = 0 + + @on \mount ~> + @load! + + @load = ~> + @loading = true + @users = null + @update! + + @api \users/recommendation do + limit: @limit + offset: @limit * @page + .then (users) ~> + @loading = false + @users = users + @update! + .catch (err, text-status) -> + console.error err + + @refresh = ~> + if @users.length < @limit + @page = 0 + else + @page++ + @load! + + @close = ~> + @unmount! diff --git a/src/web/app/desktop/tags/go-top.tag b/src/web/app/desktop/tags/go-top.tag new file mode 100644 index 0000000000..a11f4a3640 --- /dev/null +++ b/src/web/app/desktop/tags/go-top.tag @@ -0,0 +1,15 @@ +mk-go-top + button.hidden(title='一番上へ') + i.fa.fa-angle-up + +script. + + window.add-event-listener \load @on-scroll + window.add-event-listener \scroll @on-scroll + window.add-event-listener \resize @on-scroll + + @on-scroll = ~> + if $ window .scroll-top! > 500px + @remove-class \hidden + else + @add-class \hidden diff --git a/src/web/app/desktop/tags/home-widgets/broadcast.tag b/src/web/app/desktop/tags/home-widgets/broadcast.tag new file mode 100644 index 0000000000..43f1251643 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/broadcast.tag @@ -0,0 +1,75 @@ +mk-broadcast-home-widget + div.icon + svg(height='32', version='1.1', viewBox='0 0 32 32', width='32') + path.tower(d='M16.04,11.24c1.79,0,3.239-1.45,3.239-3.24S17.83,4.76,16.04,4.76c-1.79,0-3.24,1.45-3.24,3.24 C12.78,9.78,14.24,11.24,16.04,11.24z M16.04,13.84c-0.82,0-1.66-0.2-2.4-0.6L7.34,29.98h2.98l1.72-2h8l1.681,2H24.7L18.42,13.24 C17.66,13.64,16.859,13.84,16.04,13.84z M16.02,14.8l2.02,7.2h-4L16.02,14.8z M12.04,25.98l2-2h4l2,2H12.04z') + path.wave.a(d='M4.66,1.04c-0.508-0.508-1.332-0.508-1.84,0c-1.86,1.92-2.8,4.44-2.8,6.94c0,2.52,0.94,5.04,2.8,6.96 c0.5,0.52,1.32,0.52,1.82,0s0.5-1.36,0-1.88C3.28,11.66,2.6,9.82,2.6,7.98S3.28,4.3,4.64,2.9C5.157,2.391,5.166,1.56,4.66,1.04z') + path.wave.b(d='M9.58,12.22c0.5-0.5,0.5-1.34,0-1.84C8.94,9.72,8.62,8.86,8.62,8s0.32-1.72,0.96-2.38c0.5-0.52,0.5-1.34,0-1.84 C9.346,3.534,9.02,3.396,8.68,3.4c-0.32,0-0.66,0.12-0.9,0.38C6.64,4.94,6.08,6.48,6.08,8s0.58,3.06,1.7,4.22 C8.28,12.72,9.1,12.72,9.58,12.22z') + path.wave.c(d='M22.42,3.78c-0.5,0.5-0.5,1.34,0,1.84c0.641,0.66,0.96,1.52,0.96,2.38s-0.319,1.72-0.96,2.38c-0.5,0.52-0.5,1.34,0,1.84 c0.487,0.497,1.285,0.505,1.781,0.018c0.007-0.006,0.013-0.012,0.02-0.018c1.139-1.16,1.699-2.7,1.699-4.22s-0.561-3.06-1.699-4.22 c-0.494-0.497-1.297-0.5-1.794-0.007C22.424,3.775,22.422,3.778,22.42,3.78z') + path.wave.d(d='M29.18,1.06c-0.479-0.502-1.273-0.522-1.775-0.044c-0.016,0.015-0.029,0.029-0.045,0.044c-0.5,0.52-0.5,1.36,0,1.88 c1.361,1.4,2.041,3.24,2.041,5.08s-0.68,3.66-2.041,5.08c-0.5,0.52-0.5,1.36,0,1.88c0.509,0.508,1.332,0.508,1.841,0 c1.86-1.92,2.8-4.44,2.8-6.96C31.99,5.424,30.98,2.931,29.18,1.06z') + + h1 開発者募集中! + p: a(href='https://github.com/syuilo/misskey', target='_blank') Misskeyはオープンソースで開発されています。Webのリポジトリはこちら + +style. + display block + padding 10px 10px 10px 50px + background transparent + border-color #4078c0 !important + + &:after + content "" + display block + clear both + + > .icon + display block + float left + margin-left -40px + + > svg + fill currentColor + color #4078c0 + + > .wave + opacity 1 + + &.a + animation wave 20s ease-in-out 2.1s infinite + &.b + animation wave 20s ease-in-out 2s infinite + &.c + animation wave 20s ease-in-out 2s infinite + &.d + animation wave 20s ease-in-out 2.1s infinite + + @keyframes wave + 0% + opacity 1 + 1.5% + opacity 0 + 3.5% + opacity 0 + 5% + opacity 1 + 6.5% + opacity 0 + 8.5% + opacity 0 + 10% + opacity 1 + + > h1 + margin 0 + font-size 0.95em + font-weight normal + color #4078c0 + + > p + display block + z-index 1 + margin 0 + font-size 0.7em + color #555 + + a + color #555 diff --git a/src/web/app/desktop/tags/home-widgets/calendar.tag b/src/web/app/desktop/tags/home-widgets/calendar.tag new file mode 100644 index 0000000000..26cea1c699 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/calendar.tag @@ -0,0 +1,147 @@ +mk-calendar-home-widget(data-special={ special }) + div.calendar(data-is-holiday={ is-holiday }) + p.month-and-year + span.year { year }年 + span.month { month }月 + p.day { day }日 + p.week-day { week-day }曜日 + div.info + div + p + | 今日: + b { day-p.to-fixed(1) }% + div.meter + div.val(style={ 'width:' + day-p + '%' }) + + div + p + | 今月: + b { month-p.to-fixed(1) }% + div.meter + div.val(style={ 'width:' + month-p + '%' }) + + div + p + | 今年: + b { year-p.to-fixed(1) }% + div.meter + div.val(style={ 'width:' + year-p + '%' }) + +style. + display block + padding 16px 0 + color #777 + background #fff + + &[data-special='on-new-years-day'] + border-color #ef95a0 !important + + &:after + content "" + display block + clear both + + > .calendar + float left + width 60% + text-align center + + &[data-is-holiday] + > .day + color #ef95a0 + + > p + margin 0 + line-height 18px + font-size 14px + + > span + margin 0 4px + + > .day + margin 10px 0 + line-height 32px + font-size 28px + + > .info + display block + float left + width 40% + padding 0 16px 0 0 + + > div + margin-bottom 8px + + &:last-child + margin-bottom 4px + + > p + margin 0 0 2px 0 + font-size 12px + line-height 18px + color #888 + + > b + margin-left 2px + + > .meter + width 100% + overflow hidden + background #eee + border-radius 8px + + > .val + height 4px + background $theme-color + + &:nth-child(1) + > .meter > .val + background #f7796c + + &:nth-child(2) + > .meter > .val + background #a1de41 + + &:nth-child(3) + > .meter > .val + background #41ddde + +script. + @draw = ~> + now = new Date! + nd = now.get-date! + nm = now.get-month! + ny = now.get-full-year! + + @year = ny + @month = nm + 1 + @day = nd + @week-day = [\日 \月 \火 \水 \木 \金 \土][now.get-day!] + + @day-numer = (now - (new Date ny, nm, nd)) + @day-denom = 1000ms * 60s * 60m * 24h + @month-numer = (now - (new Date ny, nm, 1)) + @month-denom = (new Date ny, nm + 1, 1) - (new Date ny, nm, 1) + @year-numer = (now - (new Date ny, 0, 0)) + @year-denom = (new Date ny + 1, 0, 0) - (new Date ny, 0, 0) + + @day-p = @day-numer / @day-denom * 100 + @month-p = @month-numer / @month-denom * 100 + @year-p = @year-numer / @year-denom * 100 + + @is-holiday = + (now.get-day! == 0 or now.get-day! == 6) + + @special = + | nm == 0 and nd == 1 => \on-new-years-day + | _ => false + + @update! + + @draw! + + @on \mount ~> + @clock = set-interval @draw, 1000ms + + @on \unmount ~> + clear-interval @clock diff --git a/src/web/app/desktop/tags/home-widgets/donation.tag b/src/web/app/desktop/tags/home-widgets/donation.tag new file mode 100644 index 0000000000..a41bd9f8a4 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/donation.tag @@ -0,0 +1,37 @@ +mk-donation-home-widget + article + h1 + i.fa.fa-heart + | 寄付のお願い + p + | Misskeyの運営にはドメイン、サーバー等のコストが掛かります。 + | Misskeyは広告を掲載したりしないため、 収入を皆様からの寄付に頼っています。 + | もしご興味があれば、 + a(href='/syuilo', data-user-preview='@syuilo') @syuilo + | までご連絡ください。ご協力ありがとうございます。 + +style. + display block + background #fff + border-color #ead8bb !important + + > article + padding 20px + + > h1 + margin 0 0 5px 0 + font-size 1em + color #888 + + > i + margin-right 0.25em + + > p + display block + z-index 1 + margin 0 + font-size 0.8em + color #999 + +script. + @mixin \user-preview diff --git a/src/web/app/desktop/tags/home-widgets/mentions.tag b/src/web/app/desktop/tags/home-widgets/mentions.tag new file mode 100644 index 0000000000..0f0cd0269d --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/mentions.tag @@ -0,0 +1,117 @@ +mk-mentions-home-widget + header + span(data-is-active={ mode == 'all' }, onclick={ set-mode.bind(this, 'all') }) すべて + span(data-is-active={ mode == 'following' }, onclick={ set-mode.bind(this, 'following') }) フォロー中 + div.loading(if={ is-loading }) + mk-ellipsis-icon + p.empty(if={ is-empty }) + i.fa.fa-comments-o + span(if={ mode == 'all' }) あなた宛ての投稿はありません。 + span(if={ mode == 'following' }) あなたがフォローしているユーザーからの言及はありません。 + mk-timeline@timeline + + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + + +style. + display block + background #fff + + > header + padding 8px 16px + border-bottom solid 1px #eee + + > span + margin-right 16px + line-height 27px + font-size 18px + color #555 + + &:not([data-is-active]) + color $theme-color + cursor pointer + + &:hover + text-decoration underline + + > .loading + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + +script. + @mixin \i + @mixin \api + + @is-loading = true + @is-empty = false + @more-loading = false + @mode = \all + + @on \mount ~> + document.add-event-listener \keydown @on-document-keydown + window.add-event-listener \scroll @on-scroll + + @fetch ~> + @trigger \loaded + + @on \unmount ~> + document.remove-event-listener \keydown @on-document-keydown + window.remove-event-listener \scroll @on-scroll + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 84 # t + @refs.timeline.focus! + + @fetch = (cb) ~> + @api \posts/mentions do + following: @mode == \following + .then (posts) ~> + @is-loading = false + @is-empty = posts.length == 0 + @update! + @refs.timeline.set-posts posts + if cb? then cb! + .catch (err) ~> + console.error err + if cb? then cb! + + @more = ~> + if @more-loading or @is-loading or @refs.timeline.posts.length == 0 + return + @more-loading = true + @update! + @api \posts/mentions do + following: @mode == \following + max_id: @refs.timeline.tail!.id + .then (posts) ~> + @more-loading = false + @update! + @refs.timeline.prepend-posts posts + .catch (err) ~> + console.error err + + @on-scroll = ~> + current = window.scroll-y + window.inner-height + if current > document.body.offset-height - 8 + @more! + + @set-mode = (mode) ~> + @update do + mode: mode + @fetch! diff --git a/src/web/app/desktop/tags/home-widgets/nav.tag b/src/web/app/desktop/tags/home-widgets/nav.tag new file mode 100644 index 0000000000..5b8e7e1b3c --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/nav.tag @@ -0,0 +1,23 @@ +mk-nav-home-widget + a(href={ CONFIG.urls.about }) Misskeyについて + i ・ + a(href={ CONFIG.urls.about + '/status' }) ステータス + i ・ + a(href='https://github.com/syuilo/misskey') リポジトリ + i ・ + a(href={ CONFIG.urls.dev }) 開発者 + i ・ + a(href='https://twitter.com/misskey_xyz', target='_blank') Follow us on + +style. + display block + padding 16px + font-size 12px + color #aaa + background #fff + + a + color #999 + + i + color #ccc diff --git a/src/web/app/desktop/tags/home-widgets/notifications.tag b/src/web/app/desktop/tags/home-widgets/notifications.tag new file mode 100644 index 0000000000..588a765d02 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/notifications.tag @@ -0,0 +1,49 @@ +mk-notifications-home-widget + p.title + i.fa.fa-bell-o + | 通知 + button(onclick={ settings }, title='通知の設定'): i.fa.fa-cog + mk-notifications + +style. + display block + background #fff + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > mk-notifications + max-height 300px + overflow auto + +script. + @settings = ~> + w = riot.mount document.body.append-child document.create-element \mk-settings-window .0 + w.switch \notification diff --git a/src/web/app/desktop/tags/home-widgets/photo-stream.tag b/src/web/app/desktop/tags/home-widgets/photo-stream.tag new file mode 100644 index 0000000000..b972706574 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/photo-stream.tag @@ -0,0 +1,86 @@ +mk-photo-stream-home-widget + p.title + i.fa.fa-camera + | フォトストリーム + p.initializing(if={ initializing }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + div.stream(if={ !initializing && images.length > 0 }) + virtual(each={ image in images }) + div.img(style={ 'background-image: url(' + image.url + '?thumbnail&size=256)' }) + p.empty(if={ !initializing && images.length == 0 }) + | 写真はありません + +style. + display block + background #fff + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > .stream + display -webkit-flex + display -moz-flex + display -ms-flex + display flex + justify-content center + flex-wrap wrap + padding 8px + + > .img + flex 1 1 33% + width 33% + height 80px + background-position center center + background-size cover + background-clip content-box + border solid 2px transparent + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \stream + + @images = [] + @initializing = true + + @on \mount ~> + @stream.on \drive_file_created @on-stream-drive-file-created + + @api \drive/stream do + type: 'image/*' + limit: 9images + .then (images) ~> + @initializing = false + @images = images + @update! + + @on \unmount ~> + @stream.off \drive_file_created @on-stream-drive-file-created + + @on-stream-drive-file-created = (file) ~> + if /^image\/.+$/.test file.type + @images.unshift file + if @images.length > 9 + @images.pop! + @update! diff --git a/src/web/app/desktop/tags/home-widgets/profile.tag b/src/web/app/desktop/tags/home-widgets/profile.tag new file mode 100644 index 0000000000..ae8d43c645 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/profile.tag @@ -0,0 +1,55 @@ +mk-profile-home-widget + div.banner(style={ I.banner_url ? 'background-image: url(' + I.banner_url + '?thumbnail&size=256)' : '' }, onclick={ set-banner }) + img.avatar(src={ I.avatar_url + '?thumbnail&size=64' }, onclick={ set-avatar }, alt='avatar', data-user-preview={ I.id }) + a.name(href={ CONFIG.url + '/' + I.username }) { I.name } + p.username @{ I.username } + +style. + display block + background #fff + + > .banner + height 100px + background-color #f5f5f5 + background-size cover + background-position center + + > .avatar + display block + position absolute + top 76px + left 16px + width 58px + height 58px + margin 0 + border solid 3px #fff + border-radius 8px + vertical-align bottom + + > .name + display block + margin 10px 0 0 92px + line-height 16px + font-weight bold + color #555 + + > .username + display block + margin 4px 0 8px 92px + line-height 16px + font-size 0.9em + color #999 + +script. + @mixin \i + @mixin \user-preview + @mixin \update-avatar + @mixin \update-banner + + @set-avatar = ~> + @update-avatar @I, (i) ~> + @update-i i + + @set-banner = ~> + @update-banner @I, (i) ~> + @update-i i diff --git a/src/web/app/desktop/tags/home-widgets/rss-reader.tag b/src/web/app/desktop/tags/home-widgets/rss-reader.tag new file mode 100644 index 0000000000..b9095fb2d2 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/rss-reader.tag @@ -0,0 +1,94 @@ +mk-rss-reader-home-widget + p.title + i.fa.fa-rss-square + | RSS + button(onclick={ settings }, title='設定'): i.fa.fa-cog + div.feed(if={ !initializing }) + virtual(each={ item in items }) + a(href={ item.link }, target='_blank') { item.title } + p.initializing(if={ initializing }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + background #fff + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > button + position absolute + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .feed + padding 12px 16px + font-size 0.9em + + > a + display block + padding 4px 0 + color #666 + border-bottom dashed 1px #eee + + &:last-child + border-bottom none + + > .initializing + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \NotImplementedException + + @url = 'http://news.yahoo.co.jp/pickup/rss.xml' + @items = [] + @initializing = true + + @on \mount ~> + @fetch! + @clock = set-interval @fetch, 60000ms + + @on \unmount ~> + clear-interval @clock + + @fetch = ~> + @api CONFIG.url + '/api:rss' do + url: @url + .then (feed) ~> + @items = feed.rss.channel.item + @initializing = false + @update! + .catch (err) -> + console.error err + + @settings = ~> + @NotImplementedException! diff --git a/src/web/app/desktop/tags/home-widgets/timeline.tag b/src/web/app/desktop/tags/home-widgets/timeline.tag new file mode 100644 index 0000000000..ea2746d1ed --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/timeline.tag @@ -0,0 +1,113 @@ +mk-timeline-home-widget + mk-following-setuper(if={ no-following }) + div.loading(if={ is-loading }) + mk-ellipsis-icon + p.empty(if={ is-empty }) + i.fa.fa-comments-o + | 自分の投稿や、自分がフォローしているユーザーの投稿が表示されます。 + mk-timeline@timeline + + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + + +style. + display block + background #fff + + > mk-following-setuper + border-bottom solid 1px #eee + + > .loading + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + +script. + @mixin \i + @mixin \api + @mixin \stream + + @is-loading = true + @is-empty = false + @more-loading = false + @no-following = @I.following_count == 0 + + @on \mount ~> + @stream.on \post @on-stream-post + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + document.add-event-listener \keydown @on-document-keydown + window.add-event-listener \scroll @on-scroll + + @load ~> + @trigger \loaded + + @on \unmount ~> + @stream.off \post @on-stream-post + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + document.remove-event-listener \keydown @on-document-keydown + window.remove-event-listener \scroll @on-scroll + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 84 # t + @refs.timeline.focus! + + @load = (cb) ~> + @api \posts/timeline + .then (posts) ~> + @is-loading = false + @is-empty = posts.length == 0 + @update! + @refs.timeline.set-posts posts + if cb? then cb! + .catch (err) ~> + console.error err + if cb? then cb! + + @more = ~> + if @more-loading or @is-loading or @refs.timeline.posts.length == 0 + return + @more-loading = true + @update! + @api \posts/timeline do + max_id: @refs.timeline.tail!.id + .then (posts) ~> + @more-loading = false + @update! + @refs.timeline.prepend-posts posts + .catch (err) ~> + console.error err + + @on-stream-post = (post) ~> + @is-empty = false + @update! + @refs.timeline.add-post post + + @on-stream-follow = ~> + @load! + + @on-stream-unfollow = ~> + @load! + + @on-scroll = ~> + current = window.scroll-y + window.inner-height + if current > document.body.offset-height - 8 + @more! diff --git a/src/web/app/desktop/tags/home-widgets/tips.tag b/src/web/app/desktop/tags/home-widgets/tips.tag new file mode 100644 index 0000000000..9c1aa33ec0 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/tips.tag @@ -0,0 +1,70 @@ +mk-tips-home-widget + p@tip + i.fa.fa-lightbulb-o + span@text + +style. + display block + background transparent !important + border none !important + overflow visible !important + + > p + display block + margin 0 + padding 0 12px + text-align center + font-size 0.7em + color #999 + + > i + margin-right 4px + + kbd + display inline + padding 0 6px + margin 0 2px + font-size 1em + font-family inherit + border solid 1px #999 + border-radius 2px + +script. + @tips = [ + 'tでタイムラインにフォーカスできます' + 'pまたはnで投稿フォームを開きます' + '投稿フォームにはファイルをドラッグ&ドロップできます' + '投稿フォームにクリップボードにある画像データをペーストできます' + 'ドライブにファイルをドラッグ&ドロップしてアップロードできます' + 'ドライブでファイルをドラッグしてフォルダ移動できます' + 'ドライブでフォルダをドラッグしてフォルダ移動できます' + 'ホームをカスタマイズできます(準備中)' + 'MisskeyはMIT Licenseです' + ] + + @on \mount ~> + @set! + @clock = set-interval @change, 20000ms + + @on \unmount ~> + clear-interval @clock + + @set = ~> + @refs.text.innerHTML = @tips[Math.floor Math.random! * @tips.length] + @update! + + @change = ~> + Velocity @refs.tip, { + opacity: 0 + } { + duration: 500ms + easing: \linear + complete: @set + } + + Velocity @refs.tip, { + opacity: 1 + } { + duration: 500ms + easing: \linear + } diff --git a/src/web/app/desktop/tags/home-widgets/user-recommendation.tag b/src/web/app/desktop/tags/home-widgets/user-recommendation.tag new file mode 100644 index 0000000000..bfb90da065 --- /dev/null +++ b/src/web/app/desktop/tags/home-widgets/user-recommendation.tag @@ -0,0 +1,154 @@ +mk-user-recommendation-home-widget + p.title + i.fa.fa-users + | おすすめユーザー + button(onclick={ refresh }, title='他を見る'): i.fa.fa-refresh + div.user(if={ !loading && users.length != 0 }, each={ _user in users }) + a.avatar-anchor(href={ CONFIG.url + '/' + _user.username }) + img.avatar(src={ _user.avatar_url + '?thumbnail&size=42' }, alt='', data-user-preview={ _user.id }) + div.body + a.name(href={ CONFIG.url + '/' + _user.username }, data-user-preview={ _user.id }) { _user.name } + p.username @{ _user.username } + mk-follow-button(user={ _user }) + p.empty(if={ !loading && users.length == 0 }) + | いません! + p.loading(if={ loading }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + background #fff + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + border-bottom solid 1px #eee + + > i + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + right 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + > .user + padding 16px + border-bottom solid 1px #eee + + &:last-child + border-bottom none + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 42px + height 42px + margin 0 + border-radius 8px + vertical-align bottom + + > .body + float left + width calc(100% - 54px) + + > .name + margin 0 + font-size 16px + line-height 24px + color #555 + + > .username + display block + margin 0 + font-size 15px + line-height 16px + color #ccc + + > mk-follow-button + position absolute + top 16px + right 16px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \user-preview + + @users = null + @loading = true + + @limit = 3users + @page = 0 + + @on \mount ~> + @fetch! + @clock = set-interval ~> + if @users.length < @limit + @fetch true + , 60000ms + + @on \unmount ~> + clear-interval @clock + + @fetch = (quiet = false) ~> + @loading = true + @users = null + if not quiet then @update! + @api \users/recommendation do + limit: @limit + offset: @limit * @page + .then (users) ~> + @loading = false + @users = users + @update! + .catch (err, text-status) -> + console.error err + + @refresh = ~> + if @users.length < @limit + @page = 0 + else + @page++ + @fetch! diff --git a/src/web/app/desktop/tags/home.tag b/src/web/app/desktop/tags/home.tag new file mode 100644 index 0000000000..1ae856a6b2 --- /dev/null +++ b/src/web/app/desktop/tags/home.tag @@ -0,0 +1,86 @@ +mk-home + div.main + div.left@left + main + mk-timeline-home-widget@tl(if={ mode == 'timeline' }) + mk-mentions-home-widget@tl(if={ mode == 'mentions' }) + div.right@right + mk-detect-slow-internet-connection-notice + +style. + display block + + > .main + margin 0 auto + max-width 1200px + + &:after + content "" + display block + clear both + + > * + float left + + > * + display block + //border solid 1px #eaeaea + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + + &:not(:last-child) + margin-bottom 16px + + > main + padding 16px + width calc(100% - 275px * 2) + + > *:not(main) + width 275px + + > .left + padding 16px 0 16px 16px + + > .right + padding 16px 16px 16px 0 + + @media (max-width 1100px) + > *:not(main) + display none + + > main + float none + width 100% + max-width 700px + margin 0 auto + +script. + @mixin \i + @mode = @opts.mode || \timeline + + # https://github.com/riot/riot/issues/2080 + if @mode == '' then @mode = \timeline + + @home = [] + + @on \mount ~> + @refs.tl.on \loaded ~> + @trigger \loaded + + @I.data.home.for-each (widget) ~> + try + el = document.create-element \mk- + widget.name + \-home-widget + switch widget.place + | \left => @refs.left.append-child el + | \right => @refs.right.append-child el + @home.push (riot.mount el, do + id: widget.id + data: widget.data + .0) + catch e + # noop + + @on \unmount ~> + @home.for-each (widget) ~> + widget.unmount! diff --git a/src/web/app/desktop/tags/image-dialog.tag b/src/web/app/desktop/tags/image-dialog.tag new file mode 100644 index 0000000000..6a3885d7c7 --- /dev/null +++ b/src/web/app/desktop/tags/image-dialog.tag @@ -0,0 +1,73 @@ +mk-image-dialog + div.bg@bg(onclick={ close }) + img@img(src={ image.url }, alt={ image.name }, title={ image.name }, onclick={ close }) + +style. + display block + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + opacity 0 + + > .bg + display block + position fixed + z-index 1 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + + > img + position fixed + z-index 2 + top 0 + right 0 + bottom 0 + left 0 + max-width 100% + max-height 100% + margin auto + cursor zoom-out + +script. + @image = @opts.image + + @on \mount ~> + Velocity @root, { + opacity: 1 + } { + duration: 100ms + easing: \linear + } + + #Velocity @img, { + # scale: 1 + # opacity: 1 + #} { + # duration: 200ms + # easing: \ease-out + #} + + @close = ~> + Velocity @root, { + opacity: 0 + } { + duration: 100ms + easing: \linear + complete: ~> @unmount! + } + + #Velocity @img, { + # scale: 0.9 + # opacity: 0 + #} { + # duration: 200ms + # easing: \ease-in + # complete: ~> + # @unmount! + #} diff --git a/src/web/app/desktop/tags/images-viewer.tag b/src/web/app/desktop/tags/images-viewer.tag new file mode 100644 index 0000000000..a9939d67c4 --- /dev/null +++ b/src/web/app/desktop/tags/images-viewer.tag @@ -0,0 +1,43 @@ +mk-images-viewer + div.image@view(onmousemove={ mousemove }, style={ 'background-image: url(' + image.url + '?thumbnail' }, onclick={ click }) + img(src={ image.url + '?thumbnail&size=512' }, alt={ image.name }, title={ image.name }) + +style. + display block + padding 8px + overflow hidden + box-shadow 0 0 4px rgba(0, 0, 0, 0.2) + border-radius 4px + + > .image + cursor zoom-in + + > img + display block + max-height 256px + max-width 100% + margin 0 auto + + &:hover + > img + visibility hidden + + &:not(:hover) + background-image none !important + +script. + @images = @opts.images + @image = @images.0 + + @mousemove = (e) ~> + rect = @refs.view.get-bounding-client-rect! + mouse-x = e.client-x - rect.left + mouse-y = e.client-y - rect.top + xp = mouse-x / @refs.view.offset-width * 100 + yp = mouse-y / @refs.view.offset-height * 100 + @refs.view.style.background-position = xp + '% ' + yp + '%' + + @click = ~> + dialog = document.body.append-child document.create-element \mk-image-dialog + riot.mount dialog, do + image: @image diff --git a/src/web/app/desktop/tags/input-dialog.tag b/src/web/app/desktop/tags/input-dialog.tag new file mode 100644 index 0000000000..62ec4f5177 --- /dev/null +++ b/src/web/app/desktop/tags/input-dialog.tag @@ -0,0 +1,156 @@ +mk-input-dialog + mk-window@window(is-modal={ true }, width={ '500px' }) + + i.fa.fa-i-cursor + | { parent.title } + + + div.body + input@text(oninput={ parent.update }, onkeydown={ parent.on-keydown }, placeholder={ parent.placeholder }) + div.action + button.cancel(onclick={ parent.cancel }) キャンセル + button.ok(disabled={ !parent.allow-empty && refs.text.value.length == 0 }, onclick={ parent.ok }) 決定 + + +style. + display block + + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + > .body + padding 16px + + > input + display block + padding 8px + margin 0 + width 100% + max-width 100% + min-width 100% + font-size 1em + color #333 + background #fff + outline none + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + transition border-color .3s ease + + &:hover + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + color $theme-color + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + &::-webkit-input-placeholder + color rgba($theme-color, 0.3) + + > .action + height 72px + background lighten($theme-color, 95%) + + .ok + .cancel + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + .ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + +script. + @done = false + + @title = @opts.title + @placeholder = @opts.placeholder + @default = @opts.default + @allow-empty = if @opts.allow-empty? then @opts.allow-empty else true + + @on \mount ~> + @text = @refs.window.refs.text + if @default? + @text.value = @default + @text.focus! + + @refs.window.on \closing ~> + if @done + @opts.on-ok @text.value + else + if @opts.on-cancel? + @opts.on-cancel! + + @refs.window.on \closed ~> + @unmount! + + @cancel = ~> + @done = false + @refs.window.close! + + @ok = ~> + if not @allow-empty and @text.value == '' then return + @done = true + @refs.window.close! + + @on-keydown = (e) ~> + if e.which == 13 # Enter + e.prevent-default! + e.stop-propagation! + @ok! diff --git a/src/web/app/desktop/tags/list-user.tag b/src/web/app/desktop/tags/list-user.tag new file mode 100644 index 0000000000..1058de22e0 --- /dev/null +++ b/src/web/app/desktop/tags/list-user.tag @@ -0,0 +1,100 @@ +mk-list-user + a.avatar-anchor(href={ CONFIG.url + '/' + user.username }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.main + header + div.left + a.name(href={ CONFIG.url + '/' + user.username }) + | { user.name } + span.username + | @{ user.username } + div.body + p.followed(if={ user.is_followed }) フォローされています + div.bio { user.bio } + mk-follow-button(user={ user }) + +style. + display block + margin 0 + padding 16px + font-size 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 58px + height 58px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 74px) + + > header + margin-bottom 2px + white-space nowrap + + &:after + content "" + display block + clear both + + > .left + float left + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .body + > .followed + display inline-block + margin 0 0 4px 0 + padding 2px 8px + vertical-align top + font-size 10px + color #71afc7 + background #eefaff + border-radius 4px + + > .bio + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.1em + color #717171 + + > mk-follow-button + position absolute + top 16px + right 16px + +script. + @user = @opts.user diff --git a/src/web/app/desktop/tags/log-window.tag b/src/web/app/desktop/tags/log-window.tag new file mode 100644 index 0000000000..6dabc4de34 --- /dev/null +++ b/src/web/app/desktop/tags/log-window.tag @@ -0,0 +1,20 @@ +mk-log-window + mk-window@window(width={ '600px' }, height={ '400px' }) + + i.fa.fa-terminal + | Log + + + mk-log + + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + +script. + @on \mount ~> + @refs.window.on \closed ~> + @unmount! diff --git a/src/web/app/desktop/tags/log.tag b/src/web/app/desktop/tags/log.tag new file mode 100644 index 0000000000..20e5f8f699 --- /dev/null +++ b/src/web/app/desktop/tags/log.tag @@ -0,0 +1,62 @@ +mk-log + header + button.follow(class={ following: following }, onclick={ follow }) Follow + div.logs@logs + code(each={ logs }) + span.date { date.getHours() + ':' + date.getMinutes() + ':' + date.getSeconds() } + span.message { message } + +style. + display block + height 100% + color #fff + background #000 + + > header + height 32px + background #343a42 + + > button + line-height 32px + + > .follow + position absolute + top 0 + right 0 + + &.following + color #ff0 + + > .logs + height calc(100% - 32px) + overflow auto + + > code + display block + padding 4px 8px + + &:hover + background rgba(#fff, 0.15) + + > .date + margin-right 8px + opacity 0.5 + +script. + @mixin \log + + @following = true + + @on \mount ~> + @log-event.on \log @on-log + + @on \unmount ~> + @log-event.off \log @on-log + + @follow = ~> + @following = true + + @on-log = ~> + @update! + if @following + @refs.logs.scroll-top = @refs.logs.scroll-height diff --git a/src/web/app/desktop/tags/messaging/form.tag b/src/web/app/desktop/tags/messaging/form.tag new file mode 100644 index 0000000000..12eb0cb40f --- /dev/null +++ b/src/web/app/desktop/tags/messaging/form.tag @@ -0,0 +1,162 @@ +mk-messaging-form + textarea@text(onkeypress={ onkeypress }, onpaste={ onpaste }, placeholder='ここにメッセージを入力') + div.files + mk-uploader@uploader + button.send(onclick={ send }, disabled={ sending }, title='メッセージを送信') + i.fa.fa-paper-plane(if={ !sending }) + i.fa.fa-spinner.fa-spin(if={ sending }) + button.attach-from-local(type='button', title='PCから画像を添付する') + i.fa.fa-upload + button.attach-from-drive(type='button', title='アルバムから画像を添付する') + i.fa.fa-folder-open + input(name='file', type='file', accept='image/*') + +style. + display block + + > textarea + cursor auto + display block + width 100% + min-width 100% + max-width 100% + height 64px + margin 0 + padding 8px + font-size 1em + color #000 + outline none + border none + border-top solid 1px #eee + border-radius 0 + box-shadow none + background transparent + + > .send + position absolute + bottom 0 + right 0 + margin 0 + padding 10px 14px + line-height 1em + font-size 1em + color #aaa + transition color 0.1s ease + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + + .files + display block + margin 0 + padding 0 8px + list-style none + + &:after + content '' + display block + clear both + + > li + display block + float left + margin 4px + padding 0 + width 64px + height 64px + background-color #eee + background-repeat no-repeat + background-position center center + background-size cover + cursor move + + &:hover + > .remove + display block + + > .remove + display none + position absolute + right -6px + top -6px + margin 0 + padding 0 + background transparent + outline none + border none + border-radius 0 + box-shadow none + cursor pointer + + .attach-from-local + .attach-from-drive + margin 0 + padding 10px 14px + line-height 1em + font-size 1em + font-weight normal + text-decoration none + color #aaa + transition color 0.1s ease + + &:hover + color $theme-color + + &:active + color darken($theme-color, 10%) + transition color 0s ease + + input[type=file] + display none + +script. + @mixin \api + + @user = @opts.user + + @onpaste = (e) ~> + data = e.clipboard-data + items = data.items + for i from 0 to items.length - 1 + item = items[i] + switch (item.kind) + | \file => + @upload item.get-as-file! + + @onkeypress = (e) ~> + if (e.which == 10 || e.which == 13) && e.ctrl-key + @send! + + @select-file = ~> + @refs.file.click! + + @select-file-from-drive = ~> + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + event = riot.observable! + riot.mount browser, do + multiple: true + event: event + event.one \selected (files) ~> + files.for-each @add-file + + @send = ~> + @sending = true + @api \messaging/messages/create do + user_id: @user.id + text: @refs.text.value + .then (message) ~> + @clear! + .catch (err) ~> + console.error err + .then ~> + @sending = false + @update! + + @clear = ~> + @refs.text.value = '' + @files = [] + @update! diff --git a/src/web/app/desktop/tags/messaging/index.tag b/src/web/app/desktop/tags/messaging/index.tag new file mode 100644 index 0000000000..9f57500b83 --- /dev/null +++ b/src/web/app/desktop/tags/messaging/index.tag @@ -0,0 +1,302 @@ +mk-messaging + div.search + div.form + label(for='search-input') + i.fa.fa-search + input@search-input(type='search', oninput={ search }, placeholder='ユーザーを探す') + div.result + ol.users(if={ search-result.length > 0 }) + li(each={ user in search-result }) + a(onclick={ user._click }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=32' }, alt='') + span.name { user.name } + span.username @{ user.username } + div.main + div.history(if={ history.length > 0 }) + virtual(each={ history }) + a.user(data-is-me={ is_me }, data-is-read={ is_read }, onclick={ _click }): div + img.avatar(src={ (is_me ? recipient.avatar_url : user.avatar_url) + '?thumbnail&size=64' }, alt='') + header + span.name { is_me ? recipient.name : user.name } + span.username { '@' + (is_me ? recipient.username : user.username ) } + mk-time(time={ created_at }) + div.body + p.text + span.me(if={ is_me }) あなた: + | { text } + p.no-history(if={ history.length == 0 }) + | 履歴はありません。 + br + | ユーザーを検索して、いつでもメッセージを送受信できます。 + +style. + display block + + > .search + display block + position absolute + top 0 + left 0 + z-index 1 + width 100% + background #fff + box-shadow 0 0px 2px rgba(0, 0, 0, 0.2) + + > .form + padding 8px + background #f7f7f7 + + > label + display block + position absolute + top 0 + left 8px + z-index 1 + height 100% + width 38px + pointer-events none + + > i + display block + position absolute + top 0 + right 0 + bottom 0 + left 0 + width 1em + height 1em + margin auto + color #555 + + > input + margin 0 + padding 0 12px 0 38px + width 100% + font-size 1em + line-height 38px + color #000 + outline none + border solid 1px #eee + border-radius 5px + box-shadow none + transition color 0.5s ease, border 0.5s ease + + &:hover + border solid 1px #ddd + transition border 0.2s ease + + &:focus + color darken($theme-color, 20%) + border solid 1px $theme-color + transition color 0, border 0 + + > .result + display block + top 0 + left 0 + z-index 2 + width 100% + margin 0 + padding 0 + background #fff + + > .users + margin 0 + padding 0 + list-style none + + > li + > a + display inline-block + z-index 1 + width 100% + padding 8px 32px + vertical-align top + white-space nowrap + overflow hidden + color rgba(0, 0, 0, 0.8) + text-decoration none + transition none + + &:hover + color #fff + background $theme-color + + .name + color #fff + + .username + color #fff + + &:active + color #fff + background darken($theme-color, 10%) + + .name + color #fff + + .username + color #fff + + .avatar + vertical-align middle + min-width 32px + min-height 32px + max-width 32px + max-height 32px + margin 0 8px 0 0 + border-radius 6px + + .name + margin 0 8px 0 0 + /*font-weight bold*/ + font-weight normal + color rgba(0, 0, 0, 0.8) + + .username + font-weight normal + color rgba(0, 0, 0, 0.3) + + > .main + padding-top 56px + + > .history + + > a + display block + padding 20px 30px + text-decoration none + background #fff + border-bottom solid 1px #eee + + * + pointer-events none + user-select none + + &:hover + background #fafafa + + > .avatar + filter saturate(200%) + + &:active + background #eee + + &[data-is-read] + &[data-is-me] + opacity 0.8 + + &:not([data-is-me]):not([data-is-read]) + background-image url("/_/resources/desktop/unread.svg") + background-repeat no-repeat + background-position 0 center + + &:after + content "" + display block + clear both + + > div + max-width 500px + margin 0 auto + + > header + margin-bottom 2px + white-space nowrap + overflow hidden + + > .name + text-align left + display inline + margin 0 + padding 0 + font-size 1em + color rgba(0, 0, 0, 0.9) + font-weight bold + transition all 0.1s ease + + > .username + text-align left + margin 0 0 0 8px + color rgba(0, 0, 0, 0.5) + + > mk-time + position absolute + top 0 + right 0 + display inline + color rgba(0, 0, 0, 0.5) + font-size small + + > .avatar + float left + width 54px + height 54px + margin 0 16px 0 0 + border-radius 8px + transition all 0.1s ease + + > .body + + > .text + display block + margin 0 0 0 0 + padding 0 + overflow hidden + word-wrap break-word + font-size 1.1em + color rgba(0, 0, 0, 0.8) + + .me + color rgba(0, 0, 0, 0.4) + + > .image + display block + max-width 100% + max-height 512px + + > .no-history + margin 0 + padding 2em 1em + text-align center + color #999 + font-weight 500 + +script. + @mixin \i + @mixin \api + + @search-result = [] + + @on \mount ~> + @api \messaging/history + .then (history) ~> + @is-loading = false + history.for-each (message) ~> + message.is_me = message.user_id == @I.id + message._click = ~> + if message.is_me + @trigger \navigate-user message.recipient + else + @trigger \navigate-user message.user + @history = history + @update! + .catch (err) ~> + console.error err + + @search = ~> + q = @refs.search-input.value + if q == '' + @search-result = [] + else + @api \users/search do + query: q + .then (users) ~> + users.for-each (user) ~> + user._click = ~> + @trigger \navigate-user user + @search-result = [] + @search-result = users + @update! + .catch (err) ~> + console.error err diff --git a/src/web/app/desktop/tags/messaging/message.tag b/src/web/app/desktop/tags/messaging/message.tag new file mode 100644 index 0000000000..d7a2cc32a6 --- /dev/null +++ b/src/web/app/desktop/tags/messaging/message.tag @@ -0,0 +1,227 @@ +mk-messaging-message(data-is-me={ message.is_me }) + a.avatar-anchor(href={ CONFIG.url + '/' + message.user.username }, title={ message.user.username }, target='_blank') + img.avatar(src={ message.user.avatar_url + '?thumbnail&size=64' }, alt='') + div.content-container + div.balloon + p.read(if={ message.is_me && message.is_read }) 既読 + button.delete-button(if={ message.is_me }, title='メッセージを削除') + img(src='/_/resources/desktop/messaging/delete.png', alt='Delete') + div.content(if={ !message.is_deleted }) + div@text + div.image(if={ message.file }) + img(src={ message.file.url }, alt='image', title={ message.file.name }) + div.content(if={ message.is_deleted }) + p.is-deleted このメッセージは削除されました + footer + mk-time(time={ message.created_at }) + i.fa.fa-pencil.is-edited(if={ message.is_edited }) + +style. + $me-balloon-color = #23A7B6 + + display block + padding 10px 12px 10px 12px + background-color transparent + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + + > .avatar + display block + min-width 54px + min-height 54px + max-width 54px + max-height 54px + margin 0 + border-radius 8px + transition all 0.1s ease + + > .content-container + display block + margin 0 12px + padding 0 + max-width calc(100% - 78px) + + > .balloon + display block + float inherit + margin 0 + padding 0 + max-width 100% + min-height 38px + border-radius 16px + + &:before + content "" + pointer-events none + display block + position absolute + top 12px + + &:hover + > .delete-button + display block + + > .delete-button + display none + position absolute + z-index 1 + top -4px + right -4px + margin 0 + padding 0 + cursor pointer + outline none + border none + border-radius 0 + box-shadow none + background transparent + + > img + vertical-align bottom + width 16px + height 16px + cursor pointer + + > .read + user-select none + display block + position absolute + z-index 1 + bottom -4px + left -12px + margin 0 + color rgba(0, 0, 0, 0.5) + font-size 11px + + > .content + + > .is-deleted + display block + margin 0 + padding 0 + overflow hidden + word-wrap break-word + font-size 1em + color rgba(0, 0, 0, 0.5) + + > [ref='text'] + display block + margin 0 + padding 8px 16px + overflow hidden + word-wrap break-word + font-size 1em + color rgba(0, 0, 0, 0.8) + + &, * + user-select text + cursor auto + + & + .file + &.image + > img + border-radius 0 0 16px 16px + + > .file + &.image + > img + display block + max-width 100% + max-height 512px + border-radius 16px + + > footer + display block + clear both + margin 0 + padding 2px + font-size 10px + color rgba(0, 0, 0, 0.4) + + > .is-edited + margin-left 4px + + &:not([data-is-me='true']) + > .avatar-anchor + float left + + > .content-container + float left + + > .balloon + background #eee + + &:before + left -14px + border-top solid 8px transparent + border-right solid 8px #eee + border-bottom solid 8px transparent + border-left solid 8px transparent + + > footer + text-align left + + &[data-is-me='true'] + > .avatar-anchor + float right + + > .content-container + float right + + > .balloon + background $me-balloon-color + + &:before + right -14px + left auto + border-top solid 8px transparent + border-right solid 8px transparent + border-bottom solid 8px transparent + border-left solid 8px $me-balloon-color + + > .content + + > p.is-deleted + color rgba(255, 255, 255, 0.5) + + > [ref='text'] + &, * + color #fff !important + + > footer + text-align right + + &[data-is-deleted='true'] + > .content-container + opacity 0.5 + +script. + @mixin \i + @mixin \text + + @message = @opts.message + @message.is_me = @message.user.id == @I.id + + @on \mount ~> + if @message.text? + tokens = @analyze @message.text + + @refs.text.innerHTML = @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content diff --git a/src/web/app/desktop/tags/messaging/room-window.tag b/src/web/app/desktop/tags/messaging/room-window.tag new file mode 100644 index 0000000000..673b11419a --- /dev/null +++ b/src/web/app/desktop/tags/messaging/room-window.tag @@ -0,0 +1,26 @@ +mk-messaging-room-window + mk-window@window(is-modal={ false }, width={ '500px' }, height={ '560px' }) + + i.fa.fa-comments + | メッセージ: { parent.user.name } + + + mk-messaging-room(user={ parent.user }) + + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + > mk-messaging-room + height 100% + +script. + @user = @opts.user + + @on \mount ~> + @refs.window.on \closed ~> + @unmount! diff --git a/src/web/app/desktop/tags/messaging/room.tag b/src/web/app/desktop/tags/messaging/room.tag new file mode 100644 index 0000000000..ca396d7418 --- /dev/null +++ b/src/web/app/desktop/tags/messaging/room.tag @@ -0,0 +1,227 @@ +mk-messaging-room + div.stream@stream + p.initializing(if={ init }) + i.fa.fa-spinner.fa-spin + | 読み込み中 + p.empty(if={ !init && messages.length == 0 }) + i.fa.fa-info-circle + | このユーザーとまだ会話したことがありません + virtual(each={ message, i in messages }) + mk-messaging-message(message={ message }) + p.date(if={ i != messages.length - 1 && message._date != messages[i + 1]._date }) + span { messages[i + 1]._datetext } + + div.typings + footer + div@notifications + div.grippie(title='ドラッグしてフォームの広さを調整') + mk-messaging-form(user={ user }) + +style. + display block + + > .stream + position absolute + top 0 + left 0 + width 100% + height calc(100% - 100px) + overflow auto + + > .empty + width 100% + margin 0 + padding 16px 8px 8px 8px + text-align center + font-size 0.8em + color rgba(0, 0, 0, 0.4) + + i + margin-right 4px + + > .no-history + display block + margin 0 + padding 16px + text-align center + font-size 0.8em + color rgba(0, 0, 0, 0.4) + + i + margin-right 4px + + > .message + // something + + > .date + display block + margin 8px 0 + text-align center + + &:before + content '' + display block + position absolute + height 1px + width 90% + top 16px + left 0 + right 0 + margin 0 auto + background rgba(0, 0, 0, 0.1) + + > span + display inline-block + margin 0 + padding 0 16px + //font-weight bold + line-height 32px + color rgba(0, 0, 0, 0.3) + background #fff + + > footer + position absolute + z-index 2 + bottom 0 + width 600px + max-width 100% + margin 0 auto + padding 0 + background rgba(255, 255, 255, 0.95) + background-clip content-box + + > [ref='notifications'] + position absolute + top -48px + width 100% + padding 8px 0 + text-align center + + > p + display inline-block + margin 0 + padding 0 12px 0 28px + cursor pointer + line-height 32px + font-size 12px + color $theme-color-foreground + background $theme-color + border-radius 16px + transition opacity 1s ease + + > i + position absolute + top 0 + left 10px + line-height 32px + font-size 16px + + > .grippie + height 10px + margin-top -10px + background transparent + cursor ns-resize + + &:hover + //background rgba(0, 0, 0, 0.1) + + &:active + //background rgba(0, 0, 0, 0.2) + +script. + @mixin \i + @mixin \api + @mixin \messaging-stream + + @user = @opts.user + @init = true + @sending = false + @messages = [] + + @connection = new @MessagingStreamConnection @I, @user.id + + @on \mount ~> + @connection.event.on \message @on-message + @connection.event.on \read @on-read + + document.add-event-listener \visibilitychange @on-visibilitychange + + @api \messaging/messages do + user_id: @user.id + .then (messages) ~> + @init = false + @messages = messages.reverse! + @update! + @scroll-to-bottom! + .catch (err) ~> + console.error err + + @on \unmount ~> + @connection.event.off \message @on-message + @connection.event.off \read @on-read + @connection.close! + + document.remove-event-listener \visibilitychange @on-visibilitychange + + @on \update ~> + @messages.for-each (message) ~> + date = (new Date message.created_at).get-date! + month = (new Date message.created_at).get-month! + 1 + message._date = date + message._datetext = month + '月 ' + date + '日' + + @on-message = (message) ~> + is-bottom = @is-bottom! + + @messages.push message + if message.user_id != @I.id and not document.hidden + @connection.socket.send JSON.stringify do + type: \read + id: message.id + @update! + + if is-bottom + # Scroll to bottom + @scroll-to-bottom! + else if message.user_id != @I.id + # Notify + @notify '新しいメッセージがあります' + + @on-read = (ids) ~> + if not Array.isArray ids then ids = [ids] + ids.for-each (id) ~> + if (@messages.some (x) ~> x.id == id) + exist = (@messages.map (x) -> x.id).index-of id + @messages[exist].is_read = true + @update! + + @is-bottom = ~> + current = @refs.stream.scroll-top + @refs.stream.offset-height + max = @refs.stream.scroll-height + current > (max - 32) + + @scroll-to-bottom = ~> + @refs.stream.scroll-top = @refs.stream.scroll-height + + @notify = (message) ~> + n = document.create-element \p + n.inner-HTML = '' + message + n.onclick = ~> + @scroll-to-bottom! + n.parent-node.remove-child n + @refs.notifications.append-child n + + set-timeout ~> + n.style.opacity = 0 + set-timeout ~> + n.parent-node.remove-child n + , 1000ms + , 4000ms + + @on-visibilitychange = ~> + if document.hidden then return + @messages.for-each (message) ~> + if message.user_id != @I.id and not message.is_read + @connection.socket.send JSON.stringify do + type: \read + id: message.id diff --git a/src/web/app/desktop/tags/messaging/window.tag b/src/web/app/desktop/tags/messaging/window.tag new file mode 100644 index 0000000000..b6979b6244 --- /dev/null +++ b/src/web/app/desktop/tags/messaging/window.tag @@ -0,0 +1,29 @@ +mk-messaging-window + mk-window@window(is-modal={ false }, width={ '500px' }, height={ '560px' }) + + i.fa.fa-comments + | メッセージ + + + mk-messaging@index + + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + > mk-messaging + height 100% + +script. + @on \mount ~> + @refs.window.on \closed ~> + @unmount! + + @refs.window.refs.index.on \navigate-user (user) ~> + w = document.body.append-child document.create-element \mk-messaging-room-window + riot.mount w, do + user: user diff --git a/src/web/app/desktop/tags/notifications.tag b/src/web/app/desktop/tags/notifications.tag new file mode 100644 index 0000000000..d47815a89b --- /dev/null +++ b/src/web/app/desktop/tags/notifications.tag @@ -0,0 +1,226 @@ +mk-notifications + div.notifications(if={ notifications.length != 0 }) + virtual(each={ notification, i in notifications }) + div.notification(class={ notification.type }) + mk-time(time={ notification.created_at }) + + div.main(if={ notification.type == 'like' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-thumbs-o-up + a(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id }) { notification.user.name } + a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'repost' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-retweet + a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name } + a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post.repost) } + + div.main(if={ notification.type == 'quote' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-quote-left + a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'follow' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-user-plus + a(href={ CONFIG.url + '/' + notification.user.username }, data-user-preview={ notification.user.id }) { notification.user.name } + + div.main(if={ notification.type == 'reply' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-reply + a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'mention' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=48' }, alt='avatar') + div.text + p + i.fa.fa-at + a(href={ CONFIG.url + '/' + notification.post.user.username }, data-user-preview={ notification.post.user_id }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + p.date(if={ i != notifications.length - 1 && notification._date != notifications[i + 1]._date }) + span + i.fa.fa-angle-up + | { notification._datetext } + span + i.fa.fa-angle-down + | { notifications[i + 1]._datetext } + + p.empty(if={ notifications.length == 0 && !loading }) + | ありません! + p.loading(if={ loading }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + + > .notifications + > .notification + margin 0 + padding 16px + font-size 0.9em + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + &:last-child + border-bottom none + + > mk-time + display inline + position absolute + top 16px + right 12px + vertical-align top + color rgba(0, 0, 0, 0.6) + font-size small + + > .main + word-wrap break-word + + &:after + content "" + display block + clear both + + .avatar-anchor + display block + float left + + img + min-width 36px + min-height 36px + max-width 36px + max-height 36px + border-radius 6px + + .text + float right + width calc(100% - 36px) + padding-left 8px + + p + margin 0 + + i + margin-right 4px + + .post-preview + color rgba(0, 0, 0, 0.7) + + .post-ref + color rgba(0, 0, 0, 0.7) + + &:before, &:after + font-family FontAwesome + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &:before + content "\f10d" + + &:after + content "\f10e" + + &.like + .text p i + color #FFAC33 + + &.repost, &.quote + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + + &.reply, &.mention + .text p i + color #555 + + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.8em + color #aaa + background #fdfdfd + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + span + margin 0 16px + + i + margin-right 8px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \stream + @mixin \user-preview + @mixin \get-post-summary + + @notifications = [] + @loading = true + + @on \mount ~> + @api \i/notifications + .then (notifications) ~> + @notifications = notifications + @loading = false + @update! + .catch (err, text-status) -> + console.error err + + @stream.on \notification @on-notification + + @on \unmount ~> + @stream.off \notification @on-notification + + @on-notification = (notification) ~> + @notifications.unshift notification + @update! + + @on \update ~> + @notifications.for-each (notification) ~> + date = (new Date notification.created_at).get-date! + month = (new Date notification.created_at).get-month! + 1 + notification._date = date + notification._datetext = month + '月 ' + date + '日' diff --git a/src/web/app/desktop/tags/pages/entrance.tag b/src/web/app/desktop/tags/pages/entrance.tag new file mode 100644 index 0000000000..5e18f616a3 --- /dev/null +++ b/src/web/app/desktop/tags/pages/entrance.tag @@ -0,0 +1,77 @@ +mk-entrance + main + img(src='/_/resources/title.svg', alt='Misskey') + + mk-entrance-signin(if={ mode == 'signin' }) + mk-entrance-signup(if={ mode == 'signup' }) + div.introduction(if={ mode == 'introduction' }) + mk-introduction + button(onclick={ signin }) わかった + + mk-forkit + + footer + mk-copyright + + // ↓ https://github.com/riot/riot/issues/2134 (将来的) + style(data-disable-scope). + #wait { + right: auto; + left: 15px; + } + +style. + display block + height 100% + + > main + display block + + > img + display block + width 160px + height 170px + margin 0 auto + pointer-events none + user-select none + + > .introduction + max-width 360px + margin 0 auto + color #777 + + > mk-introduction + padding 32px + background #fff + box-shadow 0 4px 16px rgba(0, 0, 0, 0.2) + + > button + display block + margin 16px auto 0 auto + color #666 + + &:hover + text-decoration underline + + > footer + > mk-copyright + margin 0 + text-align center + line-height 64px + font-size 10px + color rgba(#000, 0.5) + +script. + @mode = \signin + + @signup = ~> + @mode = \signup + @update! + + @signin = ~> + @mode = \signin + @update! + + @introduction = ~> + @mode = \introduction + @update! diff --git a/src/web/app/desktop/tags/pages/entrance/signin.tag b/src/web/app/desktop/tags/pages/entrance/signin.tag new file mode 100644 index 0000000000..8ff39bc296 --- /dev/null +++ b/src/web/app/desktop/tags/pages/entrance/signin.tag @@ -0,0 +1,128 @@ +mk-entrance-signin + a.help(href={ CONFIG.urls.about + '/help' }, title='お困りですか?'): i.fa.fa-question + div.form + h1 + img(if={ user }, src={ user.avatar_url + '?thumbnail&size=32' }) + p { user ? user.name : 'アカウント' } + mk-signin@signin + div.divider: span or + button.signup(onclick={ parent.signup }) 新規登録 + a.introduction(onclick={ introduction }) Misskeyについて + +style. + display block + width 290px + margin 0 auto + text-align center + + &:hover + > .help + opacity 1 + + > .help + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + background transparent + opacity 0 + transition opacity 0.1s ease + + &:hover + color #444 + + &:active + color #222 + + > i + padding 14px + + > .form + padding 10px 28px 16px 28px + background #fff + box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) + + > h1 + display block + margin 0 + padding 0 + height 54px + line-height 54px + text-align center + text-transform uppercase + font-size 1em + font-weight bold + color rgba(0, 0, 0, 0.5) + border-bottom solid 1px rgba(0, 0, 0, 0.1) + + > p + display inline + margin 0 + padding 0 + + > img + display inline-block + top 10px + width 32px + height 32px + margin-right 8px + border-radius 100% + + &[src=''] + display none + + > .divider + padding 16px 0 + text-align center + + &:after + content "" + display block + position absolute + top 50% + width 100% + height 1px + border-top solid 1px rgba(0, 0, 0, 0.1) + + > * + z-index 1 + padding 0 8px + color rgba(0, 0, 0, 0.5) + background #fdfdfd + + > .signup + width 100% + line-height 56px + font-size 1em + color #fff + background $theme-color + border-radius 64px + + &:hover + background lighten($theme-color, 5%) + + &:active + background darken($theme-color, 5%) + + > .introduction + display inline-block + margin-top 16px + font-size 12px + color #666 + +script. + @on \mount ~> + @refs.signin.on \user (user) ~> + @update do + user: user + + @introduction = ~> + @parent.introduction! diff --git a/src/web/app/desktop/tags/pages/entrance/signup.tag b/src/web/app/desktop/tags/pages/entrance/signup.tag new file mode 100644 index 0000000000..1b585f7000 --- /dev/null +++ b/src/web/app/desktop/tags/pages/entrance/signup.tag @@ -0,0 +1,44 @@ +mk-entrance-signup + mk-signup + button.cancel(type='button', onclick={ parent.signin }, title='キャンセル'): i.fa.fa-times + +style. + display block + width 368px + margin 0 auto + + &:hover + > .cancel + opacity 1 + + > mk-signup + padding 18px 32px 0 32px + background #fff + box-shadow 0px 4px 16px rgba(0, 0, 0, 0.2) + + > .cancel + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + box-shadow none + background transparent + opacity 0 + transition opacity 0.1s ease + + &:hover + color #555 + + &:active + color #222 + + > i + padding 14px diff --git a/src/web/app/desktop/tags/pages/home.tag b/src/web/app/desktop/tags/pages/home.tag new file mode 100644 index 0000000000..5d419a5802 --- /dev/null +++ b/src/web/app/desktop/tags/pages/home.tag @@ -0,0 +1,51 @@ +mk-home-page + mk-ui@ui(page={ page }): mk-home@home(mode={ parent.opts.mode }) + +style. + display block + + background-position center center + background-attachment fixed + background-size cover + +script. + @mixin \i + @mixin \api + @mixin \ui-progress + @mixin \stream + @mixin \get-post-summary + + @unread-count = 0 + + @page = switch @opts.mode + | \timelie => \home + | \mentions => \mentions + | _ => \home + + @on \mount ~> + @refs.ui.refs.home.on \loaded ~> + @Progress.done! + + document.title = 'Misskey' + if @I.data.wallpaper + @api \drive/files/show do + file_id: @I.data.wallpaper + .then (file) ~> + @root.style.background-image = 'url(' + file.url + ')' + @Progress.start! + @stream.on \post @on-stream-post + document.add-event-listener \visibilitychange @window-on-visibilitychange, false + + @on \unmount ~> + @stream.off \post @on-stream-post + document.remove-event-listener \visibilitychange @window-on-visibilitychange + + @on-stream-post = (post) ~> + if document.hidden and post.user_id !== @I.id + @unread-count++ + document.title = '(' + @unread-count + ') ' + @get-post-summary post + + @window-on-visibilitychange = ~> + if !document.hidden + @unread-count = 0 + document.title = 'Misskey' diff --git a/src/web/app/desktop/tags/pages/not-found.tag b/src/web/app/desktop/tags/pages/not-found.tag new file mode 100644 index 0000000000..fe23cc6fa4 --- /dev/null +++ b/src/web/app/desktop/tags/pages/not-found.tag @@ -0,0 +1,46 @@ +mk-not-found + mk-ui + main + h1 Not Found + img(src='/_/resources/rogge.jpg', alt='') + div.mask + +style. + display block + + main + display block + width 600px + margin 32px auto + + > img + display block + width 600px + height 459px + pointer-events none + user-select none + border-radius 16px + box-shadow 0 0 16px rgba(0, 0, 0, 0.1) + + > h1 + display block + margin 0 + padding 0 + position absolute + top 260px + left 225px + transform rotate(-12deg) + z-index 2 + color #444 + font-size 24px + line-height 20px + + > .mask + position absolute + top 262px + left 217px + width 126px + height 18px + transform rotate(-12deg) + background #D6D5DA + border-radius 2px 6px 7px 6px diff --git a/src/web/app/desktop/tags/pages/post.tag b/src/web/app/desktop/tags/pages/post.tag new file mode 100644 index 0000000000..81ab9ce006 --- /dev/null +++ b/src/web/app/desktop/tags/pages/post.tag @@ -0,0 +1,25 @@ +mk-post-page + mk-ui@ui: main: mk-post-detail@detail(post={ parent.post }) + +style. + display block + + main + padding 16px + + > mk-post-detail + margin 0 auto + +script. + @mixin \ui-progress + + @post = @opts.post + + @on \mount ~> + @Progress.start! + + @refs.ui.refs.detail.on \post-fetched ~> + @Progress.set 0.5 + + @refs.ui.refs.detail.on \loaded ~> + @Progress.done! diff --git a/src/web/app/desktop/tags/pages/search.tag b/src/web/app/desktop/tags/pages/search.tag new file mode 100644 index 0000000000..a7878ddc0f --- /dev/null +++ b/src/web/app/desktop/tags/pages/search.tag @@ -0,0 +1,14 @@ +mk-search-page + mk-ui@ui: mk-search@search(query={ parent.opts.query }) + +style. + display block + +script. + @mixin \ui-progress + + @on \mount ~> + @Progress.start! + + @refs.ui.refs.search.on \loaded ~> + @Progress.done! diff --git a/src/web/app/desktop/tags/pages/user.tag b/src/web/app/desktop/tags/pages/user.tag new file mode 100644 index 0000000000..d41093c298 --- /dev/null +++ b/src/web/app/desktop/tags/pages/user.tag @@ -0,0 +1,20 @@ +mk-user-page + mk-ui@ui: mk-user@user(user={ parent.user }, page={ parent.opts.page }) + +style. + display block + +script. + @mixin \ui-progress + + @user = @opts.user + + @on \mount ~> + @Progress.start! + + @refs.ui.refs.user.on \user-fetched (user) ~> + @Progress.set 0.5 + document.title = user.name + ' | Misskey' + + @refs.ui.refs.user.on \loaded ~> + @Progress.done! diff --git a/src/web/app/desktop/tags/post-detail-sub.tag b/src/web/app/desktop/tags/post-detail-sub.tag new file mode 100644 index 0000000000..b7aa745737 --- /dev/null +++ b/src/web/app/desktop/tags/post-detail-sub.tag @@ -0,0 +1,141 @@ +mk-post-detail-sub(title={ title }) + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ post.user_id }) + div.main + header + div.left + a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }) + | { post.user.name } + span.username + | @{ post.user.username } + div.right + a.time(href={ url }) + mk-time(time={ post.created_at }) + div.body + div.text@text + div.media(if={ post.media }) + virtual(each={ file in post.media }) + img(src={ file.url + '?thumbnail&size=512' }, alt={ file.name }, title={ file.name }) + +style. + display block + margin 0 + padding 20px 32px + background #fdfdfd + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 44px + height 44px + margin 0 + border-radius 4px + vertical-align bottom + + > .main + float left + width calc(100% - 60px) + + > header + margin-bottom 4px + white-space nowrap + + &:after + content "" + display block + clear both + + > .left + float left + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .right + float right + + > .time + font-size 0.9em + color #c0c0c0 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1em + color #717171 + + > mk-url-preview + margin-top 8px + + > .media + > img + display block + max-width 100% + +script. + @mixin \api + @mixin \text + @mixin \date-stringify + @mixin \user-preview + + @post = @opts.post + + @url = CONFIG.url + '/' + @post.user.username + '/' + @post.id + + @title = @date-stringify @post.created_at + + @on \mount ~> + if @post.text? + tokens = @analyze @post.text + @refs.text.innerHTML = @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + @like = ~> + if @post.is_liked + @api \posts/likes/delete do + post_id: @post.id + .then ~> + @post.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @post.id + .then ~> + @post.is_liked = true + @update! diff --git a/src/web/app/desktop/tags/post-detail.tag b/src/web/app/desktop/tags/post-detail.tag new file mode 100644 index 0000000000..e071b7c704 --- /dev/null +++ b/src/web/app/desktop/tags/post-detail.tag @@ -0,0 +1,415 @@ +mk-post-detail(title={ title }) + + div.fetching(if={ fetching }) + mk-ellipsis-icon + + div.main(if={ !fetching }) + + button.read-more(if={ p.reply_to && p.reply_to.reply_to_id && context == null }, title='会話をもっと読み込む', onclick={ load-context }, disabled={ loading-context }) + i.fa.fa-ellipsis-v(if={ !loading-context }) + i.fa.fa-spinner.fa-pulse(if={ loading-context }) + + div.context + virtual(each={ post in context }) + mk-post-detail-sub(post={ post }) + + div.reply-to(if={ p.reply_to }) + mk-post-detail-sub(post={ p.reply_to }) + + div.repost(if={ is-repost }) + p + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=32' }, alt='avatar') + i.fa.fa-retweet + a.name(href={ CONFIG.url + '/' + post.user.username }) { post.user.name } + | がRepost + + article + a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username }) + img.avatar(src={ p.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ p.user.id }) + header + a.name(href={ CONFIG.url + '/' + p.user.username }, data-user-preview={ p.user.id }) + | { p.user.name } + span.username + | @{ p.user.username } + a.time(href={ url }) + mk-time(time={ p.created_at }) + div.body + div.text@text + div.media(if={ p.media }) + virtual(each={ file in p.media }) + img(src={ file.url + '?thumbnail&size=512' }, alt={ file.name }, title={ file.name }) + footer + button(onclick={ reply }, title='返信') + i.fa.fa-reply + p.count(if={ p.replies_count > 0 }) { p.replies_count } + button(onclick={ repost }, title='Repost') + i.fa.fa-retweet + p.count(if={ p.repost_count > 0 }) { p.repost_count } + button(class={ liked: p.is_liked }, onclick={ like }, title='善哉') + i.fa.fa-thumbs-o-up + p.count(if={ p.likes_count > 0 }) { p.likes_count } + button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h + div.reposts-and-likes + div.reposts(if={ reposts && reposts.length > 0 }) + header + a { p.repost_count } + p Repost + ol.users + li.user(each={ reposts }) + a.avatar-anchor(href={ CONFIG.url + '/' + user.username }, title={ user.name }, data-user-preview={ user.id }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=32' }, alt='') + div.likes(if={ likes && likes.length > 0 }) + header + a { p.likes_count } + p いいね + ol.users + li.user(each={ likes }) + a.avatar-anchor(href={ CONFIG.url + '/' + username }, title={ name }, data-user-preview={ id }) + img.avatar(src={ avatar_url + '?thumbnail&size=32' }, alt='') + + div.replies + virtual(each={ post in replies }) + mk-post-detail-sub(post={ post }) + +style. + display block + margin 0 + padding 0 + width 640px + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 8px + + > .fetching + padding 64px 0 + + > .main + + > .read-more + display block + margin 0 + padding 10px 0 + width 100% + font-size 1em + text-align center + color #999 + cursor pointer + background #fafafa + outline none + border none + border-bottom solid 1px #eef0f2 + border-radius 6px 6px 0 0 + + &:hover + background #f6f6f6 + + &:active + background #f0f0f0 + + &:disabled + color #ccc + + > .context + > * + border-bottom 1px solid #eef0f2 + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 16px 32px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + & + article + padding-top 8px + + > .reply-to + border-bottom 1px solid #eef0f2 + + > article + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + width 60px + height 60px + + > .avatar + display block + width 60px + height 60px + margin 0 + border-radius 8px + vertical-align bottom + + > header + position absolute + top 28px + left 108px + width calc(100% - 108px) + + > .name + display inline-block + margin 0 + line-height 24px + color #777 + font-size 18px + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + display block + text-align left + margin 0 + color #ccc + + > .time + position absolute + top 0 + right 32px + font-size 1em + color #c0c0c0 + + > .body + padding 8px 0 + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.5em + color #717171 + + > mk-url-preview + margin-top 8px + + > .media + > img + display block + max-width 100% + + > footer + font-size 1.2em + + > button + margin 0 28px 0 0 + padding 8px + background transparent + border none + font-size 1em + color #ddd + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.liked + color $theme-color + + > .reposts-and-likes + display flex + justify-content center + padding 0 + margin 16px 0 + + &:empty + display none + + > .reposts + > .likes + display flex + flex 1 1 + padding 0 + border-top solid 1px #F2EFEE + + > header + flex 1 1 80px + max-width 80px + padding 8px 5px 0px 10px + + > a + display block + font-size 1.5em + line-height 1.4em + + > p + display block + margin 0 + font-size 0.7em + line-height 1em + font-weight normal + color #a0a2a5 + + > .users + display block + flex 1 1 + margin 0 + padding 10px 10px 10px 5px + list-style none + + > .user + display block + float left + margin 4px + padding 0 + + > .avatar-anchor + display:block + + > .avatar + vertical-align bottom + width 24px + height 24px + border-radius 4px + + > .reposts + .likes + margin-left 16px + + > .replies + > * + border-top 1px solid #eef0f2 + +script. + @mixin \api + @mixin \text + @mixin \user-preview + @mixin \date-stringify + @mixin \NotImplementedException + + @fetching = true + @loading-context = false + @content = null + @post = null + + @on \mount ~> + + @api \posts/show do + post_id: @opts.post + .then (post) ~> + @fetching = false + @post = post + @trigger \loaded + + @is-repost = @post.repost? + @p = if @is-repost then @post.repost else @post + + @title = @date-stringify @p.created_at + + @update! + + if @p.text? + tokens = @analyze @p.text + @refs.text.innerHTML = @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content + + # Get likes + @api \posts/likes do + post_id: @p.id + limit: 8 + .then (likes) ~> + @likes = likes + @update! + + # Get reposts + @api \posts/reposts do + post_id: @p.id + limit: 8 + .then (reposts) ~> + @reposts = reposts + @update! + + # Get replies + @api \posts/replies do + post_id: @p.id + limit: 8 + .then (replies) ~> + @replies = replies + @update! + + @update! + + @reply = ~> + form = document.body.append-child document.create-element \mk-post-form-window + riot.mount form, do + reply: @p + + @repost = ~> + form = document.body.append-child document.create-element \mk-repost-form-window + riot.mount form, do + post: @p + + @like = ~> + if @p.is_liked + @api \posts/likes/delete do + post_id: @p.id + .then ~> + @p.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @p.id + .then ~> + @p.is_liked = true + @update! + + @load-context = ~> + @loading-context = true + + # Get context + @api \posts/context do + post_id: @p.reply_to_id + .then (context) ~> + @context = context.reverse! + @loading-context = false + @update! diff --git a/src/web/app/desktop/tags/post-form-window.tag b/src/web/app/desktop/tags/post-form-window.tag new file mode 100644 index 0000000000..8727777944 --- /dev/null +++ b/src/web/app/desktop/tags/post-form-window.tag @@ -0,0 +1,60 @@ +mk-post-form-window + + mk-window@window(is-modal={ true }, colored={ true }) + + + span(if={ !parent.opts.reply }) 新規投稿 + span(if={ parent.opts.reply }) 返信 + span.files(if={ parent.files.length != 0 }) 添付: { parent.files.length }ファイル + span.uploading-files(if={ parent.uploading-files.length != 0 }) + | { parent.uploading-files.length }個のファイルをアップロード中 + mk-ellipsis + + + + div.ref(if={ parent.opts.reply }) + mk-post-preview(post={ parent.opts.reply }) + div.body + mk-post-form@form(reply={ parent.opts.reply }) + + +style. + > mk-window + + [data-yield='header'] + > .files + > .uploading-files + margin-left 8px + opacity 0.8 + + &:before + content '(' + + &:after + content ')' + + [data-yield='content'] + > .ref + > mk-post-preview + margin 16px 22px + +script. + @uploading-files = [] + @files = [] + + @on \mount ~> + @refs.window.refs.form.focus! + + @refs.window.on \closed ~> + @unmount! + + @refs.window.refs.form.on \post ~> + @refs.window.close! + + @refs.window.refs.form.on \change-uploading-files (files) ~> + @uploading-files = files + @update! + + @refs.window.refs.form.on \change-files (files) ~> + @files = files + @update! diff --git a/src/web/app/desktop/tags/post-form.tag b/src/web/app/desktop/tags/post-form.tag new file mode 100644 index 0000000000..2248587885 --- /dev/null +++ b/src/web/app/desktop/tags/post-form.tag @@ -0,0 +1,430 @@ +mk-post-form(ondragover={ ondragover }, ondragenter={ ondragenter }, ondragleave={ ondragleave }, ondrop={ ondrop }) + textarea@text(disabled={ wait }, class={ withfiles: files.length != 0 }, oninput={ update }, onkeydown={ onkeydown }, onpaste={ onpaste }, placeholder={ opts.reply ? 'この投稿への返信...' : 'いまどうしてる?' }) + div.attaches(if={ files.length != 0 }) + ul.files@attaches + li.file(each={ files }) + div.img(style='background-image: url({ url + "?thumbnail&size=64" })', title={ name }) + img.remove(onclick={ _remove }, src='/_/resources/desktop/remove.png', title='添付取り消し', alt='') + li.add(if={ files.length < 4 }, title='PCからファイルを添付', onclick={ select-file }): i.fa.fa-plus + p.remain + | 残り{ 4 - files.length } + mk-uploader@uploader + button@upload(title='PCからファイルを添付', onclick={ select-file }): i.fa.fa-upload + button@drive(title='ドライブからファイルを添付', onclick={ select-file-from-drive }): i.fa.fa-cloud + p.text-count(class={ over: refs.text.value.length > 300 }) のこり{ 300 - refs.text.value.length }文字 + button@submit(class={ wait: wait }, disabled={ wait || (refs.text.value.length == 0 && files.length == 0) }, onclick={ post }) + | { wait ? '投稿中' : opts.reply ? '返信' : '投稿' } + mk-ellipsis(if={ wait }) + input@file(type='file', accept='image/*', multiple, tabindex='-1', onchange={ change-file }) + div.dropzone(if={ draghover }) + +style. + display block + padding 16px + background lighten($theme-color, 95%) + + &:after + content "" + display block + clear both + + > .attaches + margin 0 + padding 0 + background lighten($theme-color, 98%) + border solid 1px rgba($theme-color, 0.1) + border-top none + border-radius 0 0 4px 4px + transition border-color .3s ease + + > .remain + display block + position absolute + top 8px + right 8px + margin 0 + padding 0 + color rgba($theme-color, 0.4) + + > .files + display block + margin 0 + padding 4px + list-style none + + &:after + content "" + display block + clear both + + > .file + display block + float left + margin 4px + padding 0 + cursor move + + &:hover > .remove + display block + + > .img + width 64px + height 64px + background-size cover + background-position center center + + > .remove + display none + position absolute + top -6px + right -6px + width 16px + height 16px + cursor pointer + + > .add + display block + float left + margin 4px + padding 0 + border dashed 2px rgba($theme-color, 0.2) + cursor pointer + + &:hover + border-color rgba($theme-color, 0.3) + + > i + color rgba($theme-color, 0.4) + + > i + display block + width 60px + height 60px + line-height 60px + text-align center + font-size 1.2em + color rgba($theme-color, 0.2) + + > mk-uploader + margin 8px 0 0 0 + padding 8px + border solid 1px rgba($theme-color, 0.2) + border-radius 4px + + [ref='file'] + display none + + [ref='text'] + display block + padding 12px + margin 0 + width 100% + max-width 100% + min-width 100% + min-height calc(16px + 12px + 12px) + font-size 16px + color #333 + background #fff + outline none + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + transition border-color .3s ease + + &:hover + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + color $theme-color + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + &:disabled + opacity 0.5 + + &::-webkit-input-placeholder + color rgba($theme-color, 0.3) + + &.withfiles + border-bottom solid 1px rgba($theme-color, 0.1) !important + border-radius 4px 4px 0 0 + + &:hover + .attaches + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + .attaches + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + .text-count + pointer-events none + display block + position absolute + bottom 16px + right 138px + margin 0 + line-height 40px + color rgba($theme-color, 0.5) + + &.over + color #ec3828 + + [ref='submit'] + display block + position absolute + bottom 16px + right 16px + cursor pointer + padding 0 + margin 0 + width 110px + height 40px + font-size 1em + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + outline none + border solid 1px lighten($theme-color, 15%) + border-radius 4px + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + &.wait + background linear-gradient( + 45deg, + darken($theme-color, 10%) 25%, + $theme-color 25%, + $theme-color 50%, + darken($theme-color, 10%) 50%, + darken($theme-color, 10%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation stripe-bg 1.5s linear infinite + opacity 0.7 + cursor wait + + @keyframes stripe-bg + from {background-position: 0 0;} + to {background-position: -64px 32px;} + + [ref='upload'] + [ref='drive'] + display inline-block + cursor pointer + padding 0 + margin 8px 4px 0 0 + width 40px + height 40px + font-size 1em + color rgba($theme-color, 0.5) + background transparent + outline none + border solid 1px transparent + border-radius 4px + + &:hover + background transparent + border-color rgba($theme-color, 0.3) + + &:active + color rgba($theme-color, 0.6) + background linear-gradient(to bottom, lighten($theme-color, 80%) 0%, lighten($theme-color, 90%) 100%) + border-color rgba($theme-color, 0.5) + box-shadow 0 2px 4px rgba(0, 0, 0, 0.15) inset + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + > .dropzone + position absolute + left 0 + top 0 + width 100% + height 100% + border dashed 2px rgba($theme-color, 0.5) + pointer-events none + +script. + @mixin \api + @mixin \notify + @mixin \autocomplete + @mixin \sortable + + @wait = false + @uploadings = [] + @files = [] + @autocomplete = null + + @in-reply-to-post = @opts.reply + + # https://github.com/riot/riot/issues/2080 + if @in-reply-to-post == '' then @in-reply-to-post = null + + @on \mount ~> + @refs.uploader.on \uploaded (file) ~> + @add-file file + + @refs.uploader.on \change-uploads (uploads) ~> + @trigger \change-uploading-files uploads + + @autocomplete = new @Autocomplete @refs.text + @autocomplete.attach! + + @on \unmount ~> + @autocomplete.detach! + + @focus = ~> + @refs.text.focus! + + @clear = ~> + @refs.text.value = '' + @files = [] + @trigger \change-files + @update! + + @ondragover = (e) ~> + e.stop-propagation! + @draghover = true + # ドラッグされてきたものがファイルだったら + if e.data-transfer.effect-allowed == \all + e.data-transfer.drop-effect = \copy + else + e.data-transfer.drop-effect = \move + return false + + @ondragenter = (e) ~> + @draghover = true + + @ondragleave = (e) ~> + @draghover = false + + @ondrop = (e) ~> + e.prevent-default! + e.stop-propagation! + @draghover = false + + # ファイルだったら + if e.data-transfer.files.length > 0 + Array.prototype.for-each.call e.data-transfer.files, (file) ~> + @upload file + return false + + # データ取得 + data = e.data-transfer.get-data 'text' + if !data? + return false + + try + # パース + obj = JSON.parse data + + # (ドライブの)ファイルだったら + if obj.type == \file + @add-file obj.file + catch + # ignore + + return false + + @onkeydown = (e) ~> + if (e.which == 10 || e.which == 13) && (e.ctrl-key || e.meta-key) + @post! + + @onpaste = (e) ~> + data = e.clipboard-data + items = data.items + for i from 0 to items.length - 1 + item = items[i] + switch (item.kind) + | \file => + @upload item.get-as-file! + + @select-file = ~> + @refs.file.click! + + @select-file-from-drive = ~> + browser = document.body.append-child document.create-element \mk-select-file-from-drive-window + i = riot.mount browser, do + multiple: true + i[0].one \selected (files) ~> + files.for-each @add-file + + @change-file = ~> + files = @refs.file.files + for i from 0 to files.length - 1 + file = files.item i + @upload file + + @upload = (file) ~> + @refs.uploader.upload file + + @add-file = (file) ~> + file._remove = ~> + @files = @files.filter (x) -> x.id != file.id + @trigger \change-files @files + @update! + + @files.push file + @trigger \change-files @files + @update! + + new @Sortable @refs.attaches, do + draggable: \.file + animation: 150ms + + @post = (e) ~> + @wait = true + + files = if @files? and @files.length > 0 + then @files.map (f) -> f.id + else undefined + + @api \posts/create do + text: @refs.text.value + media_ids: files + reply_to_id: if @in-reply-to-post? then @in-reply-to-post.id else undefined + .then (data) ~> + @trigger \post + @notify if @in-reply-to-post? then '返信しました!' else '投稿しました!' + .catch (err) ~> + console.error err + @notify '投稿できませんでした' + .then ~> + @wait = false + @update! diff --git a/src/web/app/desktop/tags/post-preview.tag b/src/web/app/desktop/tags/post-preview.tag new file mode 100644 index 0000000000..f17b152801 --- /dev/null +++ b/src/web/app/desktop/tags/post-preview.tag @@ -0,0 +1,94 @@ +mk-post-preview(title={ title }) + article + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ post.user_id }) + div.main + header + a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }) + | { post.user.name } + span.username + | @{ post.user.username } + a.time(href={ CONFIG.url + '/' + post.user.username + '/' + post.id }) + mk-time(time={ post.created_at }) + div.body + mk-sub-post-content.text(post={ post }) + +style. + display block + margin 0 + padding 0 + font-size 0.9em + background #fff + + > article + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 52px + height 52px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 68px) + + > header + margin-bottom 4px + white-space nowrap + + > .name + display inline + margin 0 + padding 0 + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #d1d8da + + > .time + position absolute + top 0 + right 0 + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + +script. + @mixin \date-stringify + @mixin \user-preview + + @post = @opts.post + + @title = @date-stringify @post.created_at diff --git a/src/web/app/desktop/tags/post-status-graph.tag b/src/web/app/desktop/tags/post-status-graph.tag new file mode 100644 index 0000000000..ffb081e4f3 --- /dev/null +++ b/src/web/app/desktop/tags/post-status-graph.tag @@ -0,0 +1,72 @@ +mk-post-status-graph + canvas@canv(width={ opts.width }, height={ opts.height }) + +style. + display block + + > canvas + margin 0 auto + +script. + @mixin \api + @mixin \is-promise + + @post = null + @post-promise = if @is-promise @opts.post then @opts.post else Promise.resolve @opts.post + + @on \mount ~> + post <~ @post-promise.then + @post = post + @update! + + @api \aggregation/posts/like do + post_id: @post.id + limit: 30days + .then (likes) ~> + likes = likes.reverse! + + @api \aggregation/posts/repost do + post_id: @post.id + limit: 30days + .then (repost) ~> + repost = repost.reverse! + + @api \aggregation/posts/reply do + post_id: @post.id + limit: 30days + .then (replies) ~> + replies = replies.reverse! + + new Chart @refs.canv, do + type: \bar + data: + labels: likes.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else '' + datasets: [ + { + label: \いいね + type: \line + data: likes.map (x) ~> x.count + line-tension: 0 + border-width: 2 + fill: true + background-color: 'rgba(247, 121, 108, 0.2)' + point-background-color: \#fff + point-radius: 4 + point-border-width: 2 + border-color: \#F7796C + }, + { + label: \返信 + type: \bar + data: replies.map (x) ~> x.count + background-color: \#555 + }, + { + label: \Repost + type: \bar + data: repost.map (x) ~> x.count + background-color: \#a2d61e + } + ] + options: + responsive: false diff --git a/src/web/app/desktop/tags/progress-dialog.tag b/src/web/app/desktop/tags/progress-dialog.tag new file mode 100644 index 0000000000..7c042686e6 --- /dev/null +++ b/src/web/app/desktop/tags/progress-dialog.tag @@ -0,0 +1,92 @@ +mk-progress-dialog + mk-window@window(is-modal={ false }, can-close={ false }, width={ '500px' }) + + | { parent.title } + mk-ellipsis + + + div.body + p.init(if={ isNaN(parent.value) }) + | 待機中 + mk-ellipsis + p.percentage(if={ !isNaN(parent.value) }) { Math.floor((parent.value / parent.max) * 100) } + progress(if={ !isNaN(parent.value) && parent.value < parent.max }, value={ isNaN(parent.value) ? 0 : parent.value }, max={ parent.max }) + div.progress.waiting(if={ parent.value >= parent.max }) + + +style. + display block + + > mk-window + [data-yield='content'] + + > .body + padding 18px 24px 24px 24px + + > .init + display block + margin 0 + text-align center + color rgba(#000, 0.7) + + > .percentage + display block + margin 0 0 4px 0 + text-align center + line-height 16px + color rgba($theme-color, 0.7) + + &:after + content '%' + + > progress + > .progress + display block + margin 0 + width 100% + height 10px + background transparent + border none + border-radius 4px + overflow hidden + + &::-webkit-progress-value + background $theme-color + + &::-webkit-progress-bar + background rgba($theme-color, 0.1) + + > .progress + background linear-gradient( + 45deg, + lighten($theme-color, 30%) 25%, + $theme-color 25%, + $theme-color 50%, + lighten($theme-color, 30%) 50%, + lighten($theme-color, 30%) 75%, + $theme-color 75%, + $theme-color + ) + background-size 32px 32px + animation progress-dialog-tag-progress-waiting 1.5s linear infinite + + @keyframes progress-dialog-tag-progress-waiting + from {background-position: 0 0;} + to {background-position: -64px 32px;} + +script. + @title = @opts.title + @value = parse-int @opts.value, 10 + @max = parse-int @opts.max, 10 + + @on \mount ~> + @refs.window.on \closed ~> + @unmount! + + @update-progress = (value, max) ~> + @value = parse-int value, 10 + @max = parse-int max, 10 + @update! + + @close = ~> + @refs.window.close! diff --git a/src/web/app/desktop/tags/repost-form-window.tag b/src/web/app/desktop/tags/repost-form-window.tag new file mode 100644 index 0000000000..40012f951f --- /dev/null +++ b/src/web/app/desktop/tags/repost-form-window.tag @@ -0,0 +1,38 @@ +mk-repost-form-window + mk-window@window(is-modal={ true }, colored={ true }) + + i.fa.fa-retweet + | この投稿をRepostしますか? + + + mk-repost-form@form(post={ parent.opts.post }) + + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + +script. + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 27 # Esc + @refs.window.close! + + @on \mount ~> + @refs.window.refs.form.on \cancel ~> + @refs.window.close! + + @refs.window.refs.form.on \posted ~> + @refs.window.close! + + document.add-event-listener \keydown @on-document-keydown + + @refs.window.on \closed ~> + @unmount! + + @on \unmount ~> + document.remove-event-listener \keydown @on-document-keydown diff --git a/src/web/app/desktop/tags/repost-form.tag b/src/web/app/desktop/tags/repost-form.tag new file mode 100644 index 0000000000..37fbad251d --- /dev/null +++ b/src/web/app/desktop/tags/repost-form.tag @@ -0,0 +1,140 @@ +mk-repost-form + mk-post-preview(post={ opts.post }) + div.form(if={ quote }) + textarea@text(disabled={ wait }, placeholder='この投稿を引用...') + footer + a.quote(if={ !quote }, onclick={ onquote }) 引用する... + button.cancel(onclick={ cancel }) キャンセル + button.ok(onclick={ ok }) Repost + +style. + + > mk-post-preview + margin 16px 22px + + > .form + [ref='text'] + display block + padding 12px + margin 0 + width 100% + max-width 100% + min-width 100% + min-height calc(1em + 12px + 12px) + font-size 1em + color #333 + background #fff + outline none + border solid 1px rgba($theme-color, 0.1) + border-radius 4px + transition border-color .3s ease + + &:hover + border-color rgba($theme-color, 0.2) + transition border-color .1s ease + + &:focus + color $theme-color + border-color rgba($theme-color, 0.5) + transition border-color 0s ease + + &:disabled + opacity 0.5 + + &::-webkit-input-placeholder + color rgba($theme-color, 0.3) + + > div + padding 16px + + > footer + height 72px + background lighten($theme-color, 95%) + + > .quote + position absolute + bottom 16px + left 28px + line-height 40px + + button + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + > .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + > .ok + right 16px + font-weight bold + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:hover + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active + background $theme-color + border-color $theme-color + +script. + @mixin \api + @mixin \notify + + @wait = false + @quote = false + + @cancel = ~> + @trigger \cancel + + @ok = ~> + @wait = true + @api \posts/create do + repost_id: @opts.post.id + text: if @quote then @refs.text.value else undefined + .then (data) ~> + @trigger \posted + @notify 'Repostしました!' + .catch (err) ~> + console.error err + @notify 'Repostできませんでした' + .then ~> + @wait = false + @update! + + @onquote = ~> + @quote = true diff --git a/src/web/app/desktop/tags/search-posts.tag b/src/web/app/desktop/tags/search-posts.tag new file mode 100644 index 0000000000..9862ff6e4e --- /dev/null +++ b/src/web/app/desktop/tags/search-posts.tag @@ -0,0 +1,88 @@ +mk-search-posts + div.loading(if={ is-loading }) + mk-ellipsis-icon + p.empty(if={ is-empty }) + i.fa.fa-search + | 「{ query }」に関する投稿は見つかりませんでした。 + mk-timeline@timeline + + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + + +style. + display block + background #fff + + > .loading + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + +script. + @mixin \api + @mixin \get-post-summary + + @query = @opts.query + @is-loading = true + @is-empty = false + @more-loading = false + @page = 0 + + @on \mount ~> + document.add-event-listener \keydown @on-document-keydown + window.add-event-listener \scroll @on-scroll + + @api \posts/search do + query: @query + .then (posts) ~> + @is-loading = false + @is-empty = posts.length == 0 + @update! + @refs.timeline.set-posts posts + @trigger \loaded + .catch (err) ~> + console.error err + + @on \unmount ~> + document.remove-event-listener \keydown @on-document-keydown + window.remove-event-listener \scroll @on-scroll + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 84 # t + @refs.timeline.focus! + + @more = ~> + if @more-loading or @is-loading or @timeline.posts.length == 0 + return + @more-loading = true + @update! + @api \posts/search do + query: @query + page: @page + 1 + .then (posts) ~> + @more-loading = false + @page++ + @update! + @refs.timeline.prepend-posts posts + .catch (err) ~> + console.error err + + @on-scroll = ~> + current = window.scroll-y + window.inner-height + if current > document.body.offset-height - 16 # 遊び + @more! diff --git a/src/web/app/desktop/tags/search.tag b/src/web/app/desktop/tags/search.tag new file mode 100644 index 0000000000..aec426ac79 --- /dev/null +++ b/src/web/app/desktop/tags/search.tag @@ -0,0 +1,28 @@ +mk-search + header + h1 { query } + mk-search-posts@posts(query={ query }) + +style. + display block + padding-bottom 32px + + > header + width 100% + max-width 600px + margin 0 auto + color #555 + + > mk-search-posts + max-width 600px + margin 0 auto + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + +script. + @query = @opts.query + + @on \mount ~> + @refs.posts.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/desktop/tags/select-file-from-drive-window.tag b/src/web/app/desktop/tags/select-file-from-drive-window.tag new file mode 100644 index 0000000000..5042944837 --- /dev/null +++ b/src/web/app/desktop/tags/select-file-from-drive-window.tag @@ -0,0 +1,160 @@ +mk-select-file-from-drive-window + mk-window@window(is-modal={ true }, width={ '800px' }, height={ '500px' }) + + mk-raw(content={ parent.title }) + span.count(if={ parent.multiple && parent.file.length > 0 }) ({ parent.file.length }ファイル選択中) + + + mk-drive-browser@browser(multiple={ parent.multiple }) + div + button.upload(title='PCからドライブにファイルをアップロード', onclick={ parent.upload }): i.fa.fa-upload + button.cancel(onclick={ parent.close }) キャンセル + button.ok(disabled={ parent.multiple && parent.file.length == 0 }, onclick={ parent.ok }) 決定 + + +style. + > mk-window + [data-yield='header'] + > mk-raw + > i + margin-right 4px + + .count + margin-left 8px + opacity 0.7 + + [data-yield='content'] + > mk-drive-browser + height calc(100% - 72px) + + > div + height 72px + background lighten($theme-color, 95%) + + .upload + display inline-block + position absolute + top 8px + left 16px + cursor pointer + padding 0 + margin 8px 4px 0 0 + width 40px + height 40px + font-size 1em + color rgba($theme-color, 0.5) + background transparent + outline none + border solid 1px transparent + border-radius 4px + + &:hover + background transparent + border-color rgba($theme-color, 0.3) + + &:active + color rgba($theme-color, 0.6) + background transparent + border-color rgba($theme-color, 0.5) + box-shadow 0 2px 4px rgba(darken($theme-color, 50%), 0.15) inset + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + .ok + .cancel + display block + position absolute + bottom 16px + cursor pointer + padding 0 + margin 0 + width 120px + height 40px + font-size 1em + outline none + border-radius 4px + + &:focus + &:after + content "" + pointer-events none + position absolute + top -5px + right -5px + bottom -5px + left -5px + border 2px solid rgba($theme-color, 0.3) + border-radius 8px + + &:disabled + opacity 0.7 + cursor default + + .ok + right 16px + color $theme-color-foreground + background linear-gradient(to bottom, lighten($theme-color, 25%) 0%, lighten($theme-color, 10%) 100%) + border solid 1px lighten($theme-color, 15%) + + &:not(:disabled) + font-weight bold + + &:hover:not(:disabled) + background linear-gradient(to bottom, lighten($theme-color, 8%) 0%, darken($theme-color, 8%) 100%) + border-color $theme-color + + &:active:not(:disabled) + background $theme-color + border-color $theme-color + + .cancel + right 148px + color #888 + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + +script. + @file = [] + + @multiple = if @opts.multiple? then @opts.multiple else false + @title = @opts.title || 'ファイルを選択' + + @on \mount ~> + @refs.window.refs.browser.on \selected (file) ~> + @file = file + @ok! + + @refs.window.refs.browser.on \change-selection (files) ~> + @file = files + @update! + + @refs.window.on \closed ~> + @unmount! + + @close = ~> + @refs.window.close! + + @upload = ~> + @refs.window.refs.browser.select-local-file! + + @ok = ~> + @trigger \selected @file + @refs.window.close! diff --git a/src/web/app/desktop/tags/set-avatar-suggestion.tag b/src/web/app/desktop/tags/set-avatar-suggestion.tag new file mode 100644 index 0000000000..68c9c310df --- /dev/null +++ b/src/web/app/desktop/tags/set-avatar-suggestion.tag @@ -0,0 +1,44 @@ +mk-set-avatar-suggestion(onclick={ set }) + p + b アバターを設定 + | してみませんか? + button(onclick={ close }): i.fa.fa-times + +style. + display block + cursor pointer + color #fff + background #a8cad0 + + &:hover + background #70abb5 + + > p + display block + margin 0 auto + padding 8px + max-width 1024px + + > a + font-weight bold + color #fff + + > button + position absolute + top 0 + right 0 + padding 8px + color #fff + +script. + @mixin \i + @mixin \update-avatar + + @set = ~> + @update-avatar @I, (i) ~> + @update-i i + + @close = (e) ~> + e.prevent-default! + e.stop-propagation! + @unmount! diff --git a/src/web/app/desktop/tags/set-banner-suggestion.tag b/src/web/app/desktop/tags/set-banner-suggestion.tag new file mode 100644 index 0000000000..bff0385803 --- /dev/null +++ b/src/web/app/desktop/tags/set-banner-suggestion.tag @@ -0,0 +1,44 @@ +mk-set-banner-suggestion(onclick={ set }) + p + b バナーを設定 + | してみませんか? + button(onclick={ close }): i.fa.fa-times + +style. + display block + cursor pointer + color #fff + background #a8cad0 + + &:hover + background #70abb5 + + > p + display block + margin 0 auto + padding 8px + max-width 1024px + + > a + font-weight bold + color #fff + + > button + position absolute + top 0 + right 0 + padding 8px + color #fff + +script. + @mixin \i + @mixin \update-banner + + @set = ~> + @update-banner @I, (i) ~> + @update-i i + + @close = (e) ~> + e.prevent-default! + e.stop-propagation! + @unmount! diff --git a/src/web/app/desktop/tags/settings-window.tag b/src/web/app/desktop/tags/settings-window.tag new file mode 100644 index 0000000000..e259848718 --- /dev/null +++ b/src/web/app/desktop/tags/settings-window.tag @@ -0,0 +1,26 @@ +mk-settings-window + mk-window@window(is-modal={ true }, width={ '700px' }, height={ '550px' }) + + i.fa.fa-cog + | 設定 + + + mk-settings + + +style. + > mk-window + [data-yield='header'] + > i + margin-right 4px + + [data-yield='content'] + overflow auto + +script. + @on \mount ~> + @refs.window.on \closed ~> + @unmount! + + @close = ~> + @refs.window.close! diff --git a/src/web/app/desktop/tags/settings.tag b/src/web/app/desktop/tags/settings.tag new file mode 100644 index 0000000000..c6c0340912 --- /dev/null +++ b/src/web/app/desktop/tags/settings.tag @@ -0,0 +1,255 @@ +mk-settings + div.nav + p(class={ active: page == 'account' }, onmousedown={ set-page.bind(null, 'account') }) + i.fa.fa-fw.fa-user + | アカウント + p(class={ active: page == 'web' }, onmousedown={ set-page.bind(null, 'web') }) + i.fa.fa-fw.fa-desktop + | Web + p(class={ active: page == 'notification' }, onmousedown={ set-page.bind(null, 'notification') }) + i.fa.fa-fw.fa-bell-o + | 通知 + p(class={ active: page == 'drive' }, onmousedown={ set-page.bind(null, 'drive') }) + i.fa.fa-fw.fa-cloud + | ドライブ + p(class={ active: page == 'apps' }, onmousedown={ set-page.bind(null, 'apps') }) + i.fa.fa-fw.fa-puzzle-piece + | アプリ + p(class={ active: page == 'signin' }, onmousedown={ set-page.bind(null, 'signin') }) + i.fa.fa-fw.fa-sign-in + | ログイン履歴 + p(class={ active: page == 'password' }, onmousedown={ set-page.bind(null, 'password') }) + i.fa.fa-fw.fa-unlock-alt + | パスワード + p(class={ active: page == 'api' }, onmousedown={ set-page.bind(null, 'api') }) + i.fa.fa-fw.fa-key + | API + + div.pages + section.account(show={ page == 'account' }) + h1 アカウント + label.avatar + p アバター + img.avatar(src={ I.avatar_url + '?thumbnail&size=64' }, alt='avatar') + button.style-normal(onclick={ avatar }) 画像を選択 + label + p 名前 + input@account-name(type='text', value={ I.name }) + label + p 場所 + input@account-location(type='text', value={ I.location }) + label + p 自己紹介 + textarea@account-bio { I.bio } + button.style-primary(onclick={ update-account }) 保存 + + section.web(show={ page == 'web' }) + h1 デザイン + label + p 壁紙 + button.style-normal(onclick={ wallpaper }) 画像を選択 + section.web(show={ page == 'web' }) + h1 その他 + label.checkbox + input(type='checkbox', checked={ I.data.cache }, onclick={ update-cache }) + p 読み込みを高速化する + p API通信時に新鮮なユーザー情報をキャッシュすることでフェッチのオーバーヘッドを無くします。(実験的) + label.checkbox + input(type='checkbox', checked={ I.data.debug }, onclick={ update-debug }) + p 開発者モード + p デバッグ等の開発者モードを有効にします。 + + section.signin(show={ page == 'signin' }) + h1 ログイン履歴 + mk-signin-history + + section.api(show={ page == 'api' }) + h1 API + p + | Token: + code { I.token } + p APIを利用するには、上記のトークンを「i」というキーでパラメータに付加してリクエストします。 + p アカウントを乗っ取られてしまう可能性があるため、このトークンは第三者に教えないでください(アプリなどにも入力しないでください)。 + p + | 万が一このトークンが漏れたりその可能性がある場合は + button.regenerate(onclick={ regenerate-token }) トークンを再生成 + | できます。(副作用として、ログインしているすべてのデバイスでログアウトが発生します) + +style. + display block + + input:not([type]) + input[type='text'] + input[type='password'] + input[type='email'] + textarea + padding 8px + width 100% + font-size 16px + color #55595c + border solid 1px #dadada + border-radius 4px + + &:hover + border-color #aeaeae + + &:focus + border-color #aeaeae + + > .nav + position absolute + top 0 + left 0 + width 200px + height 100% + padding 16px 0 0 0 + border-right solid 1px #ddd + + > p + display block + padding 10px 16px + margin 0 + color #666 + cursor pointer + + -ms-user-select none + -moz-user-select none + -webkit-user-select none + user-select none + + transition margin-left 0.2s ease + + > i + margin-right 4px + + &:hover + color #555 + + &.active + margin-left 8px + color $theme-color !important + + > .pages + position absolute + top 0 + left 200px + width calc(100% - 200px) + + > section + padding 32px + + // & + section + // margin-top 16px + + h1 + display block + margin 0 + padding 0 0 8px 0 + font-size 1em + color #555 + border-bottom solid 1px #eee + + label + display block + margin 16px 0 + + &:after + content "" + display block + clear both + + > p + margin 0 0 8px 0 + font-weight bold + color #373a3c + + &.checkbox + > input + position absolute + top 0 + left 0 + + &:checked + p + color $theme-color + + > p + width calc(100% - 32px) + margin 0 0 0 32px + font-weight bold + + &:last-child + font-weight normal + color #999 + + &.account + > .general + > .avatar + > img + display block + float left + width 64px + height 64px + border-radius 4px + + > button + float left + margin-left 8px + + &.api + code + padding 4px + background #eee + + .regenerate + display inline + color $theme-color + + &:hover + text-decoration underline + +script. + @mixin \i + @mixin \api + @mixin \dialog + @mixin \update-avatar + @mixin \update-wallpaper + + @page = \account + + @set-page = (page) ~> + @page = page + + @avatar = ~> + @update-avatar @I, (i) ~> + @update-i i + + @wallpaper = ~> + @update-wallpaper @I, (i) ~> + @update-i i + + @update-account = ~> + @api \i/update do + name: @refs.account-name.value + location: @refs.account-location.value + bio: @refs.account-bio.value + .then (i) ~> + @update-i i + alert \ok + .catch (err) ~> + console.error err + + @update-cache = ~> + @I.data.cache = !@I.data.cache + @api \i/appdata/set do + data: JSON.stringify do + cache: @I.data.cache + .then ~> + @update-i! + + @update-debug = ~> + @I.data.debug = !@I.data.debug + @api \i/appdata/set do + data: JSON.stringify do + debug: @I.data.debug + .then ~> + @update-i! diff --git a/src/web/app/desktop/tags/signin-history.tag b/src/web/app/desktop/tags/signin-history.tag new file mode 100644 index 0000000000..311f8bfeda --- /dev/null +++ b/src/web/app/desktop/tags/signin-history.tag @@ -0,0 +1,73 @@ +mk-signin-history + div.records(if={ history.length != 0 }) + div(each={ history }) + mk-time(time={ created_at }) + header + i.fa.fa-check(if={ success }) + i.fa.fa-times(if={ !success }) + span.ip { ip } + pre: code { JSON.stringify(headers, null, ' ') } + +style. + display block + + > .records + > div + padding 16px 0 0 0 + border-bottom solid 1px #eee + + > header + + > i + margin-right 8px + + &.fa-check + color #0fda82 + + &.fa-times + color #ff3100 + + > .ip + display inline-block + color #444 + background #f8f8f8 + + > mk-time + position absolute + top 16px + right 0 + color #777 + + > pre + overflow auto + max-height 100px + + > code + white-space pre-wrap + word-break break-all + color #4a535a + +script. + @mixin \api + @mixin \stream + + @history = [] + @fetching = true + + @on \mount ~> + @api \i/signin_history + .then (history) ~> + @history = history + @fetching = false + @update! + .catch (err) ~> + console.error err + + @stream.on \signin @on-signin + + @on \unmount ~> + @stream.off \signin @on-signin + + @on-signin = (signin) ~> + @history.unshift signin + @update! diff --git a/src/web/app/desktop/tags/stream-indicator.tag b/src/web/app/desktop/tags/stream-indicator.tag new file mode 100644 index 0000000000..2eb5889ca6 --- /dev/null +++ b/src/web/app/desktop/tags/stream-indicator.tag @@ -0,0 +1,59 @@ +mk-stream-indicator + p(if={ state == 'initializing' }) + i.fa.fa-spinner.fa-spin + span + | 接続中 + mk-ellipsis + p(if={ state == 'reconnecting' }) + i.fa.fa-spinner.fa-spin + span + | 切断されました 接続中 + mk-ellipsis + p(if={ state == 'connected' }) + i.fa.fa-check + span 接続完了 + +style. + display block + pointer-events none + position fixed + z-index 16384 + bottom 8px + right 8px + margin 0 + padding 6px 12px + font-size 0.9em + color #fff + background rgba(0, 0, 0, 0.8) + + > p + display block + margin 0 + + > i + margin-right 0.25em + +script. + @mixin \stream + + @on \before-mount ~> + @state = @get-stream-state! + + if @state == \connected + @root.style.opacity = 0 + + @stream-state-ev.on \connected ~> + @state = @get-stream-state! + @update! + set-timeout ~> + Velocity @root, { + opacity: 0 + } 200ms \linear + , 1000ms + + @stream-state-ev.on \closed ~> + @state = @get-stream-state! + @update! + Velocity @root, { + opacity: 1 + } 0ms diff --git a/src/web/app/desktop/tags/sub-post-content.tag b/src/web/app/desktop/tags/sub-post-content.tag new file mode 100644 index 0000000000..976a6f398f --- /dev/null +++ b/src/web/app/desktop/tags/sub-post-content.tag @@ -0,0 +1,37 @@ +mk-sub-post-content + div.body + a.reply(if={ post.reply_to_id }): i.fa.fa-reply + span@text + a.quote(if={ post.repost_id }, href={ '/post:' + post.repost_id }) RP: ... + details(if={ post.media }) + summary ({ post.media.length }枚の画像) + mk-images-viewer(images={ post.media }) + +style. + display block + word-wrap break-word + + > .body + > .reply + margin-right 6px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + +script. + @mixin \text + @mixin \user-preview + + @post = @opts.post + + @on \mount ~> + if @post.text? + tokens = @analyze @post.text + @refs.text.innerHTML = @compile tokens, false + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e diff --git a/src/web/app/desktop/tags/timeline-post-sub.tag b/src/web/app/desktop/tags/timeline-post-sub.tag new file mode 100644 index 0000000000..39b1ad7f71 --- /dev/null +++ b/src/web/app/desktop/tags/timeline-post-sub.tag @@ -0,0 +1,95 @@ +mk-timeline-post-sub(title={ title }) + article + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ post.user_id }) + div.main + header + a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }) + | { post.user.name } + span.username + | @{ post.user.username } + a.created-at(href={ CONFIG.url + '/' + post.user.username + '/' + post.id }) + mk-time(time={ post.created_at }) + div.body + mk-sub-post-content.text(post={ post }) + +script. + @mixin \date-stringify + @mixin \user-preview + + @post = @opts.post + + @title = @date-stringify @post.created_at + +style. + display block + margin 0 + padding 0 + font-size 0.9em + + > article + padding 16px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 14px 0 0 + + > .avatar + display block + width 52px + height 52px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 66px) + + > header + margin-bottom 4px + white-space nowrap + line-height 21px + + > .name + display inline + margin 0 + padding 0 + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #d1d8da + + > .created-at + position absolute + top 0 + right 0 + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 diff --git a/src/web/app/desktop/tags/timeline-post.tag b/src/web/app/desktop/tags/timeline-post.tag new file mode 100644 index 0000000000..e23cd6306e --- /dev/null +++ b/src/web/app/desktop/tags/timeline-post.tag @@ -0,0 +1,376 @@ +mk-timeline-post(tabindex='-1', title={ title }, onkeydown={ on-key-down }) + + div.reply-to(if={ p.reply_to }) + mk-timeline-post-sub(post={ p.reply_to }) + + div.repost(if={ is-repost }) + p + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=32' }, alt='avatar') + i.fa.fa-retweet + a.name(href={ CONFIG.url + '/' + post.user.username }, data-user-preview={ post.user_id }) { post.user.name } + | がRepost + mk-time(time={ post.created_at }) + + article + a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username }) + img.avatar(src={ p.user.avatar_url + '?thumbnail&size=64' }, alt='avatar', data-user-preview={ p.user.id }) + div.main + header + a.name(href={ CONFIG.url + '/' + p.user.username }, data-user-preview={ p.user.id }) + | { p.user.name } + span.username + | @{ p.user.username } + a.created-at(href={ url }) + mk-time(time={ p.created_at }) + div.body + div.text + a.reply(if={ p.reply_to }): i.fa.fa-reply + span@text + a.quote(if={ p.repost != null }) RP: + div.media(if={ p.media }) + mk-images-viewer(images={ p.media }) + div.repost(if={ p.repost }) + i.fa.fa-quote-right.fa-flip-horizontal + mk-post-preview.repost(post={ p.repost }) + footer + button(onclick={ reply }, title='返信') + i.fa.fa-reply + p.count(if={ p.replies_count > 0 }) { p.replies_count } + button(onclick={ repost }, title='Repost') + i.fa.fa-retweet + p.count(if={ p.repost_count > 0 }) { p.repost_count } + button(class={ liked: p.is_liked }, onclick={ like }, title='善哉') + i.fa.fa-thumbs-o-up + p.count(if={ p.likes_count > 0 }) { p.likes_count } + button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h + button(onclick={ toggle-detail }, title='詳細') + i.fa.fa-caret-down(if={ !is-detail-opened }) + i.fa.fa-caret-up(if={ is-detail-opened }) + div.detail(if={ is-detail-opened }) + mk-post-status-graph(width='462', height='130', post={ p }) + +style. + display block + margin 0 + padding 0 + background #fff + + &:focus + z-index 1 + + &:after + content "" + pointer-events none + position absolute + top 2px + right 2px + bottom 2px + left 2px + border 2px solid rgba($theme-color, 0.3) + border-radius 4px + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 16px 32px + line-height 28px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + > mk-time + position absolute + top 16px + right 32px + font-size 0.9em + line-height 28px + + & + article + padding-top 8px + + > .reply-to + padding 0 16px + background rgba(0, 0, 0, 0.0125) + + > mk-post-preview + background transparent + + > article + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 16px 0 0 + + > .avatar + display block + width 58px + height 58px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 74px) + + > header + margin-bottom 4px + white-space nowrap + line-height 24px + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .created-at + position absolute + top 0 + right 0 + font-size 0.9em + color #c0c0c0 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.1em + color #717171 + + mk-url-preview + margin-top 8px + + > .reply + margin-right 8px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + + > .media + > img + display block + max-width 100% + + > .repost + margin 8px 0 + + > i:first-child + position absolute + top -8px + left -8px + z-index 1 + color #c0dac6 + font-size 28px + background #fff + + > mk-post-preview + padding 16px + border dashed 1px #c0dac6 + border-radius 8px + + > footer + > button + margin 0 28px 0 0 + padding 0 8px + line-height 32px + font-size 1em + color #ddd + background transparent + border none + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.liked + color $theme-color + + &:last-child + position absolute + right 0 + margin 0 + + > .detail + padding-top 4px + background rgba(0, 0, 0, 0.0125) + +style(theme='dark'). + background #0D0D0D + + > article + + &:hover + > .main > footer > button + color #eee + + > .main + > header + > .left + > .name + color #9e9c98 + + > .username + color #41403f + + > .right + > .time + color #4e4d4b + + > .body + > .text + color #9e9c98 + + > footer + > button + color #9e9c98 + + &:hover + color #fff + + > .count + color #eee + +script. + @mixin \api + @mixin \text + @mixin \date-stringify + @mixin \user-preview + @mixin \NotImplementedException + + @post = @opts.post + @is-repost = @post.repost? and !@post.text? + @p = if @is-repost then @post.repost else @post + + @title = @date-stringify @p.created_at + + @url = CONFIG.url + '/' + @p.user.username + '/' + @p.id + @is-detail-opened = false + + @on \mount ~> + if @p.text? + tokens = if @p._highlight? + then @analyze @p._highlight + else @analyze @p.text + + @refs.text.innerHTML = if @p._highlight? + then @compile tokens, true, false + else @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content + + @reply = ~> + form = document.body.append-child document.create-element \mk-post-form-window + riot.mount form, do + reply: @p + + @repost = ~> + form = document.body.append-child document.create-element \mk-repost-form-window + riot.mount form, do + post: @p + + @like = ~> + if @p.is_liked + @api \posts/likes/delete do + post_id: @p.id + .then ~> + @p.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @p.id + .then ~> + @p.is_liked = true + @update! + + @toggle-detail = ~> + @is-detail-opened = !@is-detail-opened + @update! + + @on-key-down = (e) ~> + should-be-cancel = true + switch + | e.which == 38 or e.which == 74 or (e.which == 9 and e.shift-key) => # ↑, j or Shift+Tab + focus @root, (e) -> e.previous-element-sibling + | e.which == 40 or e.which == 75 or e.which == 9 => # ↓, k or Tab + focus @root, (e) -> e.next-element-sibling + | e.which == 69 => # e + @repost! + | e.which == 70 or e.which == 76 => # f or l + @like! + | e.which == 82 => # r + @reply! + | _ => + should-be-cancel = false + + if should-be-cancel + e.prevent-default! + + function focus(el, fn) + target = fn el + if target? + if target.has-attribute \tabindex + target.focus! + else + focus target, fn diff --git a/src/web/app/desktop/tags/timeline.tag b/src/web/app/desktop/tags/timeline.tag new file mode 100644 index 0000000000..dfd1b7c14e --- /dev/null +++ b/src/web/app/desktop/tags/timeline.tag @@ -0,0 +1,86 @@ +mk-timeline + virtual(each={ post, i in posts }) + mk-timeline-post(post={ post }) + p.date(if={ i != posts.length - 1 && post._date != posts[i + 1]._date }) + span + i.fa.fa-angle-up + | { post._datetext } + span + i.fa.fa-angle-down + | { posts[i + 1]._datetext } + footer(data-yield='footer') + | + +style. + display block + + > mk-timeline-post + border-bottom solid 1px #eaeaea + + &:first-child + border-top-left-radius 4px + border-top-right-radius 4px + + &:last-of-type + border-bottom none + + > .date + display block + margin 0 + line-height 32px + font-size 14px + text-align center + color #aaa + background #fdfdfd + border-bottom solid 1px #eaeaea + + span + margin 0 16px + + i + margin-right 8px + + > footer + padding 16px + text-align center + color #ccc + border-top solid 1px #eaeaea + border-bottom-left-radius 4px + border-bottom-right-radius 4px + +style(theme='dark'). + > mk-timeline-post + border-bottom-color #222221 + +script. + @posts = [] + + @set-posts = (posts) ~> + @posts = posts + @update! + + @prepend-posts = (posts) ~> + posts.for-each (post) ~> + @posts.push post + @update! + + @add-post = (post) ~> + @posts.unshift post + @update! + + @clear = ~> + @posts = [] + @update! + + @focus = ~> + @root.children.0.focus! + + @on \update ~> + @posts.for-each (post) ~> + date = (new Date post.created_at).get-date! + month = (new Date post.created_at).get-month! + 1 + post._date = date + post._datetext = month + '月 ' + date + '日' + + @tail = ~> + @posts[@posts.length - 1] diff --git a/src/web/app/desktop/tags/ui-header-account.tag b/src/web/app/desktop/tags/ui-header-account.tag new file mode 100644 index 0000000000..ffb1eeec00 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-account.tag @@ -0,0 +1,219 @@ +mk-ui-header-account + button.header(data-active={ is-open.toString() }, onclick={ toggle }) + span.username + | { I.username } + i.fa.fa-angle-down(if={ !is-open }) + i.fa.fa-angle-up(if={ is-open }) + img.avatar(src={ I.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.menu(if={ is-open }) + ul + li: a(href={ '/' + I.username }) + i.fa.fa-user + | プロフィール + i.fa.fa-angle-right + li(onclick={ drive }): p + i.fa.fa-cloud + | ドライブ + i.fa.fa-angle-right + li: a(href='/i>mentions') + i.fa.fa-at + | あなた宛て + i.fa.fa-angle-right + ul + li(onclick={ settings }): p + i.fa.fa-cog + | 設定 + i.fa.fa-angle-right + ul + li(onclick={ signout }): p + i(class='fa fa-power-off') + | サインアウト + i.fa.fa-angle-right + +style. + display block + float left + + > .header + display block + margin 0 + padding 0 + color #9eaba8 + border none + background transparent + cursor pointer + + * + pointer-events none + + &:hover + color darken(#9eaba8, 20%) + + &:active + color darken(#9eaba8, 30%) + + &[data-active='true'] + color darken(#9eaba8, 20%) + + > .avatar + $saturate = 150% + filter saturate($saturate) + -webkit-filter saturate($saturate) + -moz-filter saturate($saturate) + -ms-filter saturate($saturate) + + > .username + display block + float left + margin 0 12px 0 16px + max-width 16em + line-height 48px + font-weight bold + font-family Meiryo, sans-serif + text-decoration none + + i + margin-left 8px + + > .avatar + display block + float left + min-width 32px + max-width 32px + min-height 32px + max-height 32px + margin 8px 8px 8px 0 + border-radius 4px + transition filter 100ms ease + + > .menu + display block + position absolute + top 56px + right -2px + width 230px + font-size 0.8em + background #fff + border-radius 4px + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + + &:before + content "" + pointer-events none + display block + position absolute + top -28px + right 12px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px rgba(0, 0, 0, 0.1) + border-left solid 14px transparent + + &:after + content "" + pointer-events none + display block + position absolute + top -27px + right 12px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px #fff + border-left solid 14px transparent + + ul + display block + margin 10px 0 + padding 0 + list-style none + + & + ul + padding-top 10px + border-top solid 1px #eee + + > li + display block + margin 0 + padding 0 + + > a + > p + display block + z-index 1 + padding 0 28px + margin 0 + line-height 40px + color #868C8C + cursor pointer + + * + pointer-events none + + > i:first-of-type + margin-right 6px + + > i:last-of-type + display block + position absolute + top 0 + right 8px + z-index 1 + padding 0 20px + font-size 1.2em + line-height 40px + + &:hover, &:active + text-decoration none + background $theme-color + color $theme-color-foreground + +script. + @mixin \i + @mixin \signout + + @is-open = false + + @on \before-unmount ~> + @close! + + @toggle = ~> + if @is-open + @close! + else + @open! + + @open = ~> + @is-open = true + @update! + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.add-event-listener \mousedown @mousedown + + @close = ~> + @is-open = false + @update! + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.remove-event-listener \mousedown @mousedown + + @mousedown = (e) ~> + e.prevent-default! + if (!contains @root, e.target) and (@root != e.target) + @close! + return false + + @drive = ~> + @close! + riot.mount document.body.append-child document.create-element \mk-drive-browser-window + + @settings = ~> + @close! + riot.mount document.body.append-child document.create-element \mk-settings-window + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/ui-header-clock.tag b/src/web/app/desktop/tags/ui-header-clock.tag new file mode 100644 index 0000000000..987907a684 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-clock.tag @@ -0,0 +1,82 @@ +mk-ui-header-clock + div.header + time@time + div.content + mk-analog-clock + +style. + display inline-block + overflow visible + + > .header + padding 0 12px + text-align center + font-size 0.5em + + &, * + cursor: default + + &:hover + background #899492 + + & + .content + visibility visible + + > time + color #fff !important + + * + color #fff !important + + &:after + content "" + display block + clear both + + > time + display table-cell + vertical-align middle + height 48px + color #9eaba8 + + > .yyyymmdd + opacity 0.7 + + > .content + visibility hidden + display block + position absolute + top auto + right 0 + z-index 3 + margin 0 + padding 0 + width 256px + background #899492 + +script. + @draw = ~> + now = new Date! + + yyyy = now.get-full-year! + mm = (\0 + (now.get-month! + 1)).slice -2 + dd = (\0 + now.get-date!).slice -2 + yyyymmdd = "#yyyy/#mm/#dd" + + hh = (\0 + now.get-hours!).slice -2 + mm = (\0 + now.get-minutes!).slice -2 + hhmm = "#hh:#mm" + + if now.get-seconds! % 2 == 0 + hhmm .= replace \: ':' + else + hhmm .= replace \: ':' + + @refs.time.innerHTML = "#yyyymmdd
    #hhmm" + + @on \mount ~> + @draw! + @clock = set-interval @draw, 1000ms + + @on \unmount ~> + clear-interval @clock diff --git a/src/web/app/desktop/tags/ui-header-nav.tag b/src/web/app/desktop/tags/ui-header-nav.tag new file mode 100644 index 0000000000..153c3137b4 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-nav.tag @@ -0,0 +1,113 @@ +mk-ui-header-nav: ul(if={ SIGNIN }) + li.home(class={ active: page == 'home' }): a(href={ CONFIG.url }) + i.fa.fa-home + p ホーム + li.messaging: a(onclick={ messaging }) + i.fa.fa-comments + p メッセージ + i.fa.fa-circle(if={ has-unread-messaging-messages }) + li.info: a(href='https://twitter.com/misskey_xyz', target='_blank') + i.fa.fa-info + p お知らせ + li.tv: a(href='https://misskey.tk', target='_blank') + i.fa.fa-television + p MisskeyTV™ + +style. + display inline-block + margin 0 + padding 0 + line-height 3rem + vertical-align top + + > ul + display inline-block + margin 0 + padding 0 + vertical-align top + line-height 3rem + list-style none + + > li + display inline-block + vertical-align top + height 48px + line-height 48px + + &.active + > a + border-bottom solid 3px $theme-color + + > a + display inline-block + z-index 1 + height 100% + padding 0 24px + font-size 1em + font-variant small-caps + color #9eaba8 + text-decoration none + transition none + cursor pointer + + * + pointer-events none + + &:hover + color darken(#9eaba8, 20%) + text-decoration none + + > i:first-child + margin-right 8px + + > i:last-child + margin-left 5px + vertical-align super + font-size 10px + color $theme-color + + @media (max-width 1100px) + margin-left -5px + + > p + display inline + margin 0 + + @media (max-width 1100px) + display none + + @media (max-width 700px) + padding 0 12px + +script. + @mixin \i + @mixin \api + @mixin \stream + + @page = @opts.page + + @on \mount ~> + @stream.on \read_all_messaging_messages @on-read-all-messaging-messages + @stream.on \unread_messaging_message @on-unread-messaging-message + + # Fetch count of unread messaging messages + @api \messaging/unread + .then (count) ~> + if count.count > 0 + @has-unread-messaging-messages = true + @update! + + @on \unmount ~> + @stream.off \read_all_messaging_messages @on-read-all-messaging-messages + @stream.off \unread_messaging_message @on-unread-messaging-message + + @on-read-all-messaging-messages = ~> + @has-unread-messaging-messages = false + @update! + + @on-unread-messaging-message = ~> + @has-unread-messaging-messages = true + @update! + + @messaging = ~> + riot.mount document.body.append-child document.create-element \mk-messaging-window diff --git a/src/web/app/desktop/tags/ui-header-notifications.tag b/src/web/app/desktop/tags/ui-header-notifications.tag new file mode 100644 index 0000000000..495aad5004 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-notifications.tag @@ -0,0 +1,111 @@ +mk-ui-header-notifications + button.header(data-active={ is-open }, onclick={ toggle }) + i.fa.fa-bell-o + div.notifications(if={ is-open }) + mk-notifications + +style. + display block + float left + + > .header + display block + margin 0 + padding 0 + width 32px + color #9eaba8 + border none + background transparent + cursor pointer + + * + pointer-events none + + &:hover + color darken(#9eaba8, 20%) + + &:active + color darken(#9eaba8, 30%) + + &[data-active='true'] + color darken(#9eaba8, 20%) + + > i + font-size 1.2em + line-height 48px + + > .notifications + display block + position absolute + top 56px + right -72px + width 300px + background #fff + border-radius 4px + box-shadow 0 1px 4px rgba(0, 0, 0, 0.25) + + &:before + content "" + pointer-events none + display block + position absolute + top -28px + right 74px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px rgba(0, 0, 0, 0.1) + border-left solid 14px transparent + + &:after + content "" + pointer-events none + display block + position absolute + top -27px + right 74px + border-top solid 14px transparent + border-right solid 14px transparent + border-bottom solid 14px #fff + border-left solid 14px transparent + + > mk-notifications + max-height 350px + font-size 1rem + overflow auto + +script. + @is-open = false + + @toggle = ~> + if @is-open + @close! + else + @open! + + @open = ~> + @is-open = true + @update! + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.add-event-listener \mousedown @mousedown + + @close = ~> + @is-open = false + @update! + all = document.query-selector-all 'body *' + Array.prototype.for-each.call all, (el) ~> + el.remove-event-listener \mousedown @mousedown + + @mousedown = (e) ~> + e.prevent-default! + if (!contains @root, e.target) and (@root != e.target) + @close! + return false + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/desktop/tags/ui-header-post-button.tag b/src/web/app/desktop/tags/ui-header-post-button.tag new file mode 100644 index 0000000000..558c987619 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-post-button.tag @@ -0,0 +1,39 @@ +mk-ui-header-post-button + button(onclick={ post }, title='新規投稿') + i.fa.fa-pencil-square-o + +style. + display inline-block + padding 8px + height 100% + vertical-align top + + > button + display inline-block + margin 0 + padding 0 10px + height 100% + font-size 1.2em + font-weight normal + text-decoration none + color $theme-color-foreground + background $theme-color !important + outline none + border none + border-radius 2px + transition background 0.1s ease + cursor pointer + + * + pointer-events none + + &:hover + background lighten($theme-color, 10%) !important + + &:active + background darken($theme-color, 10%) !important + transition background 0s ease + +script. + @post = (e) ~> + @parent.parent.open-post-form! diff --git a/src/web/app/desktop/tags/ui-header-search.tag b/src/web/app/desktop/tags/ui-header-search.tag new file mode 100644 index 0000000000..24e4e44989 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header-search.tag @@ -0,0 +1,37 @@ +mk-ui-header-search + form.search(onsubmit={ onsubmit }) + input@q(type='search', placeholder!=' 検索') + div.result + +style. + + > form + display block + float left + + > input + user-select text + cursor auto + margin 0 + padding 6px 18px + width 14em + height 48px + font-size 1em + line-height calc(48px - 12px) + background transparent + outline none + //border solid 1px #ddd + border none + border-radius 0 + transition color 0.5s ease, border 0.5s ease + font-family FontAwesome, sans-serif + + &::-webkit-input-placeholder + color #9eaba8 + +script. + @mixin \page + + @onsubmit = (e) ~> + e.prevent-default! + @page '/search:' + @refs.q.value diff --git a/src/web/app/desktop/tags/ui-header.tag b/src/web/app/desktop/tags/ui-header.tag new file mode 100644 index 0000000000..b02817cd84 --- /dev/null +++ b/src/web/app/desktop/tags/ui-header.tag @@ -0,0 +1,85 @@ +mk-ui-header + mk-donation(if={ SIGNIN && !I.data.no_donation }) + mk-special-message + div.main + div.backdrop + div.main: div.container + div.left + mk-ui-header-nav(page={ opts.page }) + div.right + mk-ui-header-search + mk-ui-header-account(if={ SIGNIN }) + mk-ui-header-notifications(if={ SIGNIN }) + mk-ui-header-post-button(if={ SIGNIN }) + mk-ui-header-clock + +style. + display block + position fixed + top 0 + z-index 1024 + width 100% + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + > .main + + > .backdrop + position absolute + top 0 + z-index 1023 + width 100% + height 48px + backdrop-filter blur(12px) + //background-color rgba(255, 255, 255, 0.75) + background #fff + + &:after + content "" + display block + width 100% + height 48px + background-image url(/_/resources/desktop/header-logo.svg) + background-size 64px + background-position center + background-repeat no-repeat + + > .main + z-index 1024 + margin 0 + padding 0 + background-clip content-box + font-size 0.9rem + user-select none + + > .container + width 100% + max-width 1300px + margin 0 auto + + &:after + content "" + display block + clear both + + > .left + float left + height 3rem + + > .right + float right + height 48px + + @media (max-width 1100px) + > mk-ui-header-search + display none + +style(theme='dark'). + box-shadow 0 1px 0 #222221 + + > .main + + > .backdrop + background #0D0D0D + +script. + @mixin \i diff --git a/src/web/app/desktop/tags/ui-notification.tag b/src/web/app/desktop/tags/ui-notification.tag new file mode 100644 index 0000000000..6e5f948b88 --- /dev/null +++ b/src/web/app/desktop/tags/ui-notification.tag @@ -0,0 +1,41 @@ +mk-ui-notification + p { opts.message } + +style. + display block + position fixed + z-index 10000 + top -64px + left 0 + right 0 + margin 0 auto + width 500px + color rgba(#000, 0.6) + background rgba(#fff, 0.9) + border-radius 0 0 8px 8px + box-shadow 0 2px 4px rgba(#000, 0.2) + + > p + margin 0 + line-height 64px + text-align center + +script. + @on \mount ~> + Velocity @root, { + top: \0px + } { + duration: 500ms + easing: \ease-out + } + + set-timeout ~> + Velocity @root, { + top: \-64px + } { + duration: 500ms + easing: \ease-out + complete: ~> + @unmount! + } + , 6000ms diff --git a/src/web/app/desktop/tags/ui.tag b/src/web/app/desktop/tags/ui.tag new file mode 100644 index 0000000000..6bced1f9e4 --- /dev/null +++ b/src/web/app/desktop/tags/ui.tag @@ -0,0 +1,37 @@ +mk-ui + div.global@global + mk-ui-header@header(page={ opts.page }) + + mk-set-avatar-suggestion(if={ SIGNIN && I.avatar_id == null }) + mk-set-banner-suggestion(if={ SIGNIN && I.banner_id == null }) + + div.content + + + mk-stream-indicator + +style. + display block + +script. + @mixin \i + + @open-post-form = ~> + riot.mount document.body.append-child document.create-element \mk-post-form-window + + @set-root-layout = ~> + @root.style.padding-top = @refs.header.root.client-height + \px + + @on \mount ~> + @set-root-layout! + document.add-event-listener \keydown @onkeydown + + @on \unmount ~> + document.remove-event-listener \keydown @onkeydown + + @onkeydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 80 or e.which == 78 # p or n + e.prevent-default! + @open-post-form! diff --git a/src/web/app/desktop/tags/user-followers-window.tag b/src/web/app/desktop/tags/user-followers-window.tag new file mode 100644 index 0000000000..d18b04446c --- /dev/null +++ b/src/web/app/desktop/tags/user-followers-window.tag @@ -0,0 +1,22 @@ +mk-user-followers-window + mk-window(is-modal={ false }, width={ '400px' }, height={ '550px' }) + + img(src={ parent.user.avatar_url + '?thumbnail&size=64' }, alt='') + | { parent.user.name }のフォロワー + + + mk-user-followers(user={ parent.user }) + + +style. + > mk-window + [data-yield='header'] + > img + display inline-block + vertical-align bottom + height calc(100% - 10px) + margin 5px + border-radius 4px + +script. + @user = @opts.user diff --git a/src/web/app/desktop/tags/user-followers.tag b/src/web/app/desktop/tags/user-followers.tag new file mode 100644 index 0000000000..52f9f43836 --- /dev/null +++ b/src/web/app/desktop/tags/user-followers.tag @@ -0,0 +1,19 @@ +mk-user-followers + mk-users-list(fetch={ fetch }, count={ user.followers_count }, you-know-count={ user.followers_you_know_count }, no-users={ 'フォロワーはいないようです。' }) + +style. + display block + height 100% + +script. + @mixin \api + + @user = @opts.user + + @fetch = (iknow, limit, cursor, cb) ~> + @api \users/followers do + user_id: @user.id + iknow: iknow + limit: limit + cursor: if cursor? then cursor else undefined + .then cb diff --git a/src/web/app/desktop/tags/user-following-window.tag b/src/web/app/desktop/tags/user-following-window.tag new file mode 100644 index 0000000000..91f94f08d3 --- /dev/null +++ b/src/web/app/desktop/tags/user-following-window.tag @@ -0,0 +1,22 @@ +mk-user-following-window + mk-window(is-modal={ false }, width={ '400px' }, height={ '550px' }) + + img(src={ parent.user.avatar_url + '?thumbnail&size=64' }, alt='') + | { parent.user.name }のフォロー + + + mk-user-following(user={ parent.user }) + + +style. + > mk-window + [data-yield='header'] + > img + display inline-block + vertical-align bottom + height calc(100% - 10px) + margin 5px + border-radius 4px + +script. + @user = @opts.user diff --git a/src/web/app/desktop/tags/user-following.tag b/src/web/app/desktop/tags/user-following.tag new file mode 100644 index 0000000000..0a39f2e4b8 --- /dev/null +++ b/src/web/app/desktop/tags/user-following.tag @@ -0,0 +1,19 @@ +mk-user-following + mk-users-list(fetch={ fetch }, count={ user.following_count }, you-know-count={ user.following_you_know_count }, no-users={ 'フォロー中のユーザーはいないようです。' }) + +style. + display block + height 100% + +script. + @mixin \api + + @user = @opts.user + + @fetch = (iknow, limit, cursor, cb) ~> + @api \users/following do + user_id: @user.id + iknow: iknow + limit: limit + cursor: if cursor? then cursor else undefined + .then cb diff --git a/src/web/app/desktop/tags/user-friends-graph.tag b/src/web/app/desktop/tags/user-friends-graph.tag new file mode 100644 index 0000000000..47c3a15613 --- /dev/null +++ b/src/web/app/desktop/tags/user-friends-graph.tag @@ -0,0 +1,64 @@ +mk-user-friends-graph + canvas@canv(width='750', height='250') + +style. + display block + width 750px + height 250px + +script. + @mixin \api + @mixin \is-promise + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + + @on \mount ~> + user <~ @user-promise.then + @user = user + @update! + + @api \aggregation/users/followers do + user_id: @user.id + limit: 30days + .then (followers) ~> + followers = followers.reverse! + + @api \aggregation/users/following do + user_id: @user.id + limit: 30days + .then (following) ~> + following = following.reverse! + + new Chart @refs.canv, do + type: \line + data: + labels: following.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else '' + datasets: [ + { + label: \フォロー + data: following.map (x) ~> x.count + line-tension: 0 + border-width: 2 + fill: true + background-color: 'rgba(127, 221, 64, 0.2)' + point-background-color: \#fff + point-radius: 4 + point-border-width: 2 + border-color: \#7fdd40 + }, + { + label: \フォロワー + data: followers.map (x) ~> x.count + line-tension: 0 + border-width: 2 + fill: true + background-color: 'rgba(255, 99, 132, 0.2)' + point-background-color: \#fff + point-radius: 4 + point-border-width: 2 + border-color: \#FF6384 + } + ] + options: + responsive: false diff --git a/src/web/app/desktop/tags/user-graphs.tag b/src/web/app/desktop/tags/user-graphs.tag new file mode 100644 index 0000000000..f7f0fcd5e0 --- /dev/null +++ b/src/web/app/desktop/tags/user-graphs.tag @@ -0,0 +1,36 @@ +mk-user-graphs + section + h1 投稿 + mk-user-posts-graph(user={ opts.user }) + + section + h1 フォロー/フォロワー + mk-user-friends-graph(user={ opts.user }) + + section + h1 いいね + mk-user-likes-graph(user={ opts.user }) + +style. + display block + + > section + margin 16px 0 + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + + > h1 + margin 0 0 8px 0 + padding 0 16px + line-height 40px + font-size 1em + color #666 + border-bottom solid 1px #eee + + > *:not(h1) + margin 0 auto 16px auto + +script. + @on \mount ~> + @trigger \loaded diff --git a/src/web/app/desktop/tags/user-header.tag b/src/web/app/desktop/tags/user-header.tag new file mode 100644 index 0000000000..5abd79ff1c --- /dev/null +++ b/src/web/app/desktop/tags/user-header.tag @@ -0,0 +1,143 @@ +mk-user-header(data-is-dark-background={ user.banner_url != null }) + div.banner@banner(style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=1024)' : '' }, onclick={ on-update-banner }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=150' }, alt='avatar') + div.title + p.name(href={ CONFIG.url + '/' + user.username }) { user.name } + p.username @{ user.username } + p.location(if={ user.location }) + i.fa.fa-map-marker + | { user.location } + footer + a(href={ '/' + user.username }) 投稿 + a(href={ '/' + user.username + '/media' }) メディア + a(href={ '/' + user.username + '/graphs' }) グラフ + button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h + +style. + $footer-height = 58px + + display block + background #fff + + &[data-is-dark-background] + > .banner + background-color #383838 + + > .title + color #fff + background linear-gradient(transparent, rgba(0, 0, 0, 0.7)) + + > .name + text-shadow 0 0 8px #000 + + > .banner + height 280px + background-color #f5f5f5 + background-size cover + background-position center + + > .avatar + display block + position absolute + bottom 16px + left 16px + z-index 2 + width 150px + height 150px + margin 0 + border solid 3px #fff + border-radius 8px + box-shadow 1px 1px 3px rgba(0, 0, 0, 0.2) + + > .title + position absolute + bottom $footer-height + left 0 + width 100% + padding 0 0 8px 195px + color #656565 + font-family '游ゴシック', 'YuGothic', 'ヒラギノ角ゴ ProN W3', 'Hiragino Kaku Gothic ProN', 'Meiryo', 'メイリオ', sans-serif + + > .name + display block + margin 0 + line-height 40px + font-weight bold + font-size 2em + + > .username + > .location + display inline-block + margin 0 16px 0 0 + line-height 20px + opacity 0.8 + + > i + margin-right 4px + + > footer + z-index 1 + height $footer-height + padding-left 195px + background #fff + + > a + display inline-block + margin 0 + width 100px + line-height $footer-height + color #555 + + > button + display block + position absolute + top 0 + right 0 + margin 8px + padding 0 + width $footer-height - 16px + line-height $footer-height - 16px - 2px + font-size 1.2em + color #777 + border solid 1px #eee + border-radius 4px + + &:hover + color #555 + border solid 1px #ddd + +script. + @mixin \i + @mixin \update-banner + @mixin \NotImplementedException + + @user = @opts.user + + @on \mount ~> + window.add-event-listener \load @scroll + window.add-event-listener \scroll @scroll + window.add-event-listener \resize @scroll + + @on \unmount ~> + window.remove-event-listener \load @scroll + window.remove-event-listener \scroll @scroll + window.remove-event-listener \resize @scroll + + @scroll = ~> + top = window.scroll-y + height = 280px + + pos = 50 - ((top / height) * 50) + @refs.banner.style.background-position = 'center ' + pos + '%' + + blur = top / 32 + if blur <= 10 + @refs.banner.style.filter = 'blur(' + blur + 'px)' + + @on-update-banner = ~> + if not @SIGNIN or @I.id != @user.id + return + + @update-banner @I, (i) ~> + @user.banner_url = i.banner_url + @update! diff --git a/src/web/app/desktop/tags/user-home.tag b/src/web/app/desktop/tags/user-home.tag new file mode 100644 index 0000000000..4bf0260ff6 --- /dev/null +++ b/src/web/app/desktop/tags/user-home.tag @@ -0,0 +1,40 @@ +mk-user-home + div.side + mk-user-profile(user={ user }) + mk-user-photos(user={ user }) + main + mk-user-timeline@tl(user={ user }) + +style. + display flex + justify-content center + + > * + > * + display block + //border solid 1px #eaeaea + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + + &:not(:last-child) + margin-bottom 16px + + > main + flex 1 1 560px + max-width 560px + margin 0 + padding 16px 0 16px 16px + + > .side + flex 1 1 270px + max-width 270px + margin 0 + padding 16px 0 16px 0 + +script. + @user = @opts.user + + @on \mount ~> + @refs.tl.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/desktop/tags/user-likes-graph.tag b/src/web/app/desktop/tags/user-likes-graph.tag new file mode 100644 index 0000000000..e9d1428713 --- /dev/null +++ b/src/web/app/desktop/tags/user-likes-graph.tag @@ -0,0 +1,39 @@ +mk-user-likes-graph + canvas@canv(width='750', height='250') + +style. + display block + width 750px + height 250px + +script. + @mixin \api + @mixin \is-promise + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + + @on \mount ~> + user <~ @user-promise.then + @user = user + @update! + + @api \aggregation/users/like do + user_id: @user.id + limit: 30days + .then (likes) ~> + likes = likes.reverse! + + new Chart @refs.canv, do + type: \bar + data: + labels: likes.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else '' + datasets: [ + { + label: \いいねした数 + data: likes.map (x) ~> x.count + background-color: \#F7796C + } + ] + options: + responsive: false diff --git a/src/web/app/desktop/tags/user-photos.tag b/src/web/app/desktop/tags/user-photos.tag new file mode 100644 index 0000000000..61a840ee61 --- /dev/null +++ b/src/web/app/desktop/tags/user-photos.tag @@ -0,0 +1,85 @@ +mk-user-photos + p.title + i.fa.fa-camera + | フォト + p.initializing(if={ initializing }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + div.stream(if={ !initializing && images.length > 0 }) + virtual(each={ image in images }) + div.img(style={ 'background-image: url(' + image.url + '?thumbnail&size=256)' }) + p.empty(if={ !initializing && images.length == 0 }) + | 写真はありません + +style. + display block + background #fff + + > .title + z-index 1 + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > i + margin-right 4px + + > .stream + display -webkit-flex + display -moz-flex + display -ms-flex + display flex + justify-content center + flex-wrap wrap + padding 8px + + > .img + flex 1 1 33% + width 33% + height 80px + background-position center center + background-size cover + background-clip content-box + border solid 2px transparent + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \is-promise + + @images = [] + @initializing = true + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @update! + + @api \users/posts do + user_id: @user.id + with_media: true + limit: 9posts + .then (posts) ~> + @initializing = false + posts.for-each (post) ~> + post.media.for-each (image) ~> + if @images.length < 9 + @images.push image + @update! diff --git a/src/web/app/desktop/tags/user-posts-graph.tag b/src/web/app/desktop/tags/user-posts-graph.tag new file mode 100644 index 0000000000..75f4ac4a67 --- /dev/null +++ b/src/web/app/desktop/tags/user-posts-graph.tag @@ -0,0 +1,68 @@ +mk-user-posts-graph + canvas@canv(width='750', height='250') + +style. + display block + width 750px + height 250px + +script. + @mixin \api + @mixin \is-promise + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + + @on \mount ~> + user <~ @user-promise.then + @user = user + @update! + + @api \aggregation/users/post do + user_id: @user.id + limit: 30days + .then (data) ~> + data = data.reverse! + new Chart @refs.canv, do + type: \line + data: + labels: data.map (x, i) ~> if i % 3 == 2 then x.date.day + '日' else '' + datasets: [ + { + label: \投稿 + data: data.map (x) ~> x.posts + line-tension: 0 + point-radius: 0 + background-color: \#555 + border-color: \transparent + }, + { + label: \Repost + data: data.map (x) ~> x.reposts + line-tension: 0 + point-radius: 0 + background-color: \#a2d61e + border-color: \transparent + }, + { + label: \返信 + data: data.map (x) ~> x.replies + line-tension: 0 + point-radius: 0 + background-color: \#F7796C + border-color: \transparent + } + ] + options: + responsive: false + scales: + x-axes: [ + { + stacked: true + } + ] + y-axes: [ + { + stacked: true + } + ] diff --git a/src/web/app/desktop/tags/user-preview.tag b/src/web/app/desktop/tags/user-preview.tag new file mode 100644 index 0000000000..f299e6236e --- /dev/null +++ b/src/web/app/desktop/tags/user-preview.tag @@ -0,0 +1,143 @@ +mk-user-preview + virtual(if={ user != null }) + div.banner(style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=512)' : '' }) + a.avatar(href={ CONFIG.url + '/' + user.username }, target='_blank'): img(src={ user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.title + p.name { user.name } + p.username @{ user.username } + div.bio { user.bio } + div.status + div + p 投稿 + a { user.posts_count } + div + p フォロー + a { user.following_count } + div + p フォロワー + a { user.followers_count } + mk-follow-button(if={ SIGNIN && user.id != I.id }, user={ user-promise }) + +style. + display block + position absolute + z-index 2048 + width 250px + background #fff + background-clip content-box + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + overflow hidden + + // https://github.com/riot/riot/issues/2081 + > virtual + display block + position relative + + > .banner + height 84px + background-color #f5f5f5 + background-size cover + background-position center + + > .avatar + display block + position absolute + top 62px + left 13px + + > img + display block + width 58px + height 58px + margin 0 + border solid 3px #fff + border-radius 8px + + > .title + display block + padding 8px 0 8px 85px + + > .name + display block + margin 0 + font-weight bold + line-height 16px + color #656565 + + > .username + display block + margin 0 + line-height 16px + font-size 0.8em + color #999 + + > .bio + padding 0 16px + font-size 0.7em + color #555 + + > .status + padding 8px 16px + + > div + display inline-block + width 33% + + > p + margin 0 + font-size 0.7em + color #aaa + + > a + font-size 1em + color $theme-color + + > mk-follow-button + position absolute + top 92px + right 8px + +script. + @mixin \i + @mixin \api + + @u = @opts.user + @user = null + @user-promise = + if typeof @u == \string + new Promise (resolve, reject) ~> + @api \users/show do + user_id: if @u.0 == \@ then undefined else @u + username: if @u.0 == \@ then @u.substr 1 else undefined + .then (user) ~> + resolve user + else + Promise.resolve @u + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @update! + + Velocity @root, { + opacity: 0 + 'margin-top': \-8px + } 0ms + Velocity @root, { + opacity: 1 + 'margin-top': 0 + } { + duration: 200ms + easing: \ease-out + } + + @close = ~> + Velocity @root, { + opacity: 0 + 'margin-top': \-8px + } { + duration: 200ms + easing: \ease-out + complete: ~> @unmount! + } diff --git a/src/web/app/desktop/tags/user-profile.tag b/src/web/app/desktop/tags/user-profile.tag new file mode 100644 index 0000000000..195aefcdf7 --- /dev/null +++ b/src/web/app/desktop/tags/user-profile.tag @@ -0,0 +1,72 @@ +mk-user-profile + div.friend-form(if={ SIGNIN && I.id != user.id }) + mk-big-follow-button(user={ user }) + p.followed(if={ user.is_followed }) フォローされています + div.bio(if={ user.bio != '' }) { user.bio } + div.friends + p.following + i.fa.fa-angle-right + a(onclick={ show-following }) { user.following_count } + | 人を + b フォロー + p.followers + i.fa.fa-angle-right + a(onclick={ show-followers }) { user.followers_count } + | 人の + b フォロワー + +style. + display block + background #fff + + > *:first-child + border-top none !important + + > .friend-form + padding 16px + border-top solid 1px #eee + + > mk-big-follow-button + width 100% + + > .followed + margin 12px 0 0 0 + padding 0 + text-align center + line-height 24px + font-size 0.8em + color #71afc7 + background #eefaff + border-radius 4px + + > .bio + padding 16px + color #555 + border-top solid 1px #eee + + > .friends + padding 16px + color #555 + border-top solid 1px #eee + + > p + margin 8px 0 + + > i + margin-left 8px + margin-right 8px + +script. + @mixin \i + + @user = @opts.user + + @show-following = ~> + window = document.body.append-child document.create-element \mk-user-following-window + riot.mount window, do + user: @user + + @show-followers = ~> + window = document.body.append-child document.create-element \mk-user-followers-window + riot.mount window, do + user: @user diff --git a/src/web/app/desktop/tags/user-timeline.tag b/src/web/app/desktop/tags/user-timeline.tag new file mode 100644 index 0000000000..ced90e2e84 --- /dev/null +++ b/src/web/app/desktop/tags/user-timeline.tag @@ -0,0 +1,142 @@ +mk-user-timeline + header + span(data-is-active={ mode == 'default' }, onclick={ set-mode.bind(this, 'default') }) 投稿 + span(data-is-active={ mode == 'with-replies' }, onclick={ set-mode.bind(this, 'with-replies') }) 投稿と返信 + div.loading(if={ is-loading }) + mk-ellipsis-icon + p.empty(if={ is-empty }) + i.fa.fa-comments-o + | このユーザーはまだ何も投稿していないようです。 + mk-timeline@timeline + + i.fa.fa-moon-o(if={ !parent.more-loading }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ parent.more-loading }) + + +style. + display block + background #fff + + > header + padding 8px 16px + border-bottom solid 1px #eee + + > span + margin-right 16px + line-height 27px + font-size 18px + color #555 + + &:not([data-is-active]) + color $theme-color + cursor pointer + + &:hover + text-decoration underline + + > .loading + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + +script. + @mixin \api + @mixin \is-promise + @mixin \get-post-summary + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + @is-loading = true + @is-empty = false + @more-loading = false + @unread-count = 0 + @mode = \default + + @on \mount ~> + document.add-event-listener \visibilitychange @window-on-visibilitychange, false + document.add-event-listener \keydown @on-document-keydown + window.add-event-listener \scroll @on-scroll + + @user-promise.then (user) ~> + @user = user + @update! + + @fetch ~> + @trigger \loaded + + @on \unmount ~> + document.remove-event-listener \visibilitychange @window-on-visibilitychange + document.remove-event-listener \keydown @on-document-keydown + window.remove-event-listener \scroll @on-scroll + + @on-document-keydown = (e) ~> + tag = e.target.tag-name.to-lower-case! + if tag != \input and tag != \textarea + if e.which == 84 # t + @refs.timeline.focus! + + @fetch = (cb) ~> + @api \users/posts do + user_id: @user.id + with_replies: @mode == \with-replies + .then (posts) ~> + @is-loading = false + @is-empty = posts.length == 0 + @update! + @refs.timeline.set-posts posts + if cb? then cb! + .catch (err) ~> + console.error err + if cb? then cb! + + @more = ~> + if @more-loading or @is-loading or @refs.timeline.posts.length == 0 + return + @more-loading = true + @update! + @api \users/posts do + user_id: @user.id + with_replies: @mode == \with-replies + max_id: @refs.timeline.tail!.id + .then (posts) ~> + @more-loading = false + @update! + @refs.timeline.prepend-posts posts + .catch (err) ~> + console.error err + + @on-stream-post = (post) ~> + @is-empty = false + @update! + @refs.timeline.add-post post + + if document.hidden + @unread-count++ + document.title = '(' + @unread-count + ') ' + @get-post-summary post + + @window-on-visibilitychange = ~> + if !document.hidden + @unread-count = 0 + document.title = 'Misskey' + + @on-scroll = ~> + current = window.scroll-y + window.inner-height + if current > document.body.offset-height - 16 # 遊び + @more! + + @set-mode = (mode) ~> + @update do + mode: mode + @fetch! diff --git a/src/web/app/desktop/tags/user.tag b/src/web/app/desktop/tags/user.tag new file mode 100644 index 0000000000..4d022e68c4 --- /dev/null +++ b/src/web/app/desktop/tags/user.tag @@ -0,0 +1,45 @@ +mk-user + div.user(if={ !fetching }) + header + mk-user-header(user={ user }) + div.body + mk-user-home(if={ page == 'home' }, user={ user }) + mk-user-graphs(if={ page == 'graphs' }, user={ user }) + +style. + display block + background #fff + + > .user + > header + max-width 560px + 270px + margin 0 auto + padding 0 16px + + > mk-user-header + border solid 1px rgba(0, 0, 0, 0.075) + border-top none + border-radius 0 0 6px 6px + overflow hidden + + > .body + max-width 560px + 270px + margin 0 auto + padding 0 16px + +script. + @mixin \api + + @username = @opts.user + @page = if @opts.page? then @opts.page else \home + @fetching = true + @user = null + + @on \mount ~> + @api \users/show do + username: @username + .then (user) ~> + @fetching = false + @user = user + @update! + @trigger \loaded diff --git a/src/web/app/desktop/tags/users-list.tag b/src/web/app/desktop/tags/users-list.tag new file mode 100644 index 0000000000..9ae96eed9f --- /dev/null +++ b/src/web/app/desktop/tags/users-list.tag @@ -0,0 +1,139 @@ +mk-users-list + nav: div + span(data-is-active={ mode == 'all' }, onclick={ set-mode.bind(this, 'all') }) + | すべて + span { opts.count } + // ↓ https://github.com/riot/riot/issues/2080 + span(if={ SIGNIN && opts.you-know-count != '' }, data-is-active={ mode == 'iknow' }, onclick={ set-mode.bind(this, 'iknow') }) + | 知り合い + span { opts.you-know-count } + + div.users(if={ !fetching && users.length != 0 }) + div(each={ users }): mk-list-user(user={ this }) + + button.more(if={ !fetching && next != null }, onclick={ more }, disabled={ more-fetching }) + span(if={ !more-fetching }) もっと + span(if={ more-fetching }) + | 読み込み中 + mk-ellipsis + + p.no(if={ !fetching && users.length == 0 }) + | { opts.no-users } + p.fetching(if={ fetching }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + height 100% + background #fff + + > nav + z-index 1 + box-shadow 0 1px 0 rgba(#000, 0.1) + + > div + display flex + justify-content center + margin 0 auto + max-width 600px + + > span + display block + flex 1 1 + text-align center + line-height 52px + font-size 14px + color #657786 + border-bottom solid 2px transparent + cursor pointer + + * + pointer-events none + + &[data-is-active] + font-weight bold + color $theme-color + border-color $theme-color + cursor default + + > span + display inline-block + margin-left 4px + padding 2px 5px + font-size 12px + line-height 1 + color #888 + background #eee + border-radius 20px + + > .users + height calc(100% - 54px) + overflow auto + + > * + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + > * + max-width 600px + margin 0 auto + + > .no + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \i + + @limit = 30users + @mode = \all + + @fetching = true + @more-fetching = false + + @on \mount ~> + @fetch ~> + @trigger \loaded + + @fetch = (cb) ~> + @fetching = true + @update! + obj <~ @opts.fetch do + @mode == \iknow + @limit + null + @users = obj.users + @next = obj.next + @fetching = false + @update! + if cb? then cb! + + @more = ~> + @more-fetching = true + @update! + obj <~ @opts.fetch do + @mode == \iknow + @limit + @cursor + @users = @users.concat obj.users + @next = obj.next + @more-fetching = false + @update! + + @set-mode = (mode) ~> + @update do + mode: mode + + @fetch! diff --git a/src/web/app/desktop/tags/window.tag b/src/web/app/desktop/tags/window.tag new file mode 100644 index 0000000000..9732a6c552 --- /dev/null +++ b/src/web/app/desktop/tags/window.tag @@ -0,0 +1,515 @@ +mk-window(data-flexible={ is-flexible }, data-colored={ opts.colored }, ondragover={ ondragover }) + div.bg@bg(show={ is-modal }, onclick={ bg-click }) + div.main@main(tabindex='-1', data-is-modal={ is-modal }, onmousedown={ on-body-mousedown }, onkeydown={ on-keydown }) + div.body + header@header(onmousedown={ on-header-mousedown }) + h1(data-yield='header') + | + button.close(if={ can-close }, onmousedown={ repel-move }, onclick={ close }, title='閉じる'): i.fa.fa-times + div.content(data-yield='content') + | + div.handle.top(if={ can-resize }, onmousedown={ on-top-handle-mousedown }) + div.handle.right(if={ can-resize }, onmousedown={ on-right-handle-mousedown }) + div.handle.bottom(if={ can-resize }, onmousedown={ on-bottom-handle-mousedown }) + div.handle.left(if={ can-resize }, onmousedown={ on-left-handle-mousedown }) + div.handle.top-left(if={ can-resize }, onmousedown={ on-top-left-handle-mousedown }) + div.handle.top-right(if={ can-resize }, onmousedown={ on-top-right-handle-mousedown }) + div.handle.bottom-right(if={ can-resize }, onmousedown={ on-bottom-right-handle-mousedown }) + div.handle.bottom-left(if={ can-resize }, onmousedown={ on-bottom-left-handle-mousedown }) + +style. + display block + + > .bg + display block + position fixed + z-index 2048 + top 0 + left 0 + width 100% + height 100% + background rgba(0, 0, 0, 0.7) + opacity 0 + pointer-events none + + > .main + display block + position fixed + z-index 2048 + top 15% + left 0 + margin 0 + opacity 0 + pointer-events none + + &:focus + &:not([data-is-modal]) + > .body + box-shadow 0 0 0px 1px rgba($theme-color, 0.5), 0 2px 6px 0 rgba(0, 0, 0, 0.2) + + > .handle + $size = 8px + + position absolute + + &.top + top -($size) + left 0 + width 100% + height $size + cursor ns-resize + + &.right + top 0 + right -($size) + width $size + height 100% + cursor ew-resize + + &.bottom + bottom -($size) + left 0 + width 100% + height $size + cursor ns-resize + + &.left + top 0 + left -($size) + width $size + height 100% + cursor ew-resize + + &.top-left + top -($size) + left -($size) + width $size * 2 + height $size * 2 + cursor nwse-resize + + &.top-right + top -($size) + right -($size) + width $size * 2 + height $size * 2 + cursor nesw-resize + + &.bottom-right + bottom -($size) + right -($size) + width $size * 2 + height $size * 2 + cursor nwse-resize + + &.bottom-left + bottom -($size) + left -($size) + width $size * 2 + height $size * 2 + cursor nesw-resize + + > .body + height 100% + overflow hidden + background #fff + border-radius 6px + box-shadow 0 2px 6px 0 rgba(0, 0, 0, 0.2) + + > header + z-index 128 + overflow hidden + cursor move + background #fff + border-radius 6px 6px 0 0 + box-shadow 0 1px 0 rgba(#000, 0.1) + + &, * + user-select none + + > h1 + pointer-events none + display block + margin 0 + height 40px + text-align center + font-size 1em + line-height 40px + font-weight normal + color #666 + + > .close + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color rgba(#000, 0.4) + border none + outline none + background transparent + + &:hover + color rgba(#000, 0.6) + + &:active + color darken(#000, 30%) + + > i + padding 0 + width 40px + line-height 40px + + > .content + height 100% + + &:not([flexible]) + > .main > .body > .content + height calc(100% - 40px) + + &[data-colored] + + > .main > .body + + > header + box-shadow 0 1px 0 rgba($theme-color, 0.1) + + > h1 + color #d0b4ac + + > .close + color rgba($theme-color, 0.4) + + &:hover + color rgba($theme-color, 0.6) + + &:active + color darken($theme-color, 30%) + +script. + @min-height = 40px + @min-width = 200px + + @is-modal = if @opts.is-modal? then @opts.is-modal else false + @can-close = if @opts.can-close? then @opts.can-close else true + @is-flexible = !@opts.height? + @can-resize = not @is-flexible + + @on \mount ~> + @refs.main.style.width = @opts.width || \530px + @refs.main.style.height = @opts.height || \auto + + @refs.main.style.top = \15% + @refs.main.style.left = (window.inner-width / 2) - (@refs.main.offset-width / 2) + \px + + @refs.header.add-event-listener \contextmenu (e) ~> + e.prevent-default! + + window.add-event-listener \resize @on-browser-resize + + @open! + + @on \unmount ~> + window.remove-event-listener \resize @on-browser-resize + + @on-browser-resize = ~> + position = @refs.main.get-bounding-client-rect! + browser-width = window.inner-width + browser-height = window.inner-height + window-width = @refs.main.offset-width + window-height = @refs.main.offset-height + + if position.left < 0 + @refs.main.style.left = 0 + + if position.top < 0 + @refs.main.style.top = 0 + + if position.left + window-width > browser-width + @refs.main.style.left = browser-width - window-width + \px + + if position.top + window-height > browser-height + @refs.main.style.top = browser-height - window-height + \px + + @open = ~> + @trigger \opening + + @top! + + if @is-modal + @refs.bg.style.pointer-events = \auto + Velocity @refs.bg, \finish true + Velocity @refs.bg, { + opacity: 1 + } { + queue: false + duration: 100ms + easing: \linear + } + + @refs.main.style.pointer-events = \auto + Velocity @refs.main, \finish true + Velocity @refs.main, {scale: 1.1} 0ms + Velocity @refs.main, { + opacity: 1 + scale: 1 + } { + queue: false + duration: 200ms + easing: \ease-out + } + + #@refs.main.focus! + + set-timeout ~> + @trigger \opened + , 300ms + + @close = ~> + @trigger \closing + + if @is-modal + @refs.bg.style.pointer-events = \none + Velocity @refs.bg, \finish true + Velocity @refs.bg, { + opacity: 0 + } { + queue: false + duration: 300ms + easing: \linear + } + + @refs.main.style.pointer-events = \none + Velocity @refs.main, \finish true + Velocity @refs.main, { + opacity: 0 + scale: 0.8 + } { + queue: false + duration: 300ms + easing: [ 0.5, -0.5, 1, 0.5 ] + } + + set-timeout ~> + @trigger \closed + , 300ms + + # 最前面へ移動します + @top = ~> + z = 0 + + ws = document.query-selector-all \mk-window + ws.for-each (w) !~> + if w == @root then return + m = w.query-selector ':scope > .main' + mz = Number(document.default-view.get-computed-style m, null .z-index) + if mz > z then z := mz + + if z > 0 + @refs.main.style.z-index = z + 1 + if @is-modal then @refs.bg.style.z-index = z + 1 + + @repel-move = (e) ~> + e.stop-propagation! + return true + + @bg-click = ~> + if @can-close + @close! + + @on-body-mousedown = (e) ~> + @top! + true + + # ヘッダー掴み時 + @on-header-mousedown = (e) ~> + e.prevent-default! + + if not contains @refs.main, document.active-element + @refs.main.focus! + + position = @refs.main.get-bounding-client-rect! + + click-x = e.client-x + click-y = e.client-y + move-base-x = click-x - position.left + move-base-y = click-y - position.top + browser-width = window.inner-width + browser-height = window.inner-height + window-width = @refs.main.offset-width + window-height = @refs.main.offset-height + + # 動かした時 + drag-listen (me) ~> + move-left = me.client-x - move-base-x + move-top = me.client-y - move-base-y + + # 上はみ出し + if move-top < 0 + move-top = 0 + + # 左はみ出し + if move-left < 0 + move-left = 0 + + # 下はみ出し + if move-top + window-height > browser-height + move-top = browser-height - window-height + + # 右はみ出し + if move-left + window-width > browser-width + move-left = browser-width - window-width + + @refs.main.style.left = move-left + \px + @refs.main.style.top = move-top + \px + + # 上ハンドル掴み時 + @on-top-handle-mousedown = (e) ~> + e.prevent-default! + + base = e.client-y + height = parse-int((get-computed-style @refs.main, '').height, 10) + top = parse-int((get-computed-style @refs.main, '').top, 10) + + # 動かした時 + drag-listen (me) ~> + move = me.client-y - base + if top + move > 0 + if height + -move > @min-height + @apply-transform-height height + -move + @apply-transform-top top + move + else # 最小の高さより小さくなろうとした時 + @apply-transform-height @min-height + @apply-transform-top top + (height - @min-height) + else # 上のはみ出し時 + @apply-transform-height top + height + @apply-transform-top 0 + + # 右ハンドル掴み時 + @on-right-handle-mousedown = (e) ~> + e.prevent-default! + + base = e.client-x + width = parse-int((get-computed-style @refs.main, '').width, 10) + left = parse-int((get-computed-style @refs.main, '').left, 10) + browser-width = window.inner-width + + # 動かした時 + drag-listen (me) ~> + move = me.client-x - base + if left + width + move < browser-width + if width + move > @min-width + @apply-transform-width width + move + else # 最小の幅より小さくなろうとした時 + @apply-transform-width @min-width + else # 右のはみ出し時 + @apply-transform-width browser-width - left + + # 下ハンドル掴み時 + @on-bottom-handle-mousedown = (e) ~> + e.prevent-default! + + base = e.client-y + height = parse-int((get-computed-style @refs.main, '').height, 10) + top = parse-int((get-computed-style @refs.main, '').top, 10) + browser-height = window.inner-height + + # 動かした時 + drag-listen (me) ~> + move = me.client-y - base + if top + height + move < browser-height + if height + move > @min-height + @apply-transform-height height + move + else # 最小の高さより小さくなろうとした時 + @apply-transform-height @min-height + else # 下のはみ出し時 + @apply-transform-height browser-height - top + + # 左ハンドル掴み時 + @on-left-handle-mousedown = (e) ~> + e.prevent-default! + + base = e.client-x + width = parse-int((get-computed-style @refs.main, '').width, 10) + left = parse-int((get-computed-style @refs.main, '').left, 10) + + # 動かした時 + drag-listen (me) ~> + move = me.client-x - base + if left + move > 0 + if width + -move > @min-width + @apply-transform-width width + -move + @apply-transform-left left + move + else # 最小の幅より小さくなろうとした時 + @apply-transform-width @min-width + @apply-transform-left left + (width - @min-width) + else # 左のはみ出し時 + @apply-transform-width left + width + @apply-transform-left 0 + + # 左上ハンドル掴み時 + @on-top-left-handle-mousedown = (e) ~> + @on-top-handle-mousedown e + @on-left-handle-mousedown e + + # 右上ハンドル掴み時 + @on-top-right-handle-mousedown = (e) ~> + @on-top-handle-mousedown e + @on-right-handle-mousedown e + + # 右下ハンドル掴み時 + @on-bottom-right-handle-mousedown = (e) ~> + @on-bottom-handle-mousedown e + @on-right-handle-mousedown e + + # 左下ハンドル掴み時 + @on-bottom-left-handle-mousedown = (e) ~> + @on-bottom-handle-mousedown e + @on-left-handle-mousedown e + + # 高さを適用 + @apply-transform-height = (height) ~> + @refs.main.style.height = height + \px + + # 幅を適用 + @apply-transform-width = (width) ~> + @refs.main.style.width = width + \px + + # Y座標を適用 + @apply-transform-top = (top) ~> + @refs.main.style.top = top + \px + + # X座標を適用 + @apply-transform-left = (left) ~> + @refs.main.style.left = left + \px + + function drag-listen fn + window.add-event-listener \mousemove fn + window.add-event-listener \mouseleave drag-clear.bind null fn + window.add-event-listener \mouseup drag-clear.bind null fn + + function drag-clear fn + window.remove-event-listener \mousemove fn + window.remove-event-listener \mouseleave drag-clear + window.remove-event-listener \mouseup drag-clear + + @ondragover = (e) ~> + e.data-transfer.drop-effect = \none + + @on-keydown = (e) ~> + if e.which == 27 # Esc + if @can-close + e.prevent-default! + e.stop-propagation! + @close! + + function contains(parent, child) + node = child.parent-node + while node? + if node == parent + return true + node = node.parent-node + return false diff --git a/src/web/app/dev/router.ls b/src/web/app/dev/router.ls new file mode 100644 index 0000000000..ac408b36ed --- /dev/null +++ b/src/web/app/dev/router.ls @@ -0,0 +1,51 @@ +# Router +#================================ + +route = require \page +page = null + +module.exports = (me) ~> + + # Routing + #-------------------------------- + + route \/ index + route \/apps apps + route \/app/new new-app + route \/app/:app app + route \* not-found + + # Handlers + #-------------------------------- + + function index + mount document.create-element \mk-index + + function apps + mount document.create-element \mk-apps-page + + function new-app + mount document.create-element \mk-new-app-page + + function app ctx + document.create-element \mk-app-page + ..set-attribute \app ctx.params.app + .. |> mount + + function not-found + mount document.create-element \mk-not-found + + # Exec + #-------------------------------- + + route! + +# Mount +#================================ + +riot = require \riot + +function mount content + if page? then page.unmount! + body = document.get-element-by-id \app + page := riot.mount body.append-child content .0 diff --git a/src/web/app/dev/script.js b/src/web/app/dev/script.js new file mode 100644 index 0000000000..407f4e84c0 --- /dev/null +++ b/src/web/app/dev/script.js @@ -0,0 +1,15 @@ +/** + * Developer Center + */ + +require('./tags.ls'); +const boot = require('../boot.ls'); +const route = require('./router.ls'); + +/** + * Boot + */ +boot(me => { + // Start routing + route(me); +}); diff --git a/src/web/app/dev/style.styl b/src/web/app/dev/style.styl new file mode 100644 index 0000000000..a7e51b8943 --- /dev/null +++ b/src/web/app/dev/style.styl @@ -0,0 +1,10 @@ +@import "../base" + +html + background-color #fff + +#init + background #100f0f + + > p + color $theme-color diff --git a/src/web/app/dev/tags.ls b/src/web/app/dev/tags.ls new file mode 100644 index 0000000000..7783402634 --- /dev/null +++ b/src/web/app/dev/tags.ls @@ -0,0 +1,5 @@ +require './tags/pages/index.tag' +require './tags/pages/apps.tag' +require './tags/pages/app.tag' +require './tags/pages/new-app.tag' +require './tags/new-app-form.tag' diff --git a/src/web/app/dev/tags/new-app-form.tag b/src/web/app/dev/tags/new-app-form.tag new file mode 100644 index 0000000000..443bf2bfff --- /dev/null +++ b/src/web/app/dev/tags/new-app-form.tag @@ -0,0 +1,260 @@ +mk-new-app-form + form(onsubmit={ onsubmit }, autocomplete='off') + section.name: label + p.caption + | アプリケーション名 + input@name( + type='text' + placeholder='ex) Misskey for iOS' + autocomplete='off' + required) + + section.nid: label + p.caption + | Named ID + input@nid( + type='text' + pattern='^[a-zA-Z0-9\-]{3,30}$' + placeholder='ex) misskey-for-ios' + autocomplete='off' + required + onkeyup={ on-change-nid }) + + p.info(if={ nid-state == 'wait' }, style='color:#999') + i.fa.fa-fw.fa-spinner.fa-pulse + | 確認しています... + p.info(if={ nid-state == 'ok' }, style='color:#3CB7B5') + i.fa.fa-fw.fa-check + | 利用できます + p.info(if={ nid-state == 'unavailable' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 既に利用されています + p.info(if={ nid-state == 'error' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 通信エラー + p.info(if={ nid-state == 'invalid-format' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | a~z、A~Z、0~9、-(ハイフン)が使えます + p.info(if={ nid-state == 'min-range' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 3文字以上でお願いします! + p.info(if={ nid-state == 'max-range' }, style='color:#FF1161') + i.fa.fa-fw.fa-exclamation-triangle + | 30文字以内でお願いします + + section.description: label + p.caption + | アプリの概要 + textarea@description( + placeholder='ex) Misskey iOSクライアント。' + autocomplete='off' + required) + + section.callback: label + p.caption + | コールバックURL (オプション) + input@cb( + type='url' + placeholder='ex) https://your.app.example.com/callback.php' + autocomplete='off') + + section.permission + p.caption + | 権限 + div@permission + label + input(type='checkbox', value='account-read') + p アカウントの情報を見る。 + label + input(type='checkbox', value='account-write') + p アカウントの情報を操作する。 + label + input(type='checkbox', value='post-write') + p 投稿する。 + label + input(type='checkbox', value='like-write') + p いいねしたりいいね解除する。 + label + input(type='checkbox', value='following-write') + p フォローしたりフォロー解除する。 + label + input(type='checkbox', value='drive-read') + p ドライブを見る。 + label + input(type='checkbox', value='drive-write') + p ドライブを操作する。 + label + input(type='checkbox', value='notification-read') + p 通知を見る。 + label + input(type='checkbox', value='notification-write') + p 通知を操作する。 + p + i.fa.fa-exclamation-triangle + | アプリ作成後も変更できますが、新たな権限を付与する場合、その時点で関連付けられているユーザーキーはすべて無効になります。 + + button(onclick={ onsubmit }) + | アプリ作成 + +style. + display block + overflow hidden + + > form + + section + display block + margin 16px 0 + + .caption + margin 0 0 4px 0 + color #616161 + font-size 0.95em + + > i + margin-right 0.25em + color #96adac + + .info + display block + margin 4px 0 + font-size 0.8em + + > i + margin-right 0.3em + + section.permission + div + padding 8px 0 + max-height 160px + overflow auto + background #fff + border solid 1px #cecece + border-radius 4px + + label + display block + padding 0 12px + line-height 32px + cursor pointer + + &:hover + > p + color #999 + + [type='checkbox']:checked + p + color #000 + + [type='checkbox'] + margin-right 4px + + [type='checkbox']:checked + p + color #111 + + > p + display inline + color #aaa + user-select none + + > p:last-child + margin 6px + font-size 0.8em + color #999 + + > i + margin-right 4px + + [type=text] + [type=url] + textarea + user-select text + display inline-block + cursor auto + padding 8px 12px + margin 0 + width 100% + font-size 1em + color #333 + background #fff + outline none + border solid 1px #cecece + border-radius 4px + + &:hover + border-color #bbb + + &:focus + border-color $theme-color + + &:disabled + opacity 0.5 + + > button + margin 20px 0 32px 0 + width 100% + font-size 1em + color #111 + border-radius 3px + +script. + @mixin \api + + @nid-state = null + + @on-change-nid = ~> + nid = @refs.nid.value + + if nid == '' + @nid-state = null + @update! + return + + err = switch + | not nid.match /^[a-zA-Z0-9\-]+$/ => \invalid-format + | nid.length < 3chars => \min-range + | nid.length > 30chars => \max-range + | _ => null + + if err? + @nid-state = err + @update! + else + @nid-state = \wait + @update! + + @api \app/name_id/available do + name_id: nid + .then (result) ~> + if result.available + @nid-state = \ok + else + @nid-state = \unavailable + @update! + .catch (err) ~> + @nid-state = \error + @update! + + @onsubmit = ~> + name = @refs.name.value + nid = @refs.nid.value + description = @refs.description.value + cb = @refs.cb.value + permission = [] + + @refs.permission.query-selector-all \input .for-each (el) ~> + if el.checked then permission.push el.value + + locker = document.body.append-child document.create-element \mk-locker + + @api \app/create do + name: name + name_id: nid + description: description + callback_url: cb + permission: permission.join \, + .then ~> + location.href = '/apps' + .catch ~> + alert 'アプリの作成に失敗しました。再度お試しください。' + + locker.parent-node.remove-child locker diff --git a/src/web/app/dev/tags/pages/app.tag b/src/web/app/dev/tags/pages/app.tag new file mode 100644 index 0000000000..aa9ba68f3f --- /dev/null +++ b/src/web/app/dev/tags/pages/app.tag @@ -0,0 +1,24 @@ +mk-app-page + p(if={ fetching }) 読み込み中 + main(if={ !fetching }) + header + h1 { app.name } + div.body + p App Secret + input(value={ app.secret }, readonly) + +style. + display block + +script. + @mixin \api + + @fetching = true + + @on \mount ~> + @api \app/show do + app_id: @opts.app + .then (app) ~> + @app = app + @fetching = false + @update! diff --git a/src/web/app/dev/tags/pages/apps.tag b/src/web/app/dev/tags/pages/apps.tag new file mode 100644 index 0000000000..f46a9d3282 --- /dev/null +++ b/src/web/app/dev/tags/pages/apps.tag @@ -0,0 +1,26 @@ +mk-apps-page + h1 アプリを管理 + a(href='/app/new') アプリ作成 + div.apps + p(if={ fetching }) 読み込み中 + virtual(if={ !fetching }) + p(if={ apps.length == 0 }) アプリなし + ul(if={ apps.length > 0 }) + li(each={ app in apps }) + a(href={ '/app/' + app.id }) + p.name { app.name } + +style. + display block + +script. + @mixin \api + + @fetching = true + + @on \mount ~> + @api \my/apps + .then (apps) ~> + @fetching = false + @apps = apps + @update! diff --git a/src/web/app/dev/tags/pages/index.tag b/src/web/app/dev/tags/pages/index.tag new file mode 100644 index 0000000000..7bc57fbb00 --- /dev/null +++ b/src/web/app/dev/tags/pages/index.tag @@ -0,0 +1,5 @@ +mk-index + a(href='/apps') アプリ + +style. + display block diff --git a/src/web/app/dev/tags/pages/new-app.tag b/src/web/app/dev/tags/pages/new-app.tag new file mode 100644 index 0000000000..8c19e39f4b --- /dev/null +++ b/src/web/app/dev/tags/pages/new-app.tag @@ -0,0 +1,33 @@ +mk-new-app-page + main + header + h1 新しいアプリを作成 + p MisskeyのAPIを利用したアプリケーションを作成できます。 + mk-new-app-form + +style. + display block + padding 64px 0 + + > main + width 100% + max-width 700px + margin 0 auto + + > header + margin 0 0 16px 0 + padding 0 0 16px 0 + border-bottom solid 1px #282827 + + > h1 + margin 0 0 12px 0 + padding 0 + line-height 32px + font-size 32px + font-weight normal + color #000 + + > p + margin 0 + line-height 16px + color #9a9894 diff --git a/src/web/app/dev/view.pug b/src/web/app/dev/view.pug new file mode 100644 index 0000000000..aea2f2adb8 --- /dev/null +++ b/src/web/app/dev/view.pug @@ -0,0 +1,5 @@ +extends ../base + +block head + link(rel='stylesheet', href='/_/resources/dev/style.css') + script(src='/_/resources/dev/script.js', async, defer) diff --git a/src/web/app/init.styl b/src/web/app/init.styl new file mode 100644 index 0000000000..972997725d --- /dev/null +++ b/src/web/app/init.styl @@ -0,0 +1,56 @@ +@charset 'utf-8' + +html + font-family sans-serif + +body > noscript > div + position fixed + z-index 32768 + top 0 + left 0 + width 100% + height 100% + text-align center + background #fff + + > p + display block + margin 32px + font-size 2em + color #555 + +#init + position fixed + z-index 16384 + top 0 + left 0 + width 100% + height 100% + text-align center + background #fff + cursor wait + + > p + display block + user-select none + margin 32px + font-size 4em + color #555 + + > span + animation init 1.4s infinite ease-in-out both + + &:nth-child(1) + animation-delay 0s + + &:nth-child(2) + animation-delay 0.16s + + &:nth-child(3) + animation-delay 0.32s + + @keyframes init + 0%, 80%, 100% + opacity 1 + 40% + opacity 0 diff --git a/src/web/app/mobile/mixins.ls b/src/web/app/mobile/mixins.ls new file mode 100644 index 0000000000..902774f91a --- /dev/null +++ b/src/web/app/mobile/mixins.ls @@ -0,0 +1,19 @@ +riot = require \riot + +module.exports = (me) ~> + if me? + (require './scripts/stream.ls') me + + require './scripts/ui.ls' + + riot.mixin \open-post-form do + open-post-form: (opts) -> + app = document.get-element-by-id \app + app.style.display = \none + form = document.body.append-child document.create-element \mk-post-form + form = riot.mount form, opts .0 + form.on \cancel recover + form.on \post recover + + function recover + app.style.display = \block diff --git a/src/web/app/mobile/router.ls b/src/web/app/mobile/router.ls new file mode 100644 index 0000000000..33ae3e82da --- /dev/null +++ b/src/web/app/mobile/router.ls @@ -0,0 +1,110 @@ +# Router +#================================ + +riot = require \riot +route = require \page +page = null + +module.exports = (me) ~> + + # Routing + #-------------------------------- + + route \/ index + route \/i/notifications notifications + route \/i/drive drive + route \/i/drive/folder/:folder drive + route \/i/drive/file/:file drive + route \/post/new new-post + route \/post::post post + route \/search::query search + route \/:user user.bind null \posts + route \/:user/graphs user.bind null \graphs + route \/:user/followers user-followers + route \/:user/following user-following + route \/:user/:post post + route \* not-found + + # Handlers + #-------------------------------- + + # / + function index + if me? then home! else entrance! + + # ホーム + function home + mount document.create-element \mk-home-page + + # 玄関 + function entrance + mount document.create-element \mk-entrance + + # 通知 + function notifications + mount document.create-element \mk-notifications-page + + # 新規投稿 + function new-post + mount document.create-element \mk-new-post-page + + # 検索 + function search ctx + document.create-element \mk-search-page + ..set-attribute \query ctx.params.query + .. |> mount + + # ユーザー + function user page, ctx + document.create-element \mk-user-page + ..set-attribute \user ctx.params.user + ..set-attribute \page page + .. |> mount + + # フォロー一覧 + function user-following ctx + document.create-element \mk-user-following-page + ..set-attribute \user ctx.params.user + .. |> mount + + # フォロワー一覧 + function user-followers ctx + document.create-element \mk-user-followers-page + ..set-attribute \user ctx.params.user + .. |> mount + + # 投稿詳細ページ + function post ctx + document.create-element \mk-post-page + ..set-attribute \post ctx.params.post + .. |> mount + + # ドライブ + function drive ctx + p = document.create-element \mk-drive-page + if ctx.params.folder then p.set-attribute \folder ctx.params.folder + if ctx.params.file then p.set-attribute \file ctx.params.file + mount p + + # not found + function not-found + mount document.create-element \mk-not-found + + # Register mixin + #-------------------------------- + + riot.mixin \page do + page: route + + # Exec + #-------------------------------- + + route! + +# Mount +#================================ + +function mount content + if page? then page.unmount! + body = document.get-element-by-id \app + page := riot.mount body.append-child content .0 diff --git a/src/web/app/mobile/script.js b/src/web/app/mobile/script.js new file mode 100644 index 0000000000..1c269a57d9 --- /dev/null +++ b/src/web/app/mobile/script.js @@ -0,0 +1,20 @@ +/** + * Mobile Client + */ + +require('./tags.ls'); +require('./scripts/sp-slidemenu.js'); +const boot = require('../boot.ls'); +const mixins = require('./mixins.ls'); +const route = require('./router.ls'); + +/** + * Boot + */ +boot(me => { + // Register mixins + mixins(me); + + // Start routing + route(me); +}); diff --git a/src/web/app/mobile/scripts/sp-slidemenu.js b/src/web/app/mobile/scripts/sp-slidemenu.js new file mode 100644 index 0000000000..f2dcae9cef --- /dev/null +++ b/src/web/app/mobile/scripts/sp-slidemenu.js @@ -0,0 +1,839 @@ +/** + * sp-slidemenu.js + * + * @version 0.1.0 + * @url https://github.com/be-hase/sp-slidemenu + * + * Copyright 2013 be-hase.com, Inc. + * Licensed under the MIT License: + * http://www.opensource.org/licenses/mit-license.php + */ + +/** + * CUSTOMIZED BY SYUILO + */ + +; (function(window, document, undefined) { + "use strict"; + var div, PREFIX, support, gestureStart, EVENTS, ANIME_SPEED, SLIDE_STATUS, SCROLL_STATUS, THRESHOLD, EVENT_MOE_TIME, rclass, ITEM_CLICK_CLASS_NAME; + div = document.createElement('div'); + PREFIX = ['webkit', 'moz', 'o', 'ms']; + support = SpSlidemenu.support = {}; + support.transform3d = hasProp([ + 'perspectiveProperty', + 'WebkitPerspective', + 'MozPerspective', + 'OPerspective', + 'msPerspective' + ]); + support.transform = hasProp([ + 'transformProperty', + 'WebkitTransform', + 'MozTransform', + 'OTransform', + 'msTransform' + ]); + support.transition = hasProp([ + 'transitionProperty', + 'WebkitTransitionProperty', + 'MozTransitionProperty', + 'OTransitionProperty', + 'msTransitionProperty' + ]); + support.addEventListener = 'addEventListener' in window; + support.msPointer = window.navigator.msPointerEnabled; + support.cssAnimation = (support.transform3d || support.transform) && support.transition; + support.touch = 'ontouchend' in window; + EVENTS = { + start: { + touch: 'touchstart', + mouse: 'mousedown' + }, + move: { + touch: 'touchmove', + mouse: 'mousemove' + }, + end: { + touch: 'touchend', + mouse: 'mouseup' + } + }; + gestureStart = false; + if (support.addEventListener) { + document.addEventListener('gesturestart', function() { + gestureStart = true; + }); + document.addEventListener('gestureend', function() { + gestureStart = false; + }); + } + ANIME_SPEED = { + slider: 200, + scrollOverBack: 400 + }; + SLIDE_STATUS = { + close: 0, + open: 1, + progress: 2 + }; + THRESHOLD = 10; + EVENT_MOE_TIME = 50; + rclass = /[\t\r\n\f]/g; + ITEM_CLICK_CLASS_NAME = 'menu-item'; + /* + [MEMO] + SpSlidemenu properties which is not function is ... + -- element -- + element: main + element: slidemenu + element: button + element: slidemenuBody + element: slidemenuContent + element: slidemenuHeader + -- options -- + bool: disableCssAnimation + bool: disabled3d + -- animation -- + bool: useCssAnimation + bool: use3d + -- slide -- + int: slideWidth + string: htmlOverflowX + string: bodyOverflowX + int: buttonStartPageX + int: buttonStartPageY + -- scroll -- + bool: scrollTouchStarted + bool: scrollMoveReady + int: scrollStartPageX + int: scrollStartPageY + int: scrollBasePageY + int: scrollTimeForVelocity + int: scrollCurrentY + int: scrollMoveEventCnt + int: scrollAnimationTimer + int: scrollOverTimer + int: scrollMaxY + */ + function SpSlidemenu(main, slidemenu, button, options) { + if (this instanceof SpSlidemenu) { + return this.init(main, slidemenu, button, options); + } else { + return new SpSlidemenu(main, slidemenu, button, options); + } + } + SpSlidemenu.prototype.init = function(main, slidemenu, button, options) { + var _this = this; + // find and set element. + _this.setElement(main, slidemenu, button); + if (!_this.main || !_this.slidemenu || !_this.button || !_this.slidemenuBody || !_this.slidemenuContent) { + throw new Error('Element not found. Please set correctly.'); + } + // options + options = options || {}; + _this.disableCssAnimation = (options.disableCssAnimation === undefined) ? false : options.disableCssAnimation; + _this.disable3d = (options.disable3d === undefined) ? false : options.disable3d; + _this.direction = 'left'; + if (options.direction === 'right') { + _this.direction = 'right'; + } + // animation + _this.useCssAnimation = support.cssAnimation; + if (_this.disableCssAnimation === true) { + _this.useCssAnimation = false; + } + _this.use3d = support.transform3d; + if (_this.disable3d === true) { + _this.use3d = false; + } + // slide + _this.slideWidth = (getDimentions(_this.slidemenu)).width; + _this.main.SpSlidemenuStatus = SLIDE_STATUS.close; + _this.htmlOverflowX = ''; + _this.bodyOverflowX = ''; + // scroll + _this.scrollCurrentY = 0; + _this.scrollAnimationTimer = false; + _this.scrollOverTimer = false; + // set default style. + _this.setDefaultStyle(); + // bind some method for callback. + _this.bindMethods(); + // add event + addTouchEvent('start', _this.button, _this.buttonTouchStart, false); + addTouchEvent('move', _this.button, blockEvent, false); + addTouchEvent('end', _this.button, _this.buttonTouchEnd, false); + addTouchEvent('start', _this.slidemenuContent, _this.scrollTouchStart, false); + addTouchEvent('move', _this.slidemenuContent, _this.scrollTouchMove, false); + addTouchEvent('end', _this.slidemenuContent, _this.scrollTouchEnd, false); + _this.slidemenuContent.addEventListener('click', _this.itemClick, false); + // window size change + window.addEventListener('resize', debounce(_this.setHeight, 100), false); + return _this; + }; + SpSlidemenu.prototype.bindMethods = function() { + var _this, funcs; + _this = this; + funcs = [ + 'setHeight', + 'slideOpen', 'slideOpenEnd', 'slideClose', 'slideCloseEnd', + 'buttonTouchStart', 'buttonTouchEnd', 'mainTouchStart', + 'scrollTouchStart', 'scrollTouchMove', 'scrollTouchEnd', 'scrollInertiaMove', 'scrollOverBack', 'scrollOver', + 'itemClick' + ]; + funcs.forEach(function(func) { + _this[func] = bind(_this[func], _this); + }); + }; + SpSlidemenu.prototype.setElement = function(main, slidemenu, button) { + var _this = this; + _this.main = main; + if (typeof main === 'string') { + _this.main = document.querySelector(main); + } + _this.slidemenu = slidemenu; + if (typeof slidemenu === 'string') { + _this.slidemenu = document.querySelector(slidemenu); + } + _this.button = button; + if (typeof button === 'string') { + _this.button = document.querySelector(button); + } + if (!_this.slidemenu) { + return; + } + _this.slidemenuBody = _this.slidemenu.querySelector('.body'); + _this.slidemenuContent = _this.slidemenu.querySelector('.content'); + _this.slidemenuHeader = _this.slidemenu.querySelector('.header'); + }; + SpSlidemenu.prototype.setDefaultStyle = function() { + var _this = this; + if (support.msPointer) { + _this.slidemenuContent.style.msTouchAction = 'none'; + } + _this.setHeight(); + if (_this.useCssAnimation) { + setStyles(_this.main, { + transitionProperty: getCSSName('transform'), + transitionTimingFunction: 'ease-in-out', + transitionDuration: ANIME_SPEED.slider + 'ms', + transitionDelay: '0ms', + transform: _this.getTranslateX(0) + }); + setStyles(_this.slidemenu, { + transitionProperty: 'visibility', + transitionTimingFunction: 'linear', + transitionDuration: '0ms', + transitionDelay: ANIME_SPEED.slider + 'ms' + }); + setStyles(_this.slidemenuContent, { + transitionProperty: getCSSName('transform'), + transitionTimingFunction: 'ease-in-out', + transitionDuration: '0ms', + transitionDelay: '0ms', + transform: _this.getTranslateY(0) + }); + } else { + setStyles(_this.main, { + position: 'relative', + left: '0px' + }); + setStyles(_this.slidemenuContent, { + top: '0px' + }); + } + }; + SpSlidemenu.prototype.setHeight = function(event) { + var _this, browserHeight; + _this = this; + browserHeight = getBrowserHeight(); + setStyles(_this.main, { + minHeight: browserHeight + 'px' + }); + setStyles(_this.slidemenu, { + height: browserHeight + 'px' + }); + }; + SpSlidemenu.prototype.buttonTouchStart = function(event) { + var _this = this; + event.preventDefault(); + event.stopPropagation(); + switch (_this.main.SpSlidemenuStatus) { + case SLIDE_STATUS.progress: + break; + case SLIDE_STATUS.open: + case SLIDE_STATUS.close: + _this.buttonStartPageX = getPage(event, 'pageX'); + _this.buttonStartPageY = getPage(event, 'pageY'); + break; + } + }; + SpSlidemenu.prototype.buttonTouchEnd = function(event) { + var _this = this; + event.preventDefault(); + event.stopPropagation(); + if (_this.shouldTrigerNext(event)) { + switch (_this.main.SpSlidemenuStatus) { + case SLIDE_STATUS.progress: + break; + case SLIDE_STATUS.open: + _this.slideClose(event); + break; + case SLIDE_STATUS.close: + _this.slideOpen(event); + break; + } + } + }; + SpSlidemenu.prototype.mainTouchStart = function(event) { + var _this = this; + event.preventDefault(); + event.stopPropagation(); + _this.slideClose(event); + }; + SpSlidemenu.prototype.shouldTrigerNext = function(event) { + var _this = this, + buttonEndPageX = getPage(event, 'pageX'), + buttonEndPageY = getPage(event, 'pageY'), + deltaX = Math.abs(buttonEndPageX - _this.buttonStartPageX), + deltaY = Math.abs(buttonEndPageY - _this.buttonStartPageY); + return deltaX < 20 && deltaY < 20; + }; + SpSlidemenu.prototype.slideOpen = function(event) { + var _this = this, toX; + + /// Misskey Original + document.body.setAttribute('data-nav-open', 'true'); + + if (_this.direction === 'left') { + toX = _this.slideWidth; + } else { + toX = -_this.slideWidth; + } + _this.main.SpSlidemenuStatus = SLIDE_STATUS.progress; + //set event + addTouchEvent('move', document, blockEvent, false); + // change style + _this.htmlOverflowX = document.documentElement.style['overflowX']; + _this.bodyOverflowX = document.body.style['overflowX']; + document.documentElement.style['overflowX'] = document.body.style['overflowX'] = 'hidden'; + if (_this.useCssAnimation) { + setStyles(_this.main, { + transform: _this.getTranslateX(toX) + }); + setStyles(_this.slidemenu, { + transitionProperty: 'z-index', + visibility: 'visible', + zIndex: '1' + }); + } else { + animate(_this.main, _this.direction, toX, ANIME_SPEED.slider); + setStyles(_this.slidemenu, { + visibility: 'visible' + }); + } + // set callback + setTimeout(_this.slideOpenEnd, ANIME_SPEED.slider + EVENT_MOE_TIME); + }; + SpSlidemenu.prototype.slideOpenEnd = function() { + var _this = this; + _this.main.SpSlidemenuStatus = SLIDE_STATUS.open; + // change style + if (_this.useCssAnimation) { + } else { + setStyles(_this.slidemenu, { + zIndex: '1' + }); + } + // add event + addTouchEvent('start', _this.main, _this.mainTouchStart, false); + }; + SpSlidemenu.prototype.slideClose = function(event) { + var _this = this; + _this.main.SpSlidemenuStatus = SLIDE_STATUS.progress; + + /// Misskey Original + document.body.setAttribute('data-nav-open', 'false'); + + //event + removeTouchEvent('start', _this.main, _this.mainTouchStart, false); + // change style + if (_this.useCssAnimation) { + setStyles(_this.main, { + transform: _this.getTranslateX(0) + }); + setStyles(_this.slidemenu, { + transitionProperty: 'visibility', + visibility: 'hidden', + zIndex: '-1' + }); + } else { + animate(_this.main, _this.direction, 0, ANIME_SPEED.slider); + setStyles(_this.slidemenu, { + zIndex: '-1' + }); + } + // set callback + setTimeout(_this.slideCloseEnd, ANIME_SPEED.slider + EVENT_MOE_TIME); + }; + SpSlidemenu.prototype.slideCloseEnd = function() { + var _this = this; + _this.main.SpSlidemenuStatus = SLIDE_STATUS.close; + // change style + document.documentElement.style['overflowX'] = _this.htmlOverflowX; + document.body.style['overflowX'] = _this.bodyOverflowX; + if (_this.useCssAnimation) { + } else { + setStyles(_this.slidemenu, { + visibility: 'hidden' + }); + } + // set event + removeTouchEvent('move', document, blockEvent, false); + }; + SpSlidemenu.prototype.scrollTouchStart = function(event) { + var _this = this; + if (gestureStart) { + return; + } + if (_this.scrollOverTimer !== false) { + clearTimeout(_this.scrollOverTimer); + } + _this.scrollCurrentY = _this.getScrollCurrentY(); + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'ease-in-out', + transitionDuration: '0ms', + transform: _this.getTranslateY(_this.scrollCurrentY) + }); + } else { + _this.stopScrollAnimate(); + setStyles(_this.slidemenuContent, { + top: _this.scrollCurrentY + 'px' + }); + } + _this.scrollOverTimer = false; + _this.scrollAnimationTimer = false; + _this.scrollTouchStarted = true; + _this.scrollMoveReady = false; + _this.scrollMoveEventCnt = 0; + _this.scrollMaxY = _this.calcMaxY(); + _this.scrollStartPageX = getPage(event, 'pageX'); + _this.scrollStartPageY = getPage(event, 'pageY'); + _this.scrollBasePageY = _this.scrollStartPageY; + _this.scrollTimeForVelocity = event.timeStamp; + _this.scrollPageYForVelocity = _this.scrollStartPageY; + _this.slidemenuContent.removeEventListener('click', blockEvent, true); + }; + SpSlidemenu.prototype.scrollTouchMove = function(event) { + var _this, pageX, pageY, distY, newY, deltaX, deltaY; + _this = this; + if (!_this.scrollTouchStarted || gestureStart) { + return; + } + pageX = getPage(event, 'pageX'); + pageY = getPage(event, 'pageY'); + if (_this.scrollMoveReady) { + event.preventDefault(); + event.stopPropagation(); + distY = pageY - _this.scrollBasePageY; + newY = _this.scrollCurrentY + distY; + if (newY > 0 || newY < _this.scrollMaxY) { + newY = Math.round(_this.scrollCurrentY + distY / 3); + } + _this.scrollSetY(newY); + if (_this.scrollMoveEventCnt % THRESHOLD === 0) { + _this.scrollPageYForVelocity = pageY; + _this.scrollTimeForVelocity = event.timeStamp; + } + _this.scrollMoveEventCnt++; + } else { + deltaX = Math.abs(pageX - _this.scrollStartPageX); + deltaY = Math.abs(pageY - _this.scrollStartPageY); + if (deltaX > 5 || deltaY > 5) { + _this.scrollMoveReady = true; + _this.slidemenuContent.addEventListener('click', blockEvent, true); + } + } + _this.scrollBasePageY = pageY; + }; + SpSlidemenu.prototype.scrollTouchEnd = function(event) { + var _this, speed, deltaY, deltaTime; + _this = this; + if (!_this.scrollTouchStarted) { + return; + } + _this.scrollTouchStarted = false; + _this.scrollMaxY = _this.calcMaxY(); + if (_this.scrollCurrentY > 0 || _this.scrollCurrentY < _this.scrollMaxY) { + _this.scrollOverBack(); + return; + } + deltaY = getPage(event, 'pageY') - _this.scrollPageYForVelocity; + deltaTime = event.timeStamp - _this.scrollTimeForVelocity; + speed = deltaY / deltaTime; + if (Math.abs(speed) >= 0.01) { + _this.scrollInertia(speed); + } + }; + SpSlidemenu.prototype.scrollInertia = function(speed) { + var _this, directionToTop, maxTo, distanceMaxTo, stopTime, canMove, to, duration, speedAtboundary, nextTo; + _this = this; + if (speed > 0) { + directionToTop = true; + maxTo = 0; + } else { + directionToTop = false; + maxTo = _this.scrollMaxY; + } + distanceMaxTo = Math.abs(_this.scrollCurrentY - maxTo); + speed = Math.abs(750 * speed); + if (speed > 1000) { + speed = 1000; + } + stopTime = speed / 500; + canMove = (speed * stopTime) - ((500 * Math.pow(stopTime, 2)) / 2); + if (canMove <= distanceMaxTo) { + if (directionToTop) { + to = _this.scrollCurrentY + canMove; + } else { + to = _this.scrollCurrentY - canMove; + } + duration = stopTime * 1000; + _this.scrollInertiaMove(to, duration, false); + } else { + to = maxTo; + speedAtboundary = Math.sqrt((2 * 500 * distanceMaxTo) + Math.pow(speed, 2)); + duration = (speedAtboundary - speed) / 500 * 1000; + _this.scrollInertiaMove(to, duration, true, speedAtboundary, directionToTop); + } + }; + SpSlidemenu.prototype.scrollInertiaMove = function(to, duration, isOver, speed, directionToTop) { + var _this = this, stopTime, canMove; + _this.scrollCurrentY = to; + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'cubic-bezier(0.33, 0.66, 0.66, 1)', + transitionDuration: duration + 'ms', + transform: _this.getTranslateY(to) + }); + } else { + _this.scrollAnimate(to, duration); + } + if (!isOver) { + return; + } + stopTime = speed / 7500; + canMove = (speed * stopTime) - ((7500 * Math.pow(stopTime, 2)) / 2); + if (directionToTop) { + to = _this.scrollCurrentY + canMove; + } else { + to = _this.scrollCurrentY - canMove; + } + duration = stopTime * 1000; + _this.scrollOver(to, duration); + }; + SpSlidemenu.prototype.scrollOver = function(to, duration) { + var _this; + _this = this; + _this.scrollCurrentY = to; + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'cubic-bezier(0.33, 0.66, 0.66, 1)', + transitionDuration: duration + 'ms', + transform: _this.getTranslateY(to) + }); + } else { + _this.scrollAnimate(to, duration); + } + _this.scrollOverTimer = setTimeout(_this.scrollOverBack, duration); + }; + SpSlidemenu.prototype.scrollOverBack = function() { + var _this, to; + _this = this; + if (_this.scrollCurrentY >= 0) { + to = 0; + } else { + to = _this.scrollMaxY; + } + _this.scrollCurrentY = to; + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'ease-out', + transitionDuration: ANIME_SPEED.scrollOverBack + 'ms', + transform: _this.getTranslateY(to) + }); + } else { + _this.scrollAnimate(to, ANIME_SPEED.scrollOverBack); + } + }; + SpSlidemenu.prototype.scrollSetY = function(y) { + var _this = this; + _this.scrollCurrentY = y; + if (_this.useCssAnimation) { + setStyles(_this.slidemenuContent, { + transitionTimingFunction: 'ease-in-out', + transitionDuration: '0ms', + transform: _this.getTranslateY(y) + }); + } else { + _this.slidemenuContent.style.top = y + 'px'; + } + }; + SpSlidemenu.prototype.scrollAnimate = function(to, transitionDuration) { + var _this = this; + _this.stopScrollAnimate(); + _this.scrollAnimationTimer = animate(_this.slidemenuContent, 'top', to, transitionDuration); + }; + SpSlidemenu.prototype.stopScrollAnimate = function() { + var _this = this; + if (_this.scrollAnimationTimer !== false) { + clearInterval(_this.scrollAnimationTimer); + } + }; + SpSlidemenu.prototype.itemClick = function(event) { + var elem = event.target || event.srcElement; + if (hasClass(elem, ITEM_CLICK_CLASS_NAME)) { + this.slideClose(); + } + }; + SpSlidemenu.prototype.calcMaxY = function(x) { + var _this, contentHeight, bodyHeight, headerHeight; + _this = this; + contentHeight = _this.slidemenuContent.offsetHeight; + bodyHeight = _this.slidemenuBody.offsetHeight; + headerHeight = 0; + if (_this.slidemenuHeader) { + headerHeight = _this.slidemenuHeader.offsetHeight; + } + if (contentHeight > bodyHeight) { + return -(contentHeight - bodyHeight + headerHeight); + } else { + return 0; + } + }; + SpSlidemenu.prototype.getScrollCurrentY = function() { + var ret = 0; + if (this.useCssAnimation) { + getStyle(window.getComputedStyle(this.slidemenuContent, ''), 'transform').split(',').forEach(function(value) { + var number = parseInt(value, 10); + if (!isNaN(number) && number !== 0 && number !== 1) { + ret = number; + } + }); + } else { + var number = parseInt(getStyle(window.getComputedStyle(this.slidemenuContent, ''), 'top'), 10); + if (!isNaN(number) && number !== 0 && number !== 1) { + ret = number; + } + } + return ret; + }; + SpSlidemenu.prototype.getTranslateX = function(x) { + var _this = this; + return _this.use3d ? 'translate3d(' + x + 'px, 0px, 0px)' : 'translate(' + x + 'px, 0px)'; + }; + SpSlidemenu.prototype.getTranslateY = function(y) { + var _this = this; + return _this.use3d ? 'translate3d(0px, ' + y + 'px, 0px)' : 'translate(0px, ' + y + 'px)'; + }; + //Utility Function + function hasProp(props) { + return some(props, function(prop) { + return div.style[prop] !== undefined; + }); + } + function upperCaseFirst(str) { + return str.charAt(0).toUpperCase() + str.substr(1); + } + function some(ary, callback) { + var i, len; + for (i = 0, len = ary.length; i < len; i++) { + if (callback(ary[i], i)) { + return true; + } + } + return false; + } + function setStyle(elem, prop, val) { + var style = elem.style; + if (!setStyle.cache) { + setStyle.cache = {}; + } + if (setStyle.cache[prop] !== undefined) { + style[setStyle.cache[prop]] = val; + return; + } + if (style[prop] !== undefined) { + setStyle.cache[prop] = prop; + style[prop] = val; + return; + } + some(PREFIX, function(_prefix) { + var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop); + if (style[_prop] !== undefined) { + //setStyle.cache[prop] = _prop; + style[_prop] = val; + return true; + } + }); + } + function setStyles(elem, styles) { + var style, prop; + for (prop in styles) { + if (styles.hasOwnProperty(prop)) { + setStyle(elem, prop, styles[prop]); + } + } + } + function getStyle(style, prop) { + var ret; + if (style[prop] !== undefined) { + return style[prop]; + } + some(PREFIX, function(_prefix) { + var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop); + if (style[_prop] !== undefined) { + ret = style[_prop]; + return true; + } + }); + return ret; + } + function getCSSName(prop) { + var ret; + if (!getCSSName.cache) { + getCSSName.cache = {}; + } + if (getCSSName.cache[prop] !== undefined) { + return getCSSName.cache[prop]; + } + if (div.style[prop] !== undefined) { + getCSSName.cache[prop] = prop; + return prop; + } + some(PREFIX, function(_prefix) { + var _prop = upperCaseFirst(_prefix) + upperCaseFirst(prop); + if (div.style[_prop] !== undefined) { + ret = '-' + _prefix + '-' + prop; + return true; + } + }); + getCSSName.cache[prop] = ret; + return ret; + } + function bind(func, context) { + var nativeBind, slice, args; + nativeBind = Function.prototype.bind; + slice = Array.prototype.slice; + if (func.bind === nativeBind && nativeBind) { + return nativeBind.apply(func, slice.call(arguments, 1)); + } + args = slice.call(arguments, 2); + return function() { + return func.apply(context, args.concat(slice.call(arguments))); + }; + } + function blockEvent(event) { + event.preventDefault(); + event.stopPropagation(); + } + function getDimentions(element) { + var previous, key, properties, result; + previous = {}; + properties = { + position: 'absolute', + visibility: 'hidden', + display: 'block' + }; + for (key in properties) { + previous[key] = element.style[key]; + element.style[key] = properties[key]; + } + result = { + width: element.offsetWidth, + height: element.offsetHeight + }; + for (key in properties) { + element.style[key] = previous[key]; + } + return result; + } + function getPage(event, page) { + return event.changedTouches ? event.changedTouches[0][page] : event[page]; + } + function addTouchEvent(eventType, element, listener, useCapture) { + useCapture = useCapture || false; + if (support.touch) { + element.addEventListener(EVENTS[eventType].touch, listener, { passive: useCapture }); + } else { + element.addEventListener(EVENTS[eventType].mouse, listener, { passive: useCapture }); + } + } + function removeTouchEvent(eventType, element, listener, useCapture) { + useCapture = useCapture || false; + if (support.touch) { + element.removeEventListener(EVENTS[eventType].touch, listener, useCapture); + } else { + element.removeEventListener(EVENTS[eventType].mouse, listener, useCapture); + } + } + function hasClass(elem, className) { + className = " " + className + " "; + if (elem.nodeType === 1 && (" " + elem.className + " ").replace(rclass, " ").indexOf(className) >= 0) { + return true; + } + return false; + } + function animate(elem, prop, to, transitionDuration) { + var begin, from, duration, easing, timer; + begin = +new Date(); + from = parseInt(elem.style[prop], 10); + to = parseInt(to, 10); + duration = parseInt(transitionDuration, 10); + easing = function(time, duration) { + return -(time /= duration) * (time - 2); + }; + timer = setInterval(function() { + var time, pos, now; + time = new Date() - begin; + if (time > duration) { + clearInterval(timer); + now = to; + } else { + pos = easing(time, duration); + now = pos * (to - from) + from; + } + elem.style[prop] = now + 'px'; + }, 10); + return timer; + } + function getBrowserHeight() { + if (window.innerHeight) { + return window.innerHeight; + } + else if (document.documentElement && document.documentElement.clientHeight !== 0) { + return document.documentElement.clientHeight; + } + else if (document.body) { + return document.body.clientHeight; + } + return 0; + } + function debounce(func, wait, immediate) { + var timeout, result; + return function() { + var context = this, args = arguments; + var later = function() { + timeout = null; + if (!immediate) result = func.apply(context, args); + }; + var callNow = immediate && !timeout; + clearTimeout(timeout); + timeout = setTimeout(later, wait); + if (callNow) result = func.apply(context, args); + return result; + }; + } + window.SpSlidemenu = SpSlidemenu; +})(window, window.document); diff --git a/src/web/app/mobile/scripts/stream.ls b/src/web/app/mobile/scripts/stream.ls new file mode 100644 index 0000000000..b7810b49ae --- /dev/null +++ b/src/web/app/mobile/scripts/stream.ls @@ -0,0 +1,13 @@ +# Stream +#================================ + +stream = require '../../common/scripts/stream.ls' +riot = require \riot + +module.exports = (me) ~> + s = stream me + + riot.mixin \stream do + stream: s.event + get-stream-state: s.get-state + stream-state-ev: s.state-ev diff --git a/src/web/app/mobile/scripts/ui.ls b/src/web/app/mobile/scripts/ui.ls new file mode 100644 index 0000000000..aa94a8b052 --- /dev/null +++ b/src/web/app/mobile/scripts/ui.ls @@ -0,0 +1,6 @@ +riot = require \riot + +ui = riot.observable! + +riot.mixin \ui do + ui: ui diff --git a/src/web/app/mobile/style.styl b/src/web/app/mobile/style.styl new file mode 100644 index 0000000000..bc7859844a --- /dev/null +++ b/src/web/app/mobile/style.styl @@ -0,0 +1,12 @@ +@import "../base" + +body[data-nav-open='true'] + #hamburger + > i + -webkit-transform rotate(-90deg) + transform rotate(-90deg) + +#wait + top auto + bottom 15px + left 15px diff --git a/src/web/app/mobile/tags.ls b/src/web/app/mobile/tags.ls new file mode 100644 index 0000000000..5805cb6b28 --- /dev/null +++ b/src/web/app/mobile/tags.ls @@ -0,0 +1,44 @@ +require './tags/ui.tag' +require './tags/ui-header.tag' +require './tags/ui-nav.tag' +require './tags/stream-indicator.tag' +require './tags/page/entrance.tag' +require './tags/page/entrance/signin.tag' +require './tags/page/entrance/signup.tag' +require './tags/page/home.tag' +require './tags/page/drive.tag' +require './tags/page/notifications.tag' +require './tags/page/user.tag' +require './tags/page/user-followers.tag' +require './tags/page/user-following.tag' +require './tags/page/post.tag' +require './tags/page/new-post.tag' +require './tags/page/search.tag' +require './tags/home.tag' +require './tags/home-timeline.tag' +require './tags/timeline.tag' +require './tags/timeline-post.tag' +require './tags/timeline-post-sub.tag' +require './tags/post-preview.tag' +require './tags/sub-post-content.tag' +require './tags/images-viewer.tag' +require './tags/drive.tag' +require './tags/drive-selector.tag' +require './tags/drive/file.tag' +require './tags/drive/folder.tag' +require './tags/drive/file-viewer.tag' +require './tags/post-form.tag' +require './tags/notification.tag' +require './tags/notifications.tag' +require './tags/notify.tag' +require './tags/notification-preview.tag' +require './tags/search.tag' +require './tags/search-posts.tag' +require './tags/post-detail.tag' +require './tags/user.tag' +require './tags/user-timeline.tag' +require './tags/follow-button.tag' +require './tags/user-preview.tag' +require './tags/users-list.tag' +require './tags/user-following.tag' +require './tags/user-followers.tag' diff --git a/src/web/app/mobile/tags/drive-selector.tag b/src/web/app/mobile/tags/drive-selector.tag new file mode 100644 index 0000000000..442299026e --- /dev/null +++ b/src/web/app/mobile/tags/drive-selector.tag @@ -0,0 +1,75 @@ +mk-drive-selector + div.body + header + h1 + | ファイルを選択 + span.count(if={ files.length > 0 }) ({ files.length }) + button.close(onclick={ cancel }): i.fa.fa-times + button.ok(onclick={ ok }): i.fa.fa-check + mk-drive@browser(select={ true }, multiple={ opts.multiple }) + +style. + display block + + > .body + position fixed + z-index 2048 + top 0 + left 0 + right 0 + margin 0 auto + width 100% + max-width 500px + height 100% + overflow hidden + background #fff + box-shadow 0 0 16px rgba(#000, 0.3) + + > header + border-bottom solid 1px #eee + + > h1 + margin 0 + padding 0 + text-align center + line-height 42px + font-size 1em + font-weight normal + + > .count + margin-left 4px + opacity 0.5 + + > .close + position absolute + top 0 + left 0 + line-height 42px + width 42px + + > .ok + position absolute + top 0 + right 0 + line-height 42px + width 42px + + > mk-drive + height calc(100% - 42px) + overflow scroll + +script. + @files = [] + + @on \mount ~> + @refs.browser.on \change-selected (files) ~> + @files = files + @update! + + @cancel = ~> + @trigger \canceled + @unmount! + + @ok = ~> + @trigger \selected @files + @unmount! diff --git a/src/web/app/mobile/tags/drive.tag b/src/web/app/mobile/tags/drive.tag new file mode 100644 index 0000000000..fcc78d1e68 --- /dev/null +++ b/src/web/app/mobile/tags/drive.tag @@ -0,0 +1,338 @@ +mk-drive + nav + p(onclick={ go-root }) + i.fa.fa-cloud + | ドライブ + virtual(each={ folder in hierarchy-folders }) + span: i.fa.fa-angle-right + p(onclick={ _move }) { folder.name } + span(if={ folder != null }): i.fa.fa-angle-right + p(if={ folder != null }) { folder.name } + div.browser(if={ file == null }, class={ loading: loading }) + div.folders(if={ folders.length > 0 }) + virtual(each={ folder in folders }) + mk-drive-folder(folder={ folder }) + p(if={ more-folders }) + | もっと読み込む + div.files(if={ files.length > 0 }) + virtual(each={ file in files }) + mk-drive-file(file={ file }) + p(if={ more-files }) + | もっと読み込む + div.empty(if={ files.length == 0 && folders.length == 0 && !loading }) + p(if={ !folder == null }) + | ドライブには何もありません。 + p(if={ folder != null }) + | このフォルダーは空です + div.loading(if={ loading }). +
    +
    +
    +
    + mk-drive-file-viewer(if={ file != null }, file={ file }) + +style. + display block + background #fff + + > nav + display block + width 100% + padding 10px 12px + overflow auto + white-space nowrap + font-size 0.9em + color #555 + background #fff + border-bottom solid 1px #dfdfdf + + > p + display inline + margin 0 + padding 0 + + &:last-child + font-weight bold + + > i + margin-right 4px + + > span + margin 0 8px + opacity 0.5 + + > .browser + &.loading + opacity 0.5 + + > .folders + > mk-drive-folder + border-bottom solid 1px #eee + + > .files + > mk-drive-file + border-bottom solid 1px #eee + + > .empty + padding 16px + text-align center + color #999 + pointer-events none + + > p + margin 0 + + > .loading + .spinner + margin 100px auto + width 40px + height 40px + text-align center + + animation sk-rotate 2.0s infinite linear + + .dot1, .dot2 + width 60% + height 60% + display inline-block + position absolute + top 0 + background-color rgba(0, 0, 0, 0.3) + border-radius 100% + + animation sk-bounce 2.0s infinite ease-in-out + + .dot2 + top auto + bottom 0 + animation-delay -1.0s + + @keyframes sk-rotate { 100% { transform: rotate(360deg); }} + + @keyframes sk-bounce { + 0%, 100% { + transform: scale(0.0); + } 50% { + transform: scale(1.0); + } + } + +script. + @mixin \api + @mixin \stream + + @files = [] + @folders = [] + @hierarchy-folders = [] + @selected-files = [] + + # 現在の階層(フォルダ) + # * null でルートを表す + @folder = null + + @file = null + + @is-select-mode = @opts.select? and @opts.select + @multiple = if @opts.multiple? then @opts.multiple else false + + @on \mount ~> + @stream.on \drive_file_created @on-stream-drive-file-created + @stream.on \drive_file_updated @on-stream-drive-file-updated + @stream.on \drive_folder_created @on-stream-drive-folder-created + @stream.on \drive_folder_updated @on-stream-drive-folder-updated + + # Riotのバグでnullを渡しても""になる + # https://github.com/riot/riot/issues/2080 + #if @opts.folder? + if @opts.folder? and @opts.folder != '' + @cd @opts.folder + else + @load! + + @on \unmount ~> + @stream.off \drive_file_created @on-stream-drive-file-created + @stream.off \drive_file_updated @on-stream-drive-file-updated + @stream.off \drive_folder_created @on-stream-drive-folder-created + @stream.off \drive_folder_updated @on-stream-drive-folder-updated + + @on-stream-drive-file-created = (file) ~> + @add-file file, true + + @on-stream-drive-file-updated = (file) ~> + current = if @folder? then @folder.id else null + if current != file.folder_id + @remove-file file + else + @add-file file, true + + @on-stream-drive-folder-created = (folder) ~> + @add-folder folder, true + + @on-stream-drive-folder-updated = (folder) ~> + current = if @folder? then @folder.id else null + if current != folder.parent_id + @remove-folder folder + else + @add-folder folder, true + + @_move = (ev) ~> + @move ev.item.folder + + @move = (target-folder) ~> + @cd target-folder, true + + @cd = (target-folder, is-move) ~> + if target-folder? and typeof target-folder == \object + target-folder = target-folder.id + + if target-folder == null + @go-root! + return + + @loading = true + @update! + + @api \drive/folders/show do + folder_id: target-folder + .then (folder) ~> + @folder = folder + @hierarchy-folders = [] + + x = (f) ~> + @hierarchy-folders.unshift f + if f.parent? + x f.parent + + if folder.parent? + x folder.parent + + @update! + if is-move then @trigger \move @folder + @trigger \cd @folder + @load! + .catch (err, text-status) -> + console.error err + + @add-folder = (folder, unshift = false) ~> + current = if @folder? then @folder.id else null + if current != folder.parent_id + return + + if (@folders.some (f) ~> f.id == folder.id) + return + + if unshift + @folders.unshift folder + else + @folders.push folder + + @update! + + @add-file = (file, unshift = false) ~> + current = if @folder? then @folder.id else null + if current != file.folder_id + return + + if (@files.some (f) ~> f.id == file.id) + exist = (@files.map (f) -> f.id).index-of file.id + @files[exist] = file + @update! + return + + if unshift + @files.unshift file + else + @files.push file + + @update! + + @remove-folder = (folder) ~> + if typeof folder == \object + folder = folder.id + @folders = @folders.filter (f) -> f.id != folder + @update! + + @remove-file = (file) ~> + if typeof file == \object + file = file.id + @files = @files.filter (f) -> f.id != file + @update! + + @go-root = ~> + if @folder != null + @folder = null + @hierarchy-folders = [] + @update! + @trigger \move-root + @load! + + @load = ~> + @folders = [] + @files = [] + @more-folders = false + @more-files = false + @loading = true + @update! + + @trigger \begin-load + + load-folders = null + load-files = null + + folders-max = 20 + files-max = 20 + + # フォルダ一覧取得 + @api \drive/folders do + folder_id: if @folder? then @folder.id else null + limit: folders-max + 1 + .then (folders) ~> + if folders.length == folders-max + 1 + @more-folders = true + folders.pop! + load-folders := folders + complete! + .catch (err, text-status) ~> + console.error err + + # ファイル一覧取得 + @api \drive/files do + folder_id: if @folder? then @folder.id else null + limit: files-max + 1 + .then (files) ~> + if files.length == files-max + 1 + @more-files = true + files.pop! + load-files := files + complete! + .catch (err, text-status) ~> + console.error err + + flag = false + complete = ~> + if flag + load-folders.for-each (folder) ~> + @add-folder folder + load-files.for-each (file) ~> + @add-file file + @loading = false + @update! + + @trigger \loaded + else + flag := true + @trigger \load-mid + + @choose-file = (file) ~> + if @is-select-mode + exist = @selected-files.some (f) ~> f.id == file.id + if exist + @selected-files = (@selected-files.filter (f) ~> f.id != file.id) + else + @selected-files.push file + @update! + @trigger \change-selected @selected-files + else + @file = file + @update! + @trigger \open-file @file diff --git a/src/web/app/mobile/tags/drive/file-viewer.tag b/src/web/app/mobile/tags/drive/file-viewer.tag new file mode 100644 index 0000000000..8ce89a06f4 --- /dev/null +++ b/src/web/app/mobile/tags/drive/file-viewer.tag @@ -0,0 +1,8 @@ +mk-drive-file-viewer + p.name { file.name } + +style. + display block + +script. + @file = @opts.file diff --git a/src/web/app/mobile/tags/drive/file.tag b/src/web/app/mobile/tags/drive/file.tag new file mode 100644 index 0000000000..ec271441a5 --- /dev/null +++ b/src/web/app/mobile/tags/drive/file.tag @@ -0,0 +1,130 @@ +mk-drive-file(onclick={ onclick }, data-is-selected={ is-selected }) + div.container + div.thumbnail(style={ 'background-image: url(' + file.url + '?thumbnail&size=128)' }) + div.body + p.name { file.name } + // + if file.tags.length > 0 + ul.tags + each tag in file.tags + li.tag(style={background: tag.color, color: contrast(tag.color)})= tag.name + footer + p.type + mk-file-type-icon(file={ file }) + | { file.type } + p.separator + p.data-size { bytes-to-size(file.datasize) } + p.separator + p.created-at + i.fa.fa-clock-o + mk-time(time={ file.created_at }) + +style. + display block + + &, * + user-select none + + * + pointer-events none + + > .container + max-width 500px + margin 0 auto + padding 16px + + &:after + content "" + display block + clear both + + > .thumbnail + display block + float left + width 64px + height 64px + background-size cover + background-position center center + + > .body + display block + float left + width calc(100% - 74px) + margin-left 10px + + > .name + display block + margin 0 + padding 0 + font-size 0.9em + font-weight bold + color #555 + text-overflow ellipsis + word-wrap break-word + + > .tags + display block + margin 4px 0 0 0 + padding 0 + list-style none + font-size 0.5em + + > .tag + display inline-block + margin 0 5px 0 0 + padding 1px 5px + border-radius 2px + + > footer + display block + margin 4px 0 0 0 + font-size 0.7em + + > .separator + display inline + margin 0 + padding 0 4px + color #CDCDCD + + > .type + display inline + margin 0 + padding 0 + color #9D9D9D + + > mk-file-type-icon + margin-right 4px + + > .data-size + display inline + margin 0 + padding 0 + color #9D9D9D + + > .created-at + display inline + margin 0 + padding 0 + color #BDBDBD + + > i + margin-right 2px + + &[data-is-selected] + background $theme-color + + &, * + color #fff !important + +script. + @mixin \bytes-to-size + + @browser = @parent + @file = @opts.file + @is-selected = @browser.selected-files.some (f) ~> f.id == @file.id + + @browser.on \change-selected (selects) ~> + @is-selected = selects.some (f) ~> f.id == @file.id + + @onclick = ~> + @browser.choose-file @file diff --git a/src/web/app/mobile/tags/drive/folder.tag b/src/web/app/mobile/tags/drive/folder.tag new file mode 100644 index 0000000000..ef3a72ea93 --- /dev/null +++ b/src/web/app/mobile/tags/drive/folder.tag @@ -0,0 +1,45 @@ +mk-drive-folder(onclick={ onclick }) + div.container + p.name + i.fa.fa-folder + | { folder.name } + i.fa.fa-angle-right + +style. + display block + color #777 + + &, * + user-select none + + * + pointer-events none + + > .container + max-width 500px + margin 0 auto + padding 16px + + > .name + display block + margin 0 + padding 0 + + > i + margin-right 6px + + > i + position absolute + top 0 + bottom 0 + right 8px + margin auto 0 auto 0 + width 1em + height 1em + +script. + @browser = @parent + @folder = @opts.folder + + @onclick = ~> + @browser.move @folder diff --git a/src/web/app/mobile/tags/follow-button.tag b/src/web/app/mobile/tags/follow-button.tag new file mode 100644 index 0000000000..7cedbbee88 --- /dev/null +++ b/src/web/app/mobile/tags/follow-button.tag @@ -0,0 +1,108 @@ +mk-follow-button + button(if={ !init }, class={ wait: wait, follow: !user.is_following, unfollow: user.is_following }, + onclick={ onclick }, + disabled={ wait }) + i.fa.fa-minus(if={ !wait && user.is_following }) + i.fa.fa-plus(if={ !wait && !user.is_following }) + i.fa.fa-spinner.fa-pulse.fa-fw(if={ wait }) + | { user.is_following ? 'フォロー解除' : 'フォロー' } + div.init(if={ init }): i.fa.fa-spinner.fa-pulse.fa-fw + +style. + display block + + > button + > .init + display block + user-select none + cursor pointer + padding 0 16px + margin 0 + height inherit + font-size 16px + outline none + border solid 1px $theme-color + border-radius 4px + + * + pointer-events none + + &.follow + color $theme-color + background transparent + + &:hover + background rgba($theme-color, 0.1) + + &:active + background rgba($theme-color, 0.2) + + &.unfollow + color $theme-color-foreground + background $theme-color + + &.wait + cursor wait !important + opacity 0.7 + + &.init + cursor wait !important + opacity 0.7 + + > i + margin-right 4px + +script. + @mixin \api + @mixin \is-promise + @mixin \stream + + @user = null + @user-promise = if @is-promise @opts.user then @opts.user else Promise.resolve @opts.user + @init = true + @wait = false + + @on \mount ~> + @user-promise.then (user) ~> + @user = user + @init = false + @update! + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + @on \unmount ~> + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + @on-stream-follow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @on-stream-unfollow = (user) ~> + if user.id == @user.id + @user = user + @update! + + @onclick = ~> + @wait = true + if @user.is_following + @api \following/delete do + user_id: @user.id + .then ~> + @user.is_following = false + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! + else + @api \following/create do + user_id: @user.id + .then ~> + @user.is_following = true + .catch (err) -> + console.error err + .then ~> + @wait = false + @update! diff --git a/src/web/app/mobile/tags/home-timeline.tag b/src/web/app/mobile/tags/home-timeline.tag new file mode 100644 index 0000000000..1754bb2b07 --- /dev/null +++ b/src/web/app/mobile/tags/home-timeline.tag @@ -0,0 +1,40 @@ +mk-home-timeline + mk-timeline@timeline(init={ init }, more={ more }, empty={ '表示する投稿がありません。誰かしらをフォローするなどしましょう。' }) + +style. + display block + +script. + @mixin \api + @mixin \stream + + @init = new Promise (res, rej) ~> + @api \posts/timeline + .then (posts) ~> + res posts + @trigger \loaded + + @on \mount ~> + @stream.on \post @on-stream-post + @stream.on \follow @on-stream-follow + @stream.on \unfollow @on-stream-unfollow + + @on \unmount ~> + @stream.off \post @on-stream-post + @stream.off \follow @on-stream-follow + @stream.off \unfollow @on-stream-unfollow + + @more = ~> + @api \posts/timeline do + max_id: @refs.timeline.tail!.id + + @on-stream-post = (post) ~> + @is-empty = false + @update! + @refs.timeline.add-post post + + @on-stream-follow = ~> + @fetch! + + @on-stream-unfollow = ~> + @fetch! diff --git a/src/web/app/mobile/tags/home.tag b/src/web/app/mobile/tags/home.tag new file mode 100644 index 0000000000..ebcf8f0bb2 --- /dev/null +++ b/src/web/app/mobile/tags/home.tag @@ -0,0 +1,17 @@ +mk-home + mk-home-timeline@tl + +style. + display block + + > mk-home-timeline + max-width 600px + margin 0 auto + + @media (min-width 500px) + padding 16px + +script. + @on \mount ~> + @refs.tl.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/mobile/tags/images-viewer.tag b/src/web/app/mobile/tags/images-viewer.tag new file mode 100644 index 0000000000..f9d774a124 --- /dev/null +++ b/src/web/app/mobile/tags/images-viewer.tag @@ -0,0 +1,25 @@ +mk-images-viewer + div.image@view(onclick={ click }) + img@img(src={ image.url + '?thumbnail&size=512' }, alt={ image.name }, title={ image.name }) + +style. + display block + padding 8px + overflow hidden + box-shadow 0 0 4px rgba(0, 0, 0, 0.2) + border-radius 4px + + > .image + + > img + display block + max-height 256px + max-width 100% + margin 0 auto + +script. + @images = @opts.images + @image = @images.0 + + @click = ~> + window.open @image.url diff --git a/src/web/app/mobile/tags/notification-preview.tag b/src/web/app/mobile/tags/notification-preview.tag new file mode 100644 index 0000000000..ee936df7ab --- /dev/null +++ b/src/web/app/mobile/tags/notification-preview.tag @@ -0,0 +1,117 @@ +mk-notification-preview(class={ notification.type }) + div.main(if={ notification.type == 'like' }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-thumbs-o-up + | { notification.user.name } + p.post-ref { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'repost' }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-retweet + | { notification.post.user.name } + p.post-ref { get-post-summary(notification.post.repost) } + + div.main(if={ notification.type == 'quote' }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-quote-left + | { notification.post.user.name } + p.post-preview { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'follow' }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-user-plus + | { notification.user.name } + + div.main(if={ notification.type == 'reply' }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-reply + | { notification.post.user.name } + p.post-preview { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'mention' }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-at + | { notification.post.user.name } + p.post-preview { get-post-summary(notification.post) } + +style. + display block + margin 0 + padding 8px + color #fff + + > .main + word-wrap break-word + + &:after + content "" + display block + clear both + + img + display block + float left + min-width 36px + min-height 36px + max-width 36px + max-height 36px + border-radius 6px + + .text + float right + width calc(100% - 36px) + padding-left 8px + + p + margin 0 + + i + margin-right 4px + + .post-ref + + &:before, &:after + font-family FontAwesome + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &:before + content "\f10d" + + &:after + content "\f10e" + + &.like + .text p i + color #FFAC33 + + &.repost, &.quote + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + + &.reply, &.mention + .text p i + color #fff + +script. + @mixin \get-post-summary + @notification = @opts.notification diff --git a/src/web/app/mobile/tags/notification.tag b/src/web/app/mobile/tags/notification.tag new file mode 100644 index 0000000000..afcc7441b4 --- /dev/null +++ b/src/web/app/mobile/tags/notification.tag @@ -0,0 +1,142 @@ +mk-notification(class={ notification.type }) + mk-time(time={ notification.created_at }) + + div.main(if={ notification.type == 'like' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-thumbs-o-up + a(href={ CONFIG.url + '/' + notification.user.username }) { notification.user.name } + a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'repost' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-retweet + a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name } + a.post-ref(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post.repost) } + + div.main(if={ notification.type == 'quote' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-quote-left + a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'follow' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.user.username }) + img.avatar(src={ notification.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-user-plus + a(href={ CONFIG.url + '/' + notification.user.username }) { notification.user.name } + + div.main(if={ notification.type == 'reply' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-reply + a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + + div.main(if={ notification.type == 'mention' }) + a.avatar-anchor(href={ CONFIG.url + '/' + notification.post.user.username }) + img.avatar(src={ notification.post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.text + p + i.fa.fa-at + a(href={ CONFIG.url + '/' + notification.post.user.username }) { notification.post.user.name } + a.post-preview(href={ CONFIG.url + '/' + notification.post.user.username + '/' + notification.post.id }) { get-post-summary(notification.post) } + +style. + display block + margin 0 + padding 16px + + > mk-time + display inline + position absolute + top 16px + right 12px + vertical-align top + color rgba(0, 0, 0, 0.6) + font-size 12px + + > .main + word-wrap break-word + + &:after + content "" + display block + clear both + + .avatar-anchor + display block + float left + + img + min-width 36px + min-height 36px + max-width 36px + max-height 36px + border-radius 6px + + .text + float right + width calc(100% - 36px) + padding-left 8px + + p + margin 0 + + i + margin-right 4px + + .post-preview + color rgba(0, 0, 0, 0.7) + + .post-ref + color rgba(0, 0, 0, 0.7) + + &:before, &:after + font-family FontAwesome + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &:before + content "\f10d" + + &:after + content "\f10e" + + &.like + .text p i + color #FFAC33 + + &.repost, &.quote + .text p i + color #77B255 + + &.follow + .text p i + color #53c7ce + + &.reply, &.mention + .text p i + color #555 + + .post-preview + color rgba(0, 0, 0, 0.7) + +script. + @mixin \get-post-summary + @notification = @opts.notification diff --git a/src/web/app/mobile/tags/notifications.tag b/src/web/app/mobile/tags/notifications.tag new file mode 100644 index 0000000000..7510d59967 --- /dev/null +++ b/src/web/app/mobile/tags/notifications.tag @@ -0,0 +1,98 @@ +mk-notifications + div.notifications(if={ notifications.length != 0 }) + virtual(each={ notification, i in notifications }) + mk-notification(notification={ notification }) + + p.date(if={ i != notifications.length - 1 && notification._date != notifications[i + 1]._date }) + span + i.fa.fa-angle-up + | { notification._datetext } + span + i.fa.fa-angle-down + | { notifications[i + 1]._datetext } + + p.empty(if={ notifications.length == 0 && !loading }) + | ありません! + p.loading(if={ loading }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + background #fff + + > .notifications + margin 0 auto + max-width 500px + + > mk-notification + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + &:last-child + border-bottom none + + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.8em + color #aaa + background #fdfdfd + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + span + margin 0 16px + + i + margin-right 8px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \api + @mixin \stream + @mixin \get-post-summary + + @notifications = [] + @loading = true + + @on \mount ~> + @api \i/notifications + .then (notifications) ~> + @notifications = notifications + @loading = false + @update! + @trigger \loaded + .catch (err, text-status) -> + console.error err + + @stream.on \notification @on-notification + + @on \unmount ~> + @stream.off \notification @on-notification + + @on-notification = (notification) ~> + @notifications.unshift notification + @update! + + @on \update ~> + @notifications.for-each (notification) ~> + date = (new Date notification.created_at).get-date! + month = (new Date notification.created_at).get-month! + 1 + notification._date = date + notification._datetext = month + '月 ' + date + '日' diff --git a/src/web/app/mobile/tags/notify.tag b/src/web/app/mobile/tags/notify.tag new file mode 100644 index 0000000000..9dd93ccf25 --- /dev/null +++ b/src/web/app/mobile/tags/notify.tag @@ -0,0 +1,35 @@ +mk-notify + mk-notification-preview(notification={ opts.notification }) + +style. + display block + position fixed + z-index 1024 + bottom -64px + left 0 + width 100% + height 64px + pointer-events none + -webkit-backdrop-filter blur(2px) + backdrop-filter blur(2px) + background-color rgba(#000, 0.5) + +script. + @on \mount ~> + Velocity @root, { + bottom: \0px + } { + duration: 500ms + easing: \ease-out + } + + set-timeout ~> + Velocity @root, { + bottom: \-64px + } { + duration: 500ms + easing: \ease-out + complete: ~> + @unmount! + } + , 6000ms diff --git a/src/web/app/mobile/tags/page/drive.tag b/src/web/app/mobile/tags/page/drive.tag new file mode 100644 index 0000000000..9bef7e6648 --- /dev/null +++ b/src/web/app/mobile/tags/page/drive.tag @@ -0,0 +1,46 @@ +mk-drive-page + mk-ui@ui: mk-drive@browser(folder={ parent.opts.folder }, file={ parent.opts.file }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + + @on \mount ~> + document.title = 'Misskey Drive' + @ui.trigger \title 'ドライブ' + + @refs.ui.refs.browser.on \begin-load ~> + @Progress.start! + + @refs.ui.refs.browser.on \loaded-mid ~> + @Progress.set 0.5 + + @refs.ui.refs.browser.on \loaded ~> + @Progress.done! + + @refs.ui.refs.browser.on \move-root ~> + @ui.trigger \title 'ドライブ' + + # Rewrite URL + history.push-state null null '/i/drive' + + @refs.ui.refs.browser.on \cd (folder) ~> + # TODO: escape html characters in folder.name + @ui.trigger \title '' + folder.name + + @refs.ui.refs.browser.on \move (folder) ~> + # Rewrite URL + history.push-state null null '/i/drive/folder/' + folder.id + + @refs.ui.refs.browser.on \open-file (file) ~> + # TODO: escape html characters in file.name + @ui.trigger \title '' + file.name + + # Rewrite URL + history.push-state null null '/i/drive/file/' + file.id + + riot.mount \mk-file-type-icon do + file: file diff --git a/src/web/app/mobile/tags/page/entrance.tag b/src/web/app/mobile/tags/page/entrance.tag new file mode 100644 index 0000000000..67d8bc9bbf --- /dev/null +++ b/src/web/app/mobile/tags/page/entrance.tag @@ -0,0 +1,57 @@ +mk-entrance + main + img(src='/_/resources/title.svg', alt='Misskey') + + mk-entrance-signin(if={ mode == 'signin' }) + mk-entrance-signup(if={ mode == 'signup' }) + div.introduction(if={ mode == 'introduction' }) + mk-introduction + button(onclick={ signin }) わかった + + footer + mk-copyright + +style. + display block + height 100% + + > main + display block + + > img + display block + width 130px + height 120px + margin 0 auto + + > .introduction + max-width 300px + margin 0 auto + color #666 + + > button + display block + margin 16px auto 0 auto + + > footer + > mk-copyright + margin 0 + text-align center + line-height 64px + font-size 10px + color rgba(#000, 0.5) + +script. + @mode = \signin + + @signup = ~> + @mode = \signup + @update! + + @signin = ~> + @mode = \signin + @update! + + @introduction = ~> + @mode = \introduction + @update! diff --git a/src/web/app/mobile/tags/page/entrance/signin.tag b/src/web/app/mobile/tags/page/entrance/signin.tag new file mode 100644 index 0000000000..484c414e8e --- /dev/null +++ b/src/web/app/mobile/tags/page/entrance/signin.tag @@ -0,0 +1,45 @@ +mk-entrance-signin + mk-signin + div.divider: span or + button.signup(onclick={ parent.signup }) 新規登録 + a.introduction(onclick={ parent.introduction }) Misskeyについて + +style. + display block + margin 0 auto + padding 0 8px + max-width 350px + text-align center + + > .signup + padding 16px + width 100% + font-size 1em + color #fff + background $theme-color + border-radius 3px + + > .divider + padding 16px 0 + text-align center + + &:after + content "" + display block + position absolute + top 50% + width 100% + height 1px + border-top solid 1px rgba(0, 0, 0, 0.1) + + > * + z-index 1 + padding 0 8px + color rgba(0, 0, 0, 0.5) + background #fdfdfd + + > .introduction + display inline-block + margin-top 16px + font-size 12px + color #666 diff --git a/src/web/app/mobile/tags/page/entrance/signup.tag b/src/web/app/mobile/tags/page/entrance/signup.tag new file mode 100644 index 0000000000..a28f85e634 --- /dev/null +++ b/src/web/app/mobile/tags/page/entrance/signup.tag @@ -0,0 +1,35 @@ +mk-entrance-signup + mk-signup + button.cancel(type='button', onclick={ parent.signin }, title='キャンセル'): i.fa.fa-times + +style. + display block + margin 0 auto + padding 0 8px + max-width 350px + + > .cancel + cursor pointer + display block + position absolute + top 0 + right 0 + z-index 1 + margin 0 + padding 0 + font-size 1.2em + color #999 + border none + outline none + box-shadow none + background transparent + transition opacity 0.1s ease + + &:hover + color #555 + + &:active + color #222 + + > i + padding 14px diff --git a/src/web/app/mobile/tags/page/home.tag b/src/web/app/mobile/tags/page/home.tag new file mode 100644 index 0000000000..c8d7729652 --- /dev/null +++ b/src/web/app/mobile/tags/page/home.tag @@ -0,0 +1,40 @@ +mk-home-page + mk-ui@ui: mk-home@home + +style. + display block + +script. + @mixin \i + @mixin \ui + @mixin \ui-progress + @mixin \stream + @mixin \get-post-summary + + @unread-count = 0 + + @on \mount ~> + document.title = 'Misskey' + @ui.trigger \title 'ホーム' + + @Progress.start! + + @stream.on \post @on-stream-post + document.add-event-listener \visibilitychange @window-on-visibilitychange, false + + @refs.ui.refs.home.on \loaded ~> + @Progress.done! + + @on \unmount ~> + @stream.off \post @on-stream-post + document.remove-event-listener \visibilitychange @window-on-visibilitychange + + @on-stream-post = (post) ~> + if document.hidden and post.user_id !== @I.id + @unread-count++ + document.title = '(' + @unread-count + ') ' + @get-post-summary post + + @window-on-visibilitychange = ~> + if !document.hidden + @unread-count = 0 + document.title = 'Misskey' diff --git a/src/web/app/mobile/tags/page/new-post.tag b/src/web/app/mobile/tags/page/new-post.tag new file mode 100644 index 0000000000..21e00fc1f9 --- /dev/null +++ b/src/web/app/mobile/tags/page/new-post.tag @@ -0,0 +1,5 @@ +mk-new-post-page + mk-post-form@form + +style. + display block diff --git a/src/web/app/mobile/tags/page/notifications.tag b/src/web/app/mobile/tags/page/notifications.tag new file mode 100644 index 0000000000..9fb34dcd75 --- /dev/null +++ b/src/web/app/mobile/tags/page/notifications.tag @@ -0,0 +1,18 @@ +mk-notifications-page + mk-ui@ui: mk-notifications@notifications + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + + @on \mount ~> + document.title = 'Misskey | 通知' + @ui.trigger \title '通知' + + @Progress.start! + + @refs.ui.refs.notifications.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/post.tag b/src/web/app/mobile/tags/page/post.tag new file mode 100644 index 0000000000..1dc74d267a --- /dev/null +++ b/src/web/app/mobile/tags/page/post.tag @@ -0,0 +1,31 @@ +mk-post-page + mk-ui@ui: main: mk-post-detail@post(post={ parent.post }) + +style. + display block + + main + background #fff + + > mk-post-detail + width 100% + max-width 500px + margin 0 auto + +script. + @mixin \ui + @mixin \ui-progress + + @post = @opts.post + + @on \mount ~> + document.title = 'Misskey' + @ui.trigger \title '投稿' + + @Progress.start! + + @refs.ui.refs.post.on \post-fetched ~> + @Progress.set 0.5 + + @refs.ui.refs.post.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/search.tag b/src/web/app/mobile/tags/page/search.tag new file mode 100644 index 0000000000..20de271f73 --- /dev/null +++ b/src/web/app/mobile/tags/page/search.tag @@ -0,0 +1,19 @@ +mk-search-page + mk-ui@ui: mk-search@search(query={ parent.opts.query }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + + @on \mount ~> + document.title = '検索: ' + @opts.query + ' | Misskey' + # TODO: クエリをHTMLエスケープ + @ui.trigger \title '' + @opts.query + + @Progress.start! + + @refs.ui.refs.search.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/user-followers.tag b/src/web/app/mobile/tags/page/user-followers.tag new file mode 100644 index 0000000000..e7e9a6fd1e --- /dev/null +++ b/src/web/app/mobile/tags/page/user-followers.tag @@ -0,0 +1,31 @@ +mk-user-followers-page + mk-ui@ui: mk-user-followers@list(if={ !parent.fetching }, user={ parent.user }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + @mixin \api + + @fetching = true + @user = null + + @on \mount ~> + @Progress.start! + + @api \users/show do + username: @opts.user + .then (user) ~> + @user = user + @fetching = false + + document.title = user.name + 'のフォロワー | Misskey' + # TODO: ユーザー名をエスケープ + @ui.trigger \title '' + user.name + 'のフォロー' + + @update! + + @refs.ui.refs.list.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/user-following.tag b/src/web/app/mobile/tags/page/user-following.tag new file mode 100644 index 0000000000..a74ba97b72 --- /dev/null +++ b/src/web/app/mobile/tags/page/user-following.tag @@ -0,0 +1,31 @@ +mk-user-following-page + mk-ui@ui: mk-user-following@list(if={ !parent.fetching }, user={ parent.user }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + @mixin \api + + @fetching = true + @user = null + + @on \mount ~> + @Progress.start! + + @api \users/show do + username: @opts.user + .then (user) ~> + @user = user + @fetching = false + + document.title = user.name + 'のフォロー | Misskey' + # TODO: ユーザー名をエスケープ + @ui.trigger \title '' + user.name + 'のフォロー' + + @update! + + @refs.ui.refs.list.on \loaded ~> + @Progress.done! diff --git a/src/web/app/mobile/tags/page/user.tag b/src/web/app/mobile/tags/page/user.tag new file mode 100644 index 0000000000..9667abfd14 --- /dev/null +++ b/src/web/app/mobile/tags/page/user.tag @@ -0,0 +1,20 @@ +mk-user-page + mk-ui@ui: mk-user@user(user={ parent.user }, page={ parent.opts.page }) + +style. + display block + +script. + @mixin \ui + @mixin \ui-progress + + @user = @opts.user + + @on \mount ~> + @Progress.start! + + @refs.ui.refs.user.on \loaded (user) ~> + @Progress.done! + document.title = user.name + ' | Misskey' + # TODO: ユーザー名をエスケープ + @ui.trigger \title '' + user.name diff --git a/src/web/app/mobile/tags/post-detail.tag b/src/web/app/mobile/tags/post-detail.tag new file mode 100644 index 0000000000..c7eb091ce6 --- /dev/null +++ b/src/web/app/mobile/tags/post-detail.tag @@ -0,0 +1,415 @@ +mk-post-detail + + div.fetching(if={ fetching }) + mk-ellipsis-icon + + div.main(if={ !fetching }) + + button.read-more(if={ p.reply_to && p.reply_to.reply_to_id && context == null }, onclick={ load-context }, disabled={ loading-context }) + i.fa.fa-ellipsis-v(if={ !loading-context }) + i.fa.fa-spinner.fa-pulse(if={ loading-context }) + + div.context + virtual(each={ post in context }) + mk-post-preview(post={ post }) + + div.reply-to(if={ p.reply_to }) + mk-post-preview(post={ p.reply_to }) + + div.repost(if={ is-repost }) + p + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=32' }, alt='avatar') + i.fa.fa-retweet + a.name(href={ CONFIG.url + '/' + post.user.username }) { post.user.name } + | がRepost + + article + a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username }) + img.avatar(src={ p.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + header + a.name(href={ CONFIG.url + '/' + p.user.username }) + | { p.user.name } + span.username + | @{ p.user.username } + div.body + div.text@text + div.media(if={ p.media }) + virtual(each={ file in p.media }) + img(src={ file.url + '?thumbnail&size=512' }, alt={ file.name }, title={ file.name }) + a.time(href={ url }) + mk-time(time={ p.created_at }, mode='detail') + footer + button(onclick={ reply }, title='返信') + i.fa.fa-reply + p.count(if={ p.replies_count > 0 }) { p.replies_count } + button(onclick={ repost }, title='Repost') + i.fa.fa-retweet + p.count(if={ p.repost_count > 0 }) { p.repost_count } + button(class={ liked: p.is_liked }, onclick={ like }, title='善哉') + i.fa.fa-thumbs-o-up + p.count(if={ p.likes_count > 0 }) { p.likes_count } + button(onclick={ NotImplementedException }): i.fa.fa-ellipsis-h + div.reposts-and-likes + div.reposts(if={ reposts && reposts.length > 0 }) + header + a { p.repost_count } + p Repost + ol.users + li.user(each={ reposts }) + a.avatar-anchor(href={ CONFIG.url + '/' + user.username }, title={ user.name }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=32' }, alt='') + div.likes(if={ likes && likes.length > 0 }) + header + a { p.likes_count } + p いいね + ol.users + li.user(each={ likes }) + a.avatar-anchor(href={ CONFIG.url + '/' + username }, title={ name }) + img.avatar(src={ avatar_url + '?thumbnail&size=32' }, alt='') + + div.replies + virtual(each={ post in replies }) + mk-post-detail-sub(post={ post }) + +style. + display block + margin 0 + padding 0 + + > .fetching + padding 64px 0 + + > .main + + > .read-more + display block + margin 0 + padding 10px 0 + width 100% + font-size 1em + text-align center + color #999 + cursor pointer + background #fafafa + outline none + border none + border-bottom solid 1px #eef0f2 + border-radius 6px 6px 0 0 + box-shadow none + + &:hover + background #f6f6f6 + + &:active + background #f0f0f0 + + &:disabled + color #ccc + + > .context + > * + border-bottom 1px solid #eef0f2 + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 16px 32px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + min-width 28px + min-height 28px + max-width 28px + max-height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + & + article + padding-top 8px + + > .reply-to + border-bottom 1px solid #eef0f2 + + > article + padding 14px 16px 9px 16px + + @media (min-width 500px) + padding 28px 32px 18px 32px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + + > .avatar + display block + width 54px + height 54px + margin 0 + border-radius 8px + vertical-align bottom + + @media (min-width 500px) + width 60px + height 60px + + > header + position absolute + top 18px + left 80px + width calc(100% - 80px) + + @media (min-width 500px) + top 28px + left 108px + width calc(100% - 108px) + + > .name + display inline-block + margin 0 + color #777 + font-size 16px + font-weight bold + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + display block + text-align left + margin 0 + color #ccc + + > .body + padding 8px 0 + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 16px + color #717171 + + @media (min-width 500px) + font-size 24px + + > mk-url-preview + margin-top 8px + + > .media + > img + display block + max-width 100% + + > .time + font-size 16px + color #c0c0c0 + + > footer + font-size 1.2em + + > button + margin 0 28px 0 0 + padding 8px + background transparent + border none + box-shadow none + font-size 1em + color #ddd + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.liked + color $theme-color + + > .reposts-and-likes + display flex + justify-content center + padding 0 + margin 16px 0 + + &:empty + display none + + > .reposts + > .likes + display flex + flex 1 1 + padding 0 + border-top solid 1px #F2EFEE + + > header + flex 1 1 80px + max-width 80px + padding 8px 5px 0px 10px + + > a + display block + font-size 1.5em + line-height 1.4em + + > p + display block + margin 0 + font-size 0.7em + line-height 1em + font-weight normal + color #a0a2a5 + + > .users + display block + flex 1 1 + margin 0 + padding 10px 10px 10px 5px + list-style none + + > .user + display block + float left + margin 4px + padding 0 + + > .avatar-anchor + display:block + + > .avatar + vertical-align bottom + width 24px + height 24px + border-radius 4px + + > .reposts + .likes + margin-left 16px + + > .replies + > * + border-top 1px solid #eef0f2 + +script. + @mixin \api + @mixin \text + @mixin \get-post-summary + @mixin \open-post-form + + @fetching = true + @loading-context = false + @content = null + @post = null + + @on \mount ~> + @api \posts/show do + post_id: @opts.post + .then (post) ~> + @post = post + @is-repost = @post.repost? + @p = if @is-repost then @post.repost else @post + @summary = @get-post-summary @p + @trigger \loaded + @fetching = false + @update! + + if @p.text? + tokens = @analyze @p.text + @refs.text.innerHTML = @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content + + # Get likes + @api \posts/likes do + post_id: @p.id + limit: 8 + .then (likes) ~> + @likes = likes + @update! + + # Get reposts + @api \posts/reposts do + post_id: @p.id + limit: 8 + .then (reposts) ~> + @reposts = reposts + @update! + + # Get replies + @api \posts/replies do + post_id: @p.id + limit: 8 + .then (replies) ~> + @replies = replies + @update! + + @reply = ~> + @open-post-form do + reply: @p + + @repost = ~> + text = window.prompt '「' + @summary + '」をRepost' + if text? + @api \posts/create do + repost_id: @p.id + text: if text == '' then undefined else text + + @like = ~> + if @p.is_liked + @api \posts/likes/delete do + post_id: @p.id + .then ~> + @p.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @p.id + .then ~> + @p.is_liked = true + @update! + + @load-context = ~> + @loading-context = true + + # Get context + @api \posts/context do + post_id: @p.reply_to_id + .then (context) ~> + @context = context.reverse! + @loading-context = false + @update! diff --git a/src/web/app/mobile/tags/post-form.tag b/src/web/app/mobile/tags/post-form.tag new file mode 100644 index 0000000000..759a0820b8 --- /dev/null +++ b/src/web/app/mobile/tags/post-form.tag @@ -0,0 +1,254 @@ +mk-post-form + header: div + button.cancel(onclick={ cancel }): i.fa.fa-times + div + span.text-count(class={ over: refs.text.value.length > 300 }) { 300 - refs.text.value.length } + button.submit(onclick={ post }) 投稿 + div.form + mk-post-preview(if={ opts.reply }, post={ opts.reply }) + textarea@text(disabled={ wait }, oninput={ update }, onkeypress={ onkeypress }, onpaste={ onpaste }, placeholder={ opts.reply ? 'この投稿への返信...' : 'いまどうしてる?' }) + div.attaches(if={ files.length != 0 }) + ul.files@attaches + li.file(each={ files }) + div.img(style='background-image: url({ url + "?thumbnail&size=64" })', title={ name }) + li.add(if={ files.length < 4 }, title='PCからファイルを添付', onclick={ select-file }): i.fa.fa-plus + mk-uploader@uploader + button@upload(onclick={ select-file }): i.fa.fa-upload + button@drive(onclick={ select-file-from-drive }): i.fa.fa-cloud + input@file(type='file', accept='image/*', multiple, onchange={ change-file }) + +style. + display block + padding-top 50px + + > header + position fixed + z-index 1000 + top 0 + left 0 + width 100% + height 50px + background #fff + + > div + max-width 500px + margin 0 auto + + > .cancel + width 50px + line-height 50px + font-size 24px + color #555 + + > div + position absolute + top 0 + right 0 + + > .text-count + line-height 50px + color #657786 + + > .submit + margin 8px + padding 0 16px + line-height 34px + color $theme-color-foreground + background $theme-color + border-radius 4px + + &:disabled + opacity 0.7 + + > .form + max-width 500px + margin 0 auto + + > mk-post-preview + padding 16px + + > .attaches + + > .files + display block + margin 0 + padding 4px + list-style none + + &:after + content "" + display block + clear both + + > .file + display block + float left + margin 4px + padding 0 + cursor move + + &:hover > .remove + display block + + > .img + width 64px + height 64px + background-size cover + background-position center center + + > .remove + display none + position absolute + top -6px + right -6px + width 16px + height 16px + cursor pointer + + > .add + display block + float left + margin 4px + padding 0 + border dashed 2px rgba($theme-color, 0.2) + cursor pointer + + &:hover + border-color rgba($theme-color, 0.3) + + > i + color rgba($theme-color, 0.4) + + > i + display block + width 60px + height 60px + line-height 60px + text-align center + font-size 1.2em + color rgba($theme-color, 0.2) + + > mk-uploader + margin 8px 0 0 0 + padding 8px + + > [ref='file'] + display none + + > [ref='text'] + display block + padding 12px + margin 0 + width 100% + max-width 100% + min-width 100% + min-height 80px + font-size 16px + color #333 + border none + border-bottom solid 1px #ddd + border-radius 0 + + &:disabled + opacity 0.5 + + > [ref='upload'] + > [ref='drive'] + display inline-block + padding 0 + margin 0 + width 48px + height 48px + font-size 20px + color #657786 + background transparent + outline none + border none + border-radius 0 + box-shadow none + +script. + @mixin \api + + @wait = false + @uploadings = [] + @files = [] + + @on \mount ~> + @refs.uploader.on \uploaded (file) ~> + @add-file file + + @refs.uploader.on \change-uploads (uploads) ~> + @trigger \change-uploading-files uploads + + @refs.text.focus! + + @onkeypress = (e) ~> + if (e.char-code == 10 || e.char-code == 13) && e.ctrl-key + @post! + else + return true + + @onpaste = (e) ~> + data = e.clipboard-data + items = data.items + for i from 0 to items.length - 1 + item = items[i] + switch (item.kind) + | \file => + @upload item.get-as-file! + return true + + @select-file = ~> + @refs.file.click! + + @select-file-from-drive = ~> + browser = document.body.append-child document.create-element \mk-drive-selector + browser = riot.mount browser, do + multiple: true + .0 + browser.on \selected (files) ~> + files.for-each @add-file + + @change-file = ~> + files = @refs.file.files + for i from 0 to files.length - 1 + file = files.item i + @upload file + + @upload = (file) ~> + @refs.uploader.upload file + + @add-file = (file) ~> + file._remove = ~> + @files = @files.filter (x) -> x.id != file.id + @trigger \change-files @files + @update! + + @files.push file + @trigger \change-files @files + @update! + + @post = ~> + @wait = true + + files = if @files? and @files.length > 0 + then @files.map (f) -> f.id + else undefined + + @api \posts/create do + text: @refs.text.value + media_ids: files + reply_to_id: if @opts.reply? then @opts.reply.id else undefined + .then (data) ~> + @trigger \post + @unmount! + .catch (err) ~> + console.error err + #@opts.ui.trigger \notification 'Error!' + @wait = false + @update! + + @cancel = ~> + @trigger \cancel + @unmount! diff --git a/src/web/app/mobile/tags/post-preview.tag b/src/web/app/mobile/tags/post-preview.tag new file mode 100644 index 0000000000..e15b2be244 --- /dev/null +++ b/src/web/app/mobile/tags/post-preview.tag @@ -0,0 +1,89 @@ +mk-post-preview + article + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.main + header + a.name(href={ CONFIG.url + '/' + post.user.username }) + | { post.user.name } + span.username + | @{ post.user.username } + a.time(href={ CONFIG.url + '/' + post.user.username + '/' + post.id }) + mk-time(time={ post.created_at }) + div.body + mk-sub-post-content.text(post={ post }) + +style. + display block + margin 0 + padding 0 + font-size 0.9em + background #fff + + > article + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 12px 0 0 + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 60px) + + > header + margin-bottom 4px + white-space nowrap + + > .name + display inline + margin 0 + padding 0 + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #d1d8da + + > .time + position absolute + top 0 + right 0 + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + +script. + @post = @opts.post diff --git a/src/web/app/mobile/tags/search-posts.tag b/src/web/app/mobile/tags/search-posts.tag new file mode 100644 index 0000000000..4b1b12af27 --- /dev/null +++ b/src/web/app/mobile/tags/search-posts.tag @@ -0,0 +1,29 @@ +mk-search-posts + mk-timeline(init={ init }, more={ more }, empty={ '「' + query + '」に関する投稿は見つかりませんでした。' }) + +style. + display block + background #fff + +script. + @mixin \api + + @max = 30 + @offset = 0 + + @query = @opts.query + @with-media = @opts.with-media + + @init = new Promise (res, rej) ~> + @api \posts/search do + query: @query + .then (posts) ~> + res posts + @trigger \loaded + + @more = ~> + @offset += @max + @api \posts/search do + query: @query + max: @max + offset: @offset diff --git a/src/web/app/mobile/tags/search.tag b/src/web/app/mobile/tags/search.tag new file mode 100644 index 0000000000..bf2299cc9b --- /dev/null +++ b/src/web/app/mobile/tags/search.tag @@ -0,0 +1,12 @@ +mk-search + mk-search-posts@posts(query={ query }) + +style. + display block + +script. + @query = @opts.query + + @on \mount ~> + @refs.posts.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/mobile/tags/stream-indicator.tag b/src/web/app/mobile/tags/stream-indicator.tag new file mode 100644 index 0000000000..2eb5889ca6 --- /dev/null +++ b/src/web/app/mobile/tags/stream-indicator.tag @@ -0,0 +1,59 @@ +mk-stream-indicator + p(if={ state == 'initializing' }) + i.fa.fa-spinner.fa-spin + span + | 接続中 + mk-ellipsis + p(if={ state == 'reconnecting' }) + i.fa.fa-spinner.fa-spin + span + | 切断されました 接続中 + mk-ellipsis + p(if={ state == 'connected' }) + i.fa.fa-check + span 接続完了 + +style. + display block + pointer-events none + position fixed + z-index 16384 + bottom 8px + right 8px + margin 0 + padding 6px 12px + font-size 0.9em + color #fff + background rgba(0, 0, 0, 0.8) + + > p + display block + margin 0 + + > i + margin-right 0.25em + +script. + @mixin \stream + + @on \before-mount ~> + @state = @get-stream-state! + + if @state == \connected + @root.style.opacity = 0 + + @stream-state-ev.on \connected ~> + @state = @get-stream-state! + @update! + set-timeout ~> + Velocity @root, { + opacity: 0 + } 200ms \linear + , 1000ms + + @stream-state-ev.on \closed ~> + @state = @get-stream-state! + @update! + Velocity @root, { + opacity: 1 + } 0ms diff --git a/src/web/app/mobile/tags/sub-post-content.tag b/src/web/app/mobile/tags/sub-post-content.tag new file mode 100644 index 0000000000..595f63d794 --- /dev/null +++ b/src/web/app/mobile/tags/sub-post-content.tag @@ -0,0 +1,36 @@ +mk-sub-post-content + div.body + a.reply(if={ post.reply_to_id }): i.fa.fa-reply + span@text + a.quote(if={ post.repost_id }, href={ '/post:' + post.repost_id }) RP: ... + details(if={ post.media }) + summary ({ post.media.length }枚の画像) + mk-images-viewer(images={ post.media }) + +style. + display block + word-wrap break-word + + > .body + > .reply + margin-right 6px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + +script. + @mixin \text + + @post = @opts.post + + @on \mount ~> + if @post.text? + tokens = @analyze @post.text + @refs.text.innerHTML = @compile tokens, false + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e diff --git a/src/web/app/mobile/tags/timeline-post-sub.tag b/src/web/app/mobile/tags/timeline-post-sub.tag new file mode 100644 index 0000000000..920503ebcc --- /dev/null +++ b/src/web/app/mobile/tags/timeline-post-sub.tag @@ -0,0 +1,99 @@ +mk-timeline-post-sub + article + a.avatar-anchor(href={ '/' + post.user.username }) + img.avatar(src={ post.user.avatar_url + '?thumbnail&size=96' }, alt='avatar') + div.main + header + a.name(href={ '/' + post.user.username }) + | { post.user.name } + span.username + | @{ post.user.username } + a.created-at(href={ '/' + post.user.username + '/' + post.id }) + mk-time(time={ post.created_at }) + div.body + mk-sub-post-content.text(post={ post }) + +style. + display block + margin 0 + padding 0 + font-size 0.9em + + > article + padding 16px + + &:after + content "" + display block + clear both + + &:hover + > .main > footer > button + color #888 + + > .avatar-anchor + display block + float left + margin 0 10px 0 0 + + @media (min-width 500px) + margin-right 16px + + > .avatar + display block + width 44px + height 44px + margin 0 + border-radius 8px + vertical-align bottom + + @media (min-width 500px) + width 52px + height 52px + + > .main + float left + width calc(100% - 54px) + + @media (min-width 500px) + width calc(100% - 68px) + + > header + margin-bottom 4px + white-space nowrap + + > .name + display inline + margin 0 + padding 0 + color #607073 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #d1d8da + + > .created-at + position absolute + top 0 + right 0 + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + +script. + @post = @opts.post diff --git a/src/web/app/mobile/tags/timeline-post.tag b/src/web/app/mobile/tags/timeline-post.tag new file mode 100644 index 0000000000..a71fab26f0 --- /dev/null +++ b/src/web/app/mobile/tags/timeline-post.tag @@ -0,0 +1,296 @@ +mk-timeline-post(class={ repost: is-repost }) + + div.reply-to(if={ p.reply_to }) + mk-timeline-post-sub(post={ p.reply_to }) + + div.repost(if={ is-repost }) + p + a.avatar-anchor(href={ CONFIG.url + '/' + post.user.username }): img.avatar(src={ post.user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + i.fa.fa-retweet + a.name(href={ CONFIG.url + '/' + post.user.username }) { post.user.name } + | がRepost + mk-time(time={ post.created_at }) + + article + a.avatar-anchor(href={ CONFIG.url + '/' + p.user.username }) + img.avatar(src={ p.user.avatar_url + '?thumbnail&size=96' }, alt='avatar') + div.main + header + a.name(href={ CONFIG.url + '/' + p.user.username }) + | { p.user.name } + span.username + | @{ p.user.username } + a.created-at(href={ url }) + mk-time(time={ p.created_at }) + div.body + div.text + a.reply(if={ p.reply_to }): i.fa.fa-reply + soan@text + a.quote(if={ p.repost != null }) RP: + div.media(if={ p.media }) + mk-images-viewer(images={ p.media }) + div.repost(if={ p.repost }) + i.fa.fa-quote-right.fa-flip-horizontal + mk-post-preview.repost(post={ p.repost }) + footer + button(onclick={ reply }) + i.fa.fa-reply + p.count(if={ p.replies_count > 0 }) { p.replies_count } + button(onclick={ repost }, title='Repost') + i.fa.fa-retweet + p.count(if={ p.repost_count > 0 }) { p.repost_count } + button(class={ liked: p.is_liked }, onclick={ like }) + i.fa.fa-thumbs-o-up + p.count(if={ p.likes_count > 0 }) { p.likes_count } + +style. + display block + margin 0 + padding 0 + font-size 12px + + @media (min-width 350px) + font-size 14px + + @media (min-width 500px) + font-size 16px + + > .repost + color #9dbb00 + background linear-gradient(to bottom, #edfde2 0%, #fff 100%) + + > p + margin 0 + padding 8px 16px + line-height 28px + + @media (min-width 500px) + padding 16px + + .avatar-anchor + display inline-block + + .avatar + vertical-align bottom + width 28px + height 28px + margin 0 8px 0 0 + border-radius 6px + + i + margin-right 4px + + .name + font-weight bold + + > mk-time + position absolute + top 8px + right 16px + font-size 0.9em + line-height 28px + + @media (min-width 500px) + top 16px + + & + article + padding-top 8px + + > .reply-to + background rgba(0, 0, 0, 0.0125) + + > mk-post-preview + background transparent + + > article + padding 14px 16px 9px 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 10px 0 0 + + @media (min-width 500px) + margin-right 16px + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 6px + vertical-align bottom + + @media (min-width 500px) + width 58px + height 58px + border-radius 8px + + > .main + float left + width calc(100% - 58px) + + @media (min-width 500px) + width calc(100% - 74px) + + > header + white-space nowrap + + @media (min-width 500px) + margin-bottom 2px + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .created-at + position absolute + top 0 + right 0 + font-size 0.9em + color #c0c0c0 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.1em + color #717171 + + mk-url-preview + margin-top 8px + + > .reply + margin-right 8px + color #717171 + + > .quote + margin-left 4px + font-style oblique + color #a0bf46 + + > .media + > img + display block + max-width 100% + + > .repost + margin 8px 0 + + > i:first-child + position absolute + top -8px + left -8px + z-index 1 + color #c0dac6 + font-size 28px + background #fff + + > mk-post-preview + padding 16px + border dashed 1px #c0dac6 + border-radius 8px + + > footer + > button + margin 0 28px 0 0 + padding 8px + background transparent + border none + box-shadow none + font-size 1em + color #ddd + cursor pointer + + &:hover + color #666 + + > .count + display inline + margin 0 0 0 8px + color #999 + + &.liked + color $theme-color + +script. + @mixin \api + @mixin \text + @mixin \get-post-summary + @mixin \open-post-form + + @post = @opts.post + @is-repost = @post.repost? and !@post.text? + @p = if @is-repost then @post.repost else @post + @summary = @get-post-summary @p + @url = CONFIG.url + '/' + @p.user.username + '/' + @p.id + + @on \mount ~> + if @p.text? + tokens = if @p._highlight? + then @analyze @p._highlight + else @analyze @p.text + + @refs.text.innerHTML = if @p._highlight? + then @compile tokens, true, false + else @compile tokens + + @refs.text.children.for-each (e) ~> + if e.tag-name == \MK-URL + riot.mount e + + # URLをプレビュー + tokens + .filter (t) -> t.type == \link + .map (t) ~> + @preview = @refs.text.append-child document.create-element \mk-url-preview + riot.mount @preview, do + url: t.content + + @reply = ~> + @open-post-form do + reply: @p + + @repost = ~> + text = window.prompt '「' + @summary + '」をRepost' + if text? + @api \posts/create do + repost_id: @p.id + text: if text == '' then undefined else text + + @like = ~> + if @p.is_liked + @api \posts/likes/delete do + post_id: @p.id + .then ~> + @p.is_liked = false + @update! + else + @api \posts/likes/create do + post_id: @p.id + .then ~> + @p.is_liked = true + @update! diff --git a/src/web/app/mobile/tags/timeline.tag b/src/web/app/mobile/tags/timeline.tag new file mode 100644 index 0000000000..7114824872 --- /dev/null +++ b/src/web/app/mobile/tags/timeline.tag @@ -0,0 +1,128 @@ +mk-timeline + div.init(if={ init }) + i.fa.fa-spinner.fa-pulse + | 読み込んでいます + div.empty(if={ !init && posts.length == 0 }) + i.fa.fa-comments-o + | { opts.empty || '表示するものがありません' } + virtual(each={ post, i in posts }) + mk-timeline-post(post={ post }) + p.date(if={ i != posts.length - 1 && post._date != posts[i + 1]._date }) + span + i.fa.fa-angle-up + | { post._datetext } + span + i.fa.fa-angle-down + | { posts[i + 1]._datetext } + footer(if={ !init }) + button(if={ can-fetch-more }, onclick={ more }, disabled={ fetching }) + span(if={ !fetching }) もっとみる + span(if={ fetching }) + | 読み込み中 + mk-ellipsis + +style. + display block + background #fff + background-clip content-box + overflow hidden + + > .init + padding 64px 0 + text-align center + color #999 + + > i + margin-right 4px + + > .empty + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > i + display block + margin-bottom 16px + font-size 3em + color #ccc + + > mk-timeline-post + border-bottom solid 1px #eaeaea + + &:last-of-type + border-bottom none + + > .date + display block + margin 0 + line-height 32px + text-align center + font-size 0.9em + color #aaa + background #fdfdfd + border-bottom solid 1px #eaeaea + + span + margin 0 16px + + i + margin-right 8px + + > footer + text-align center + border-top solid 1px #eaeaea + border-bottom-left-radius 4px + border-bottom-right-radius 4px + + > button + margin 0 + padding 16px + width 100% + color $theme-color + + &:disabled + opacity 0.7 + +script. + @posts = [] + @init = true + @fetching = false + @can-fetch-more = true + + @on \mount ~> + @opts.init.then (posts) ~> + @init = false + @set-posts posts + + @on \update ~> + @posts.for-each (post) ~> + date = (new Date post.created_at).get-date! + month = (new Date post.created_at).get-month! + 1 + post._date = date + post._datetext = month + '月 ' + date + '日' + + @more = ~> + if @init or @fetching or @posts.length == 0 then return + @fetching = true + @update! + @opts.more!.then (posts) ~> + @fetching = false + @prepend-posts posts + + @set-posts = (posts) ~> + @posts = posts + @update! + + @prepend-posts = (posts) ~> + posts.for-each (post) ~> + @posts.push post + @update! + + @add-post = (post) ~> + @posts.unshift post + @update! + + @tail = ~> + @posts[@posts.length - 1] diff --git a/src/web/app/mobile/tags/ui-header.tag b/src/web/app/mobile/tags/ui-header.tag new file mode 100644 index 0000000000..7105d065f8 --- /dev/null +++ b/src/web/app/mobile/tags/ui-header.tag @@ -0,0 +1,98 @@ +mk-ui-header + mk-special-message + div.main + div.backdrop + div.content + button.nav#hamburger: i.fa.fa-bars + h1@title Misskey + button.post(onclick={ post }): i.fa.fa-pencil + +style. + $height = 48px + + display block + position fixed + top 0 + z-index 1024 + width 100% + box-shadow 0 1px 0 rgba(#000, 0.075) + + > .main + color rgba(#000, 0.6) + + > .backdrop + position absolute + top 0 + z-index 1023 + width 100% + height $height + -webkit-backdrop-filter blur(12px) + backdrop-filter blur(12px) + background-color rgba(#fff, 0.75) + + > .content + z-index 1024 + + > h1 + display block + margin 0 auto + padding 0 + width 100% + max-width calc(100% - 112px) + text-align center + font-size 1.1em + font-weight normal + line-height $height + white-space nowrap + overflow hidden + text-overflow ellipsis + + > i + margin-right 8px + + > img + display inline-block + vertical-align bottom + width ($height - 16px) + height ($height - 16px) + margin 8px + border-radius 6px + + > .nav + display block + position absolute + top 0 + left 0 + width $height + font-size 1.4em + line-height $height + border-right solid 1px rgba(#000, 0.1) + + > i + transition all 0.2s ease + + > .post + display block + position absolute + top 0 + right 0 + width $height + text-align center + font-size 1.4em + color inherit + line-height $height + border-left solid 1px rgba(#000, 0.1) + +script. + @mixin \ui + @mixin \open-post-form + + @on \mount ~> + @opts.ready! + + @ui.one \title (title) ~> + if @refs.title? + @refs.title.innerHTML = title + + @post = ~> + @open-post-form! diff --git a/src/web/app/mobile/tags/ui-nav.tag b/src/web/app/mobile/tags/ui-nav.tag new file mode 100644 index 0000000000..2c551b30ad --- /dev/null +++ b/src/web/app/mobile/tags/ui-nav.tag @@ -0,0 +1,169 @@ +mk-ui-nav + div.body: div.content + a.me(if={ SIGNIN }, href={ CONFIG.url + '/' + I.username }) + img.avatar(src={ I.avatar_url + '?thumbnail&size=128' }, alt='avatar') + p.name { I.name } + div.links + ul + li.post: a(href='/i/post') + i.icon.fa.fa-pencil-square-o + | 新規投稿 + i.angle.fa.fa-angle-right + ul + li.home: a(href='/') + i.icon.fa.fa-home + | ホーム + i.angle.fa.fa-angle-right + li.mentions: a(href='/i/mentions') + i.icon.fa.fa-at + | あなた宛て + i.angle.fa.fa-angle-right + li.notifications: a(href='/i/notifications') + i.icon.fa.fa-bell-o + | 通知 + i.angle.fa.fa-angle-right + li.messaging: a + i.icon.fa.fa-comments-o + | メッセージ + i.angle.fa.fa-angle-right + ul + li.settings: a(onclick={ search }) + i.icon.fa.fa-search + | 検索 + i.angle.fa.fa-angle-right + ul + li.settings: a(href='/i/drive') + i.icon.fa.fa-cloud + | ドライブ + i.angle.fa.fa-angle-right + li.settings: a(href='/i/upload') + i.icon.fa.fa-upload + | アップロード + i.angle.fa.fa-angle-right + ul + li.settings: a(href='/i/settings') + i.icon.fa.fa-cog + | 設定 + i.angle.fa.fa-angle-right + p.about + a Misskeyについて + +style. + display block + position fixed + top 0 + left 0 + z-index -1 + width 240px + color #fff + background #313538 + visibility hidden + + .body + height 100% + overflow hidden + + .content + min-height 100% + + .me + display block + margin 0 + padding 16px + + .avatar + display inline + max-width 64px + border-radius 32px + vertical-align middle + + .name + display block + margin 0 16px + position absolute + top 0 + left 80px + padding 0 + width calc(100% - 112px) + color #fff + line-height 96px + overflow hidden + text-overflow ellipsis + white-space nowrap + + ul + display block + margin 16px 0 + padding 0 + list-style none + + &:first-child + margin-top 0 + + li + display block + font-size 1em + line-height 1em + border-top solid 1px rgba(0, 0, 0, 0.2) + background #353A3E + background-clip content-box + + &:last-child + border-bottom solid 1px rgba(0, 0, 0, 0.2) + + a + display block + padding 0 20px + line-height 3rem + line-height calc(1rem + 30px) + color #eee + text-decoration none + + > .icon + margin-right 0.5em + + > .angle + position absolute + top 0 + right 0 + padding 0 20px + font-size 1.2em + line-height calc(1rem + 30px) + color #ccc + + > .unread-count + position absolute + height calc(0.9em + 10px) + line-height calc(0.9em + 10px) + top 0 + bottom 0 + right 38px + margin auto 0 + padding 0px 8px + min-width 2em + font-size 0.9em + text-align center + color #fff + background rgba(255, 255, 255, 0.1) + border-radius 1em + + .about + margin 1em 1em 2em 1em + text-align center + font-size 0.6em + opacity 0.3 + + a + color #fff + +script. + @mixin \i + @mixin \page + + @on \mount ~> + @opts.ready! + + @search = ~> + query = window.prompt \検索 + if query? and query != '' + @page '/search:' + query diff --git a/src/web/app/mobile/tags/ui.tag b/src/web/app/mobile/tags/ui.tag new file mode 100644 index 0000000000..81dfac80ca --- /dev/null +++ b/src/web/app/mobile/tags/ui.tag @@ -0,0 +1,50 @@ +mk-ui + div.global@global + mk-ui-header@header(ready={ ready }) + mk-ui-nav@nav(ready={ ready }) + + div.content@main + + + mk-stream-indicator + +style. + display block + + > .global + > .content + background #fff + +script. + @mixin \stream + + @ready-count = 0 + + #@ui.on \notification (text) ~> + # alert text + + @on \mount ~> + @stream.on \notification @on-stream-notification + @ready! + + @on \unmount ~> + @stream.off \notification @on-stream-notification + @slide.slide-close! + + @ready = ~> + @ready-count++ + + if @ready-count == 2 + @slide = SpSlidemenu @refs.main, @refs.nav.root, \#hamburger {direction: \left} + @init-view-position! + + @init-view-position = ~> + top = @refs.header.root.offset-height + @refs.main.style.padding-top = top + \px + @refs.nav.root.style.margin-top = top + \px + @refs.nav.root.query-selector '.body > .content' .style.padding-bottom = top + \px + + @on-stream-notification = (notification) ~> + el = document.body.append-child document.create-element \mk-notify + riot.mount el, do + notification: notification diff --git a/src/web/app/mobile/tags/user-followers.tag b/src/web/app/mobile/tags/user-followers.tag new file mode 100644 index 0000000000..7004398268 --- /dev/null +++ b/src/web/app/mobile/tags/user-followers.tag @@ -0,0 +1,22 @@ +mk-user-followers + mk-users-list@list(fetch={ fetch }, count={ user.followers_count }, you-know-count={ user.followers_you_know_count }, no-users={ 'フォロワーはいないようです。' }) + +style. + display block + +script. + @mixin \api + + @user = @opts.user + + @fetch = (iknow, limit, cursor, cb) ~> + @api \users/followers do + user_id: @user.id + iknow: iknow + limit: limit + cursor: if cursor? then cursor else undefined + .then cb + + @on \mount ~> + @refs.list.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/mobile/tags/user-following.tag b/src/web/app/mobile/tags/user-following.tag new file mode 100644 index 0000000000..c122acd607 --- /dev/null +++ b/src/web/app/mobile/tags/user-following.tag @@ -0,0 +1,22 @@ +mk-user-following + mk-users-list@list(fetch={ fetch }, count={ user.following_count }, you-know-count={ user.following_you_know_count }, no-users={ 'フォロー中のユーザーはいないようです。' }) + +style. + display block + +script. + @mixin \api + + @user = @opts.user + + @fetch = (iknow, limit, cursor, cb) ~> + @api \users/following do + user_id: @user.id + iknow: iknow + limit: limit + cursor: if cursor? then cursor else undefined + .then cb + + @on \mount ~> + @refs.list.on \loaded ~> + @trigger \loaded diff --git a/src/web/app/mobile/tags/user-preview.tag b/src/web/app/mobile/tags/user-preview.tag new file mode 100644 index 0000000000..4f5fbc1520 --- /dev/null +++ b/src/web/app/mobile/tags/user-preview.tag @@ -0,0 +1,103 @@ +mk-user-preview + a.avatar-anchor(href={ CONFIG.url + '/' + user.username }) + img.avatar(src={ user.avatar_url + '?thumbnail&size=64' }, alt='avatar') + div.main + header + div.left + a.name(href={ CONFIG.url + '/' + user.username }) + | { user.name } + span.username + | @{ user.username } + div.body + div.bio { user.bio } + +style. + display block + margin 0 + padding 16px + font-size 12px + + @media (min-width 350px) + font-size 14px + + @media (min-width 500px) + font-size 16px + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + margin 0 10px 0 0 + + @media (min-width 500px) + margin-right 16px + + > .avatar + display block + width 48px + height 48px + margin 0 + border-radius 6px + vertical-align bottom + + @media (min-width 500px) + width 58px + height 58px + border-radius 8px + + > .main + float left + width calc(100% - 58px) + + @media (min-width 500px) + width calc(100% - 74px) + + > header + white-space nowrap + + @media (min-width 500px) + margin-bottom 2px + + &:after + content "" + display block + clear both + + > .left + float left + + > .name + display inline + margin 0 + padding 0 + color #777 + font-size 1em + font-weight 700 + text-align left + text-decoration none + + &:hover + text-decoration underline + + > .username + text-align left + margin 0 0 0 8px + color #ccc + + > .body + + > .bio + cursor default + display block + margin 0 + padding 0 + word-wrap break-word + font-size 1.1em + color #717171 + +script. + @user = @opts.user diff --git a/src/web/app/mobile/tags/user-timeline.tag b/src/web/app/mobile/tags/user-timeline.tag new file mode 100644 index 0000000000..7aa23d2150 --- /dev/null +++ b/src/web/app/mobile/tags/user-timeline.tag @@ -0,0 +1,28 @@ +mk-user-timeline + mk-timeline(init={ init }, more={ more }, empty={ with-media ? 'メディア付き投稿はありません。' : 'このユーザーはまだ投稿していないようです。' }) + +style. + display block + max-width 600px + margin 0 auto + background #fff + +script. + @mixin \api + + @user = @opts.user + @with-media = @opts.with-media + + @init = new Promise (res, rej) ~> + @api \users/posts do + user_id: @user.id + with_media: @with-media + .then (posts) ~> + res posts + @trigger \loaded + + @more = ~> + @api \users/posts do + user_id: @user.id + with_media: @with-media + max_id: @refs.timeline.tail!.id diff --git a/src/web/app/mobile/tags/user.tag b/src/web/app/mobile/tags/user.tag new file mode 100644 index 0000000000..8f4c04cf9c --- /dev/null +++ b/src/web/app/mobile/tags/user.tag @@ -0,0 +1,198 @@ +mk-user + div.user(if={ !fetching }) + header + div.banner(style={ user.banner_url ? 'background-image: url(' + user.banner_url + '?thumbnail&size=1024)' : '' }) + div.body + div.top + a.avatar: img(src={ user.avatar_url + '?thumbnail&size=160' }, alt='avatar') + mk-follow-button(if={ SIGNIN && I.id != user.id }, user={ user }) + + div.title + h1 { user.name } + span.username @{ user.username } + span.followed(if={ user.is_followed }) フォローされています + + div.bio { user.bio } + + div.info + p.location(if={ user.location }) + i.fa.fa-map-marker + | { user.location } + + div.friends + a(href='{ user.username }/following') + b { user.following_count } + i フォロー + a(href='{ user.username }/followers') + b { user.followers_count } + i フォロワー + nav + a(data-is-active={ page == 'posts' }, onclick={ go-posts }) 投稿 + a(data-is-active={ page == 'media' }, onclick={ go-media }) メディア + a(data-is-active={ page == 'graphs' }, onclick={ go-graphs }) グラフ + a(data-is-active={ page == 'likes' }, onclick={ go-likes }) いいね + + div.body + mk-user-timeline(if={ page == 'posts' }, user={ user }) + mk-user-timeline(if={ page == 'media' }, user={ user }, with-media={ true }) + mk-user-graphs(if={ page == 'graphs' }, user={ user }) + +style. + display block + + > .user + > header + > .banner + padding-bottom 33.3% + background-color #f5f5f5 + background-size cover + background-position center + + > .body + padding 8px + margin 0 auto + max-width 600px + + > .top + &:after + content '' + display block + clear both + + > .avatar + display block + float left + width 25% + height 40px + + > img + display block + position absolute + left -2px + bottom -2px + width 100% + border 2px solid #fff + border-radius 6px + + @media (min-width 500px) + left -4px + bottom -4px + border 4px solid #fff + border-radius 12px + + > mk-follow-button + float right + height 40px + + > .title + margin 8px 0 + + > h1 + margin 0 + line-height 22px + font-size 20px + color #222 + + > .username + display inline-block + line-height 20px + font-size 16px + font-weight bold + color #657786 + + > .followed + margin-left 8px + padding 2px 4px + font-size 12px + color #657786 + background #f8f8f8 + border-radius 4px + + > .bio + margin 8px 0 + color #333 + + > .info + margin 8px 0 + + > .location + display inline + margin 0 + color #555 + + > i + margin-right 4px + + > .friends + > a + color #657786 + + &:first-child + margin-right 16px + + > b + margin-right 4px + font-size 16px + color #14171a + + > i + font-size 14px + + > nav + display flex + justify-content center + margin 0 auto + max-width 600px + border-bottom solid 1px #ddd + + > a + display block + flex 1 1 + text-align center + line-height 52px + font-size 14px + text-decoration none + color #657786 + border-bottom solid 2px transparent + + &[data-is-active] + font-weight bold + color $theme-color + border-color $theme-color + + > .body + @media (min-width 500px) + padding 16px 0 0 0 + +script. + @mixin \i + @mixin \api + + @username = @opts.user + @page = if @opts.page? then @opts.page else \posts + @fetching = true + + @on \mount ~> + @api \users/show do + username: @username + .then (user) ~> + @fetching = false + @user = user + @trigger \loaded user + @update! + + @go-posts = ~> + @page = \posts + @update! + + @go-media = ~> + @page = \media + @update! + + @go-graphs = ~> + @page = \graphs + @update! + + @go-likes = ~> + @page = \likes + @update! diff --git a/src/web/app/mobile/tags/users-list.tag b/src/web/app/mobile/tags/users-list.tag new file mode 100644 index 0000000000..3e29a0a4cc --- /dev/null +++ b/src/web/app/mobile/tags/users-list.tag @@ -0,0 +1,125 @@ +mk-users-list + nav + span(data-is-active={ mode == 'all' }, onclick={ set-mode.bind(this, 'all') }) + | すべて + span { opts.count } + // ↓ https://github.com/riot/riot/issues/2080 + span(if={ SIGNIN && opts.you-know-count != '' }, data-is-active={ mode == 'iknow' }, onclick={ set-mode.bind(this, 'iknow') }) + | 知り合い + span { opts.you-know-count } + + div.users(if={ !fetching && users.length != 0 }) + mk-user-preview(each={ users }, user={ this }) + + button.more(if={ !fetching && next != null }, onclick={ more }, disabled={ more-fetching }) + span(if={ !more-fetching }) もっと + span(if={ more-fetching }) + | 読み込み中 + mk-ellipsis + + p.no(if={ !fetching && users.length == 0 }) + | { opts.no-users } + p.fetching(if={ fetching }) + i.fa.fa-spinner.fa-pulse.fa-fw + | 読み込んでいます + mk-ellipsis + +style. + display block + background #fff + + > nav + display flex + justify-content center + margin 0 auto + max-width 600px + border-bottom solid 1px #ddd + + > span + display block + flex 1 1 + text-align center + line-height 52px + font-size 14px + color #657786 + border-bottom solid 2px transparent + + &[data-is-active] + font-weight bold + color $theme-color + border-color $theme-color + + > span + display inline-block + margin-left 4px + padding 2px 5px + font-size 12px + line-height 1 + color #888 + background #eee + border-radius 20px + + > .users + > * + max-width 600px + margin 0 auto + border-bottom solid 1px rgba(0, 0, 0, 0.05) + + > .no + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +script. + @mixin \i + + @limit = 30users + @mode = \all + + @fetching = true + @more-fetching = false + + @on \mount ~> + @fetch ~> + @trigger \loaded + + @fetch = (cb) ~> + @fetching = true + @update! + obj <~ @opts.fetch do + @mode == \iknow + @limit + null + @users = obj.users + @next = obj.next + @fetching = false + @update! + if cb? then cb! + + @more = ~> + @more-fetching = true + @update! + obj <~ @opts.fetch do + @mode == \iknow + @limit + @cursor + @users = @users.concat obj.users + @next = obj.next + @more-fetching = false + @update! + + @set-mode = (mode) ~> + @update do + mode: mode + + @fetch! diff --git a/src/web/app/reset.styl b/src/web/app/reset.styl new file mode 100644 index 0000000000..cf872337cf --- /dev/null +++ b/src/web/app/reset.styl @@ -0,0 +1,27 @@ +* + position relative + box-sizing border-box + background-clip padding-box !important + +input:not([type]) +input[type='text'] +input[type='password'] +input[type='email'] +textarea +button +progress + -webkit-appearance none + -moz-appearance none + appearance none + box-shadow none + +button + margin 0 + padding 0 + background transparent + border none + cursor pointer + color inherit + + * + pointer-events none diff --git a/src/web/apple-touch-icon.ts b/src/web/apple-touch-icon.ts new file mode 100644 index 0000000000..32e1840405 --- /dev/null +++ b/src/web/apple-touch-icon.ts @@ -0,0 +1,8 @@ +import * as path from 'path'; +import * as express from 'express'; + +const app = express.Router(); +app.get('/apple-touch-icon.png', (req, res) => + res.sendFile(path.resolve(__dirname + '/../../resources/apple-touch-icon.png'))); + +module.exports = app; diff --git a/src/web/manifest.ts b/src/web/manifest.ts new file mode 100644 index 0000000000..e87b0ee328 --- /dev/null +++ b/src/web/manifest.ts @@ -0,0 +1,6 @@ +import * as express from 'express'; + +const app = express.Router(); +app.get('/manifest.json', (req, res) => res.sendFile(__dirname + '/../../resources/manifest.json')); + +module.exports = app; diff --git a/src/web/meta.ts b/src/web/meta.ts new file mode 100644 index 0000000000..9729faa9ea --- /dev/null +++ b/src/web/meta.ts @@ -0,0 +1,13 @@ +import * as express from 'express'; +const git = require('git-last-commit'); + +module.exports = async (req: express.Request, res: express.Response) => { + // Get commit info + git.getLastCommit((err, commit) => { + res.send({ + commit: commit + }); + }, { + dst: `${__dirname}/../../misskey` + }); +}; diff --git a/src/web/serve-app.ts b/src/web/serve-app.ts new file mode 100644 index 0000000000..3292cfde37 --- /dev/null +++ b/src/web/serve-app.ts @@ -0,0 +1,9 @@ +import * as path from 'path'; +import * as express from 'express'; +import * as ms from 'ms'; + +export default (name: string) => (req: express.Request, res: express.Response) => { + res.sendFile(path.resolve(`${__dirname}/app/${name}/view.html`), { + maxAge: ms('7 days') + }); +}; diff --git a/src/web/server.ts b/src/web/server.ts new file mode 100644 index 0000000000..d30680f68c --- /dev/null +++ b/src/web/server.ts @@ -0,0 +1,77 @@ +/** + * Web Server + */ + +import * as ms from 'ms'; + +// express modules +import * as express from 'express'; +import * as bodyParser from 'body-parser'; +import * as favicon from 'serve-favicon'; +import * as compression from 'compression'; +const subdomain = require('subdomain'); +import serveApp from './serve-app'; + +/** + * Init app + */ +const app = express(); +app.disable('x-powered-by'); +app.set('view engine', 'pug'); + +app.use(bodyParser.urlencoded({ extended: true })); +app.use(compression()); + +/** + * Initialize requests + */ +app.use((req, res, next) => { + res.header('X-Frame-Options', 'DENY'); + next(); +}); + +/** + * Static resources + */ +app.use(favicon(`${__dirname}/resources/favicon.ico`)); +app.use(require('./manifest')); +app.use(require('./apple-touch-icon')); +app.use('/_/resources', express.static(`${__dirname}/resources`, { + maxAge: ms('7 days') +})); + +/** + * Common API + */ +app.get(/\/api:meta/, require('./meta')); +app.get(/\/api:url/, require('./service/url-preview')); +app.post(/\/api:rss/, require('./service/rss-proxy')); + +/** + * Subdomain + */ +app.use(subdomain({ + base: config.host, + prefix: '@' +})); + +/** + * Routing + */ + +app.use('/@/about/resources', express.static(`${__dirname}/about/resources`, { + maxAge: ms('7 days') +})); + +app.get('/@/about/:page(*)', (req, res) => { + res.render(`${__dirname}/about/pages/${req.params.page}`, { + path: req.params.page, + config: config + }); +}); + +app.get('/@/auth/*', serveApp('auth')); // authorize form +app.get('/@/dev/*', serveApp('dev')); // developer center +app.get('*', serveApp('client')); // client + +module.exports = app; diff --git a/src/web/service/proxy/proxy.ts b/src/web/service/proxy/proxy.ts new file mode 100644 index 0000000000..34c1deafad --- /dev/null +++ b/src/web/service/proxy/proxy.ts @@ -0,0 +1,30 @@ +import * as url from 'url'; +import * as express from 'express'; +import * as request from 'request'; + +module.exports = (req: express.Request, res: express.Response) => { + const _url = req.params.url; + + if (!_url) { + return; + } + + request({ + url: _url + url.parse(req.url, true).search, + encoding: null + }, (err, response, content) => { + if (err) { + console.error(err); + return; + } + + const contentType = response.headers['content-type']; + + if (/^text\//.test(contentType) || contentType === 'application/javascript') { + content = content.toString().replace(/http:\/\//g, `${config.secondary_scheme}://proxy.${config.secondary_host}/http://`); + } + + res.header('Content-Type', contentType); + res.send(content); + }); +}; diff --git a/src/web/service/proxy/server.ts b/src/web/service/proxy/server.ts new file mode 100644 index 0000000000..5b1b8d106c --- /dev/null +++ b/src/web/service/proxy/server.ts @@ -0,0 +1,17 @@ +/** + * Forward Proxy Service + */ + +import * as express from 'express'; +import * as cors from 'cors'; + +/** + * Init app + */ +const app = express(); +app.disable('x-powered-by'); +app.use(cors()); + +app.get('/:url(*)', require('./proxy')); + +module.exports = app; diff --git a/src/web/service/rss-proxy.ts b/src/web/service/rss-proxy.ts new file mode 100644 index 0000000000..8cc3711e70 --- /dev/null +++ b/src/web/service/rss-proxy.ts @@ -0,0 +1,16 @@ +import * as express from 'express'; +import * as request from 'request'; +const xml2json = require('xml2json'); + +module.exports = (req: express.Request, res: express.Response) => { + const url: string = req.body.url; + + request(url, (err, response, xml) => { + if (err) { + console.error(err); + return; + } + + res.send(xml2json.toJson(xml)); + }); +}; diff --git a/src/web/service/url-preview.ts b/src/web/service/url-preview.ts new file mode 100644 index 0000000000..d1a345ef17 --- /dev/null +++ b/src/web/service/url-preview.ts @@ -0,0 +1,13 @@ +import * as express from 'express'; +import summaly from 'summaly'; + +module.exports = async (req: express.Request, res: express.Response) => { + const summary = await summaly(req.query.url); + summary.icon = wrap(summary.icon); + summary.thumbnail = wrap(summary.thumbnail); + res.send(summary); +}; + +function wrap(url: string): string { + return `${config.proxy_url}/${url}`; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000000..9fa05af4e7 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "noEmitOnError": false, + "noImplicitAny": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "experimentalDecorators": true, + "declaration": false, + "sourceMap": false, + "target": "es6", + "module": "commonjs", + "removeComments": false, + "noLib": false, + "outDir": "built", + "rootDir": "src" + }, + "compileOnSave": false, + "include": [ + "./node_modules/typescript/lib/lib.es6.d.ts", + "./src/**/*.ts", + "!./src/web/**/*.ts" + ] +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000000..c1574ec1ce --- /dev/null +++ b/tslint.json @@ -0,0 +1,116 @@ +{ + "rules": { + // TypeScript Specific + "member-access": false, + "member-ordering": [true, + "static-before-instance", + "variables-before-functions" + ], + "no-any": false, + "no-inferrable-types": false, + "no-internal-module": false, + "no-namespace": false, + "no-reference": true, + "no-var-requires": false, + "only-arrow-functions": false, + "typedef": [true, + "call-signature", + "property-declaration" + ], + "typedef-whitespace": [true, { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + }], + + // Functionality + "ban": false, + "curly": false, + "forin": true, + "label-position": true, + "no-arg": true, + "no-bitwise": true, + "no-conditional-assignment": true, + "no-console": [true, + "debug", + "info", + "time", + "timeEnd", + "trace" + ], + "no-construct": true, + "no-debugger": true, + "no-duplicate-key": true, + "no-duplicate-variable": true, + "no-empty": true, + "no-eval": false, + "no-for-in-array": false, + "no-invalid-this": [true, "check-function-in-method"], + "no-null-keyword": false, + "no-shadowed-variable": false, + "no-string-literal": false, + "no-switch-case-fall-through": true, + "no-unreachable": true, + "no-unsafe-finally": true, + "no-unused-expression": true, + "no-unused-new": true, + "no-unused-variable": true, + "no-use-before-declare": true, + "no-var-keyword": true, + "radix": true, + "restrict-plus-operands": false, + "switch-default": false, + "triple-equals": [false, "allow-null-check", "allow-undefined-check"], + "use-isnan": true, + + // Maintainability + "eofline": true, + "indent": [true, "tabs"], + "linebreak-style": [true, "CRLF"], + "max-file-line-count": false, + "max-line-length": [true, 140], + "no-default-export": false, + "no-mergeable-namespace": true, + "no-require-imports": false, + "no-trailing-whitespace": true, + "object-literal-sort-keys": false, + "trailing-comma": true, + + // Style + "align": [true, + "parameters", + "statements" + ], + "arrow-parens": false, + "class-name": true, + "comment-format": false, + "interface-name": false, + "jsdoc-format": true, + "new-parens": true, + "no-angle-bracket-type-assertion": true, + "no-consecutive-blank-lines": true, + "no-constructor-vars": true, + "object-literal-key-quotes": false, + "one-line": [true, + "check-catch", + "check-finally", + "check-else", + "check-open-brace", + "check-whitespace" + ], + "one-variable-per-declaration": true, + "ordered-imports": false, + "quotemark": [true, "single", "avoid-escape"], + "semicolon": true, + "variable-name": false, + "whitespace": [true, + "check-branch", + "check-decl", + "check-operator", + "check-separator", + "check-type" + ] + } +} diff --git a/update.sh b/update.sh new file mode 100644 index 0000000000..c48d20de68 --- /dev/null +++ b/update.sh @@ -0,0 +1,4 @@ +#!/bin/sh +git pull +npm install +npm run build