diff options
| author | syuilo <syuilotan@yahoo.co.jp> | 2018-03-29 20:32:18 +0900 |
|---|---|---|
| committer | syuilo <syuilotan@yahoo.co.jp> | 2018-03-29 20:32:18 +0900 |
| commit | cf33e483f7e6f40e8cbbbc0118a7df70bdaf651f (patch) | |
| tree | 318279530d3392ee40d91968477fc0e78d5cf0f7 /src/client/app/desktop | |
| parent | Update .travis.yml (diff) | |
| download | sharkey-cf33e483f7e6f40e8cbbbc0118a7df70bdaf651f.tar.gz sharkey-cf33e483f7e6f40e8cbbbc0118a7df70bdaf651f.tar.bz2 sharkey-cf33e483f7e6f40e8cbbbc0118a7df70bdaf651f.zip | |
整理した
Diffstat (limited to 'src/client/app/desktop')
124 files changed, 14813 insertions, 0 deletions
diff --git a/src/client/app/desktop/api/choose-drive-file.ts b/src/client/app/desktop/api/choose-drive-file.ts new file mode 100644 index 0000000000..fbda600e6e --- /dev/null +++ b/src/client/app/desktop/api/choose-drive-file.ts @@ -0,0 +1,30 @@ +import { url } from '../../config'; +import MkChooseFileFromDriveWindow from '../views/components/choose-file-from-drive-window.vue'; + +export default function(opts) { + return new Promise((res, rej) => { + const o = opts || {}; + + if (document.body.clientWidth > 800) { + const w = new MkChooseFileFromDriveWindow({ + propsData: { + title: o.title, + multiple: o.multiple, + initFolder: o.currentFolder + } + }).$mount(); + w.$once('selected', file => { + res(file); + }); + document.body.appendChild(w.$el); + } else { + window['cb'] = file => { + res(file); + }; + + window.open(url + '/selectdrive', + 'choose_drive_window', + 'height=500, width=800'); + } + }); +} diff --git a/src/client/app/desktop/api/choose-drive-folder.ts b/src/client/app/desktop/api/choose-drive-folder.ts new file mode 100644 index 0000000000..9b33a20d9a --- /dev/null +++ b/src/client/app/desktop/api/choose-drive-folder.ts @@ -0,0 +1,17 @@ +import MkChooseFolderFromDriveWindow from '../views/components/choose-folder-from-drive-window.vue'; + +export default function(opts) { + return new Promise((res, rej) => { + const o = opts || {}; + const w = new MkChooseFolderFromDriveWindow({ + propsData: { + title: o.title, + initFolder: o.currentFolder + } + }).$mount(); + w.$once('selected', folder => { + res(folder); + }); + document.body.appendChild(w.$el); + }); +} diff --git a/src/client/app/desktop/api/contextmenu.ts b/src/client/app/desktop/api/contextmenu.ts new file mode 100644 index 0000000000..b70d7122d3 --- /dev/null +++ b/src/client/app/desktop/api/contextmenu.ts @@ -0,0 +1,16 @@ +import Ctx from '../views/components/context-menu.vue'; + +export default function(e, menu, opts?) { + const o = opts || {}; + const vm = new Ctx({ + propsData: { + menu, + x: e.pageX - window.pageXOffset, + y: e.pageY - window.pageYOffset, + } + }).$mount(); + vm.$once('closed', () => { + if (o.closed) o.closed(); + }); + document.body.appendChild(vm.$el); +} diff --git a/src/client/app/desktop/api/dialog.ts b/src/client/app/desktop/api/dialog.ts new file mode 100644 index 0000000000..07935485b0 --- /dev/null +++ b/src/client/app/desktop/api/dialog.ts @@ -0,0 +1,19 @@ +import Dialog from '../views/components/dialog.vue'; + +export default function(opts) { + return new Promise<string>((res, rej) => { + const o = opts || {}; + const d = new Dialog({ + propsData: { + title: o.title, + text: o.text, + modal: o.modal, + buttons: o.actions + } + }).$mount(); + d.$once('clicked', id => { + res(id); + }); + document.body.appendChild(d.$el); + }); +} diff --git a/src/client/app/desktop/api/input.ts b/src/client/app/desktop/api/input.ts new file mode 100644 index 0000000000..ce26a8112f --- /dev/null +++ b/src/client/app/desktop/api/input.ts @@ -0,0 +1,20 @@ +import InputDialog from '../views/components/input-dialog.vue'; + +export default function(opts) { + return new Promise<string>((res, rej) => { + const o = opts || {}; + const d = new InputDialog({ + propsData: { + title: o.title, + placeholder: o.placeholder, + default: o.default, + type: o.type || 'text', + allowEmpty: o.allowEmpty + } + }).$mount(); + d.$once('done', text => { + res(text); + }); + document.body.appendChild(d.$el); + }); +} diff --git a/src/client/app/desktop/api/notify.ts b/src/client/app/desktop/api/notify.ts new file mode 100644 index 0000000000..1f89f40ce6 --- /dev/null +++ b/src/client/app/desktop/api/notify.ts @@ -0,0 +1,10 @@ +import Notification from '../views/components/ui-notification.vue'; + +export default function(message) { + const vm = new Notification({ + propsData: { + message + } + }).$mount(); + document.body.appendChild(vm.$el); +} diff --git a/src/client/app/desktop/api/post.ts b/src/client/app/desktop/api/post.ts new file mode 100644 index 0000000000..cf49615df3 --- /dev/null +++ b/src/client/app/desktop/api/post.ts @@ -0,0 +1,21 @@ +import PostFormWindow from '../views/components/post-form-window.vue'; +import RepostFormWindow from '../views/components/repost-form-window.vue'; + +export default function(opts) { + const o = opts || {}; + if (o.repost) { + const vm = new RepostFormWindow({ + propsData: { + repost: o.repost + } + }).$mount(); + document.body.appendChild(vm.$el); + } else { + const vm = new PostFormWindow({ + propsData: { + reply: o.reply + } + }).$mount(); + document.body.appendChild(vm.$el); + } +} diff --git a/src/client/app/desktop/api/update-avatar.ts b/src/client/app/desktop/api/update-avatar.ts new file mode 100644 index 0000000000..36a2ffe914 --- /dev/null +++ b/src/client/app/desktop/api/update-avatar.ts @@ -0,0 +1,98 @@ +import OS from '../../common/mios'; +import { apiUrl } from '../../config'; +import CropWindow from '../views/components/crop-window.vue'; +import ProgressDialog from '../views/components/progress-dialog.vue'; + +export default (os: OS) => (cb, file = null) => { + const fileSelected = file => { + + const w = new CropWindow({ + propsData: { + image: file, + title: 'アバターとして表示する部分を選択', + aspectRatio: 1 / 1 + } + }).$mount(); + + w.$once('cropped', blob => { + const data = new FormData(); + data.append('i', os.i.account.token); + data.append('file', blob, file.name + '.cropped.png'); + + os.api('drive/folders/find', { + name: 'アイコン' + }).then(iconFolder => { + if (iconFolder.length === 0) { + os.api('drive/folders/create', { + name: 'アイコン' + }).then(iconFolder => { + upload(data, iconFolder); + }); + } else { + upload(data, iconFolder[0]); + } + }); + }); + + w.$once('skipped', () => { + set(file); + }); + + document.body.appendChild(w.$el); + }; + + const upload = (data, folder) => { + const dialog = new ProgressDialog({ + propsData: { + title: '新しいアバターをアップロードしています' + } + }).$mount(); + document.body.appendChild(dialog.$el); + + if (folder) data.append('folderId', folder.id); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = e => { + const file = JSON.parse((e.target as any).response); + (dialog as any).close(); + set(file); + }; + + xhr.upload.onprogress = e => { + if (e.lengthComputable) (dialog as any).update(e.loaded, e.total); + }; + + xhr.send(data); + }; + + const set = file => { + os.api('i/update', { + avatarId: file.id + }).then(i => { + os.i.avatarId = i.avatarId; + os.i.avatarUrl = i.avatarUrl; + + os.apis.dialog({ + title: '%fa:info-circle%アバターを更新しました', + text: '新しいアバターが反映されるまで時間がかかる場合があります。', + actions: [{ + text: 'わかった' + }] + }); + + if (cb) cb(i); + }); + }; + + if (file) { + fileSelected(file); + } else { + os.apis.chooseDriveFile({ + multiple: false, + title: '%fa:image%アバターにする画像を選択' + }).then(file => { + fileSelected(file); + }); + } +}; diff --git a/src/client/app/desktop/api/update-banner.ts b/src/client/app/desktop/api/update-banner.ts new file mode 100644 index 0000000000..e66dbf016b --- /dev/null +++ b/src/client/app/desktop/api/update-banner.ts @@ -0,0 +1,98 @@ +import OS from '../../common/mios'; +import { apiUrl } from '../../config'; +import CropWindow from '../views/components/crop-window.vue'; +import ProgressDialog from '../views/components/progress-dialog.vue'; + +export default (os: OS) => (cb, file = null) => { + const fileSelected = file => { + + const w = new CropWindow({ + propsData: { + image: file, + title: 'バナーとして表示する部分を選択', + aspectRatio: 16 / 9 + } + }).$mount(); + + w.$once('cropped', blob => { + const data = new FormData(); + data.append('i', os.i.account.token); + data.append('file', blob, file.name + '.cropped.png'); + + os.api('drive/folders/find', { + name: 'バナー' + }).then(bannerFolder => { + if (bannerFolder.length === 0) { + os.api('drive/folders/create', { + name: 'バナー' + }).then(iconFolder => { + upload(data, iconFolder); + }); + } else { + upload(data, bannerFolder[0]); + } + }); + }); + + w.$once('skipped', () => { + set(file); + }); + + document.body.appendChild(w.$el); + }; + + const upload = (data, folder) => { + const dialog = new ProgressDialog({ + propsData: { + title: '新しいバナーをアップロードしています' + } + }).$mount(); + document.body.appendChild(dialog.$el); + + if (folder) data.append('folderId', folder.id); + + const xhr = new XMLHttpRequest(); + xhr.open('POST', apiUrl + '/drive/files/create', true); + xhr.onload = e => { + const file = JSON.parse((e.target as any).response); + (dialog as any).close(); + set(file); + }; + + xhr.upload.onprogress = e => { + if (e.lengthComputable) (dialog as any).update(e.loaded, e.total); + }; + + xhr.send(data); + }; + + const set = file => { + os.api('i/update', { + bannerId: file.id + }).then(i => { + os.i.bannerId = i.bannerId; + os.i.bannerUrl = i.bannerUrl; + + os.apis.dialog({ + title: '%fa:info-circle%バナーを更新しました', + text: '新しいバナーが反映されるまで時間がかかる場合があります。', + actions: [{ + text: 'わかった' + }] + }); + + if (cb) cb(i); + }); + }; + + if (file) { + fileSelected(file); + } else { + os.apis.chooseDriveFile({ + multiple: false, + title: '%fa:image%バナーにする画像を選択' + }).then(file => { + fileSelected(file); + }); + } +}; diff --git a/src/client/app/desktop/assets/grid.svg b/src/client/app/desktop/assets/grid.svg new file mode 100644 index 0000000000..d1d72cd8ce --- /dev/null +++ b/src/client/app/desktop/assets/grid.svg @@ -0,0 +1,150 @@ +<?xml version="1.0" encoding="UTF-8" standalone="no"?> +<!-- Created with Inkscape (http://www.inkscape.org/) --> + +<svg + xmlns:dc="http://purl.org/dc/elements/1.1/" + xmlns:cc="http://creativecommons.org/ns#" + xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:svg="http://www.w3.org/2000/svg" + xmlns="http://www.w3.org/2000/svg" + xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd" + xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" + width="32" + height="32" + viewBox="0 0 8.4666665 8.4666669" + version="1.1" + id="svg8" + inkscape:version="0.92.1 r15371" + sodipodi:docname="grid.svg"> + <defs + id="defs2" /> + <sodipodi:namedview + id="base" + pagecolor="#ffffff" + bordercolor="#666666" + borderopacity="1.0" + inkscape:pageopacity="0.0" + inkscape:pageshadow="2" + inkscape:zoom="22.4" + inkscape:cx="14.687499" + inkscape:cy="14.558219" + inkscape:document-units="px" + inkscape:current-layer="layer1" + showgrid="true" + units="px" + showguides="true" + inkscape:window-width="1920" + inkscape:window-height="1017" + inkscape:window-x="-8" + inkscape:window-y="1072" + inkscape:window-maximized="1"> + <inkscape:grid + type="xygrid" + id="grid3680" + empspacing="8" + empcolor="#ff3fff" + empopacity="0.41176471" /> + </sodipodi:namedview> + <metadata + id="metadata5"> + <rdf:RDF> + <cc:Work + rdf:about=""> + <dc:format>image/svg+xml</dc:format> + <dc:type + rdf:resource="http://purl.org/dc/dcmitype/StillImage" /> + <dc:title></dc:title> + </cc:Work> + </rdf:RDF> + </metadata> + <g + inkscape:label="レイヤー 1" + inkscape:groupmode="layer" + id="layer1" + transform="translate(0,-288.53331)"> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 0,296.99998 v -8.46667 h 8.4666666 l 10e-8,0.26458 H 0.26458333 l 0,8.20209 z" + id="path3684" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333334,292.23748 h 0.2645833 v 0.52916 h 0.5291667 l 0,0.26459 H 4.4979167 v 0.52917 H 4.2333334 v -0.52917 H 3.7041667 l 0,-0.26459 h 0.5291667 z" + id="path4491" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccccccccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 3.4395833,292.76664 0,0.26459 H 2.38125 l 0,-0.26459 z" + id="path4493" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 6.3499999,292.76664 10e-8,0.26459 H 5.2916667 l -1e-7,-0.26459 z" + id="path4493-2" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 7.6729167,292.76664 v 0.26459 H 6.6145834 v -0.26459 z" + id="path4493-6" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 2.1166666,292.76664 1e-7,0.26459 H 1.0583334 l -1e-7,-0.26459 z" + id="path4493-1" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333333,291.97289 0.2645834,0 v -1.05833 l -0.2645834,0 z" + id="path4522" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333334,290.64997 0.2645833,1e-5 v -1.05833 l -0.2645833,-1e-5 z" + id="path4522-7" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333334,294.88331 h 0.2645833 v -1.05833 H 4.2333334 Z" + id="path4522-5" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333333,296.20622 h 0.2645833 v -1.05833 H 4.2333333 Z" + id="path4522-74" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333334,289.32706 0.2645834,10e-6 -10e-8,-0.52918 -0.2645834,-10e-6 z" + id="path4522-7-4" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 4.2333332,296.99998 h 0.2645835 l 0,-0.52917 H 4.2333333 Z" + id="path4522-7-4-4" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 0.79375,292.76664 -3e-8,0.26459 -0.52916667,0 3e-8,-0.26459 z" + id="path4493-1-7" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + <path + style="fill:#000000;fill-opacity:0.05;stroke:none;stroke-width:0.26458332px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1" + d="m 8.4666667,292.76664 v 0.26459 l -0.5291667,0 v -0.26459 z" + id="path4493-1-7-2" + inkscape:connector-curvature="0" + sodipodi:nodetypes="ccccc" /> + </g> +</svg> diff --git a/src/client/app/desktop/assets/header-logo-white.svg b/src/client/app/desktop/assets/header-logo-white.svg new file mode 100644 index 0000000000..8082edb30d --- /dev/null +++ b/src/client/app/desktop/assets/header-logo-white.svg @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="レイヤー_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="256px" height="256px" viewBox="0 0 256 256" enable-background="new 0 0 256 256" xml:space="preserve">
+<circle fill="#FFFFFF" cx="128" cy="153.6" r="19.201"/>
+<circle fill="#FFFFFF" cx="51.2" cy="153.6" r="19.2"/>
+<circle fill="#FFFFFF" cx="204.8" cy="153.6" r="19.2"/>
+<polyline fill="none" stroke="#FFFFFF" stroke-width="16" stroke-linejoin="round" stroke-miterlimit="10" points="51.2,153.6
+ 89.601,102.4 128,153.6 166.4,102.4 204.799,153.6 "/>
+<circle fill="#FFFFFF" cx="89.6" cy="102.4" r="19.2"/>
+<circle fill="#FFFFFF" cx="166.4" cy="102.4" r="19.199"/>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>
diff --git a/src/client/app/desktop/assets/header-logo.svg b/src/client/app/desktop/assets/header-logo.svg new file mode 100644 index 0000000000..3a2207954a --- /dev/null +++ b/src/client/app/desktop/assets/header-logo.svg @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 16.0.3, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg version="1.1" id="レイヤー_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px"
+ y="0px" width="256px" height="256px" viewBox="0 0 256 256" enable-background="new 0 0 256 256" xml:space="preserve">
+<circle cx="128" cy="153.6" r="19.201"/>
+<circle cx="51.2" cy="153.6" r="19.2"/>
+<circle cx="204.8" cy="153.6" r="19.2"/>
+<polyline fill="none" stroke="#000000" stroke-width="16" stroke-linejoin="round" stroke-miterlimit="10" points="51.2,153.6
+ 89.601,102.4 128,153.6 166.4,102.4 204.799,153.6 "/>
+<circle cx="89.6" cy="102.4" r="19.2"/>
+<circle cx="166.4" cy="102.4" r="19.199"/>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+<g>
+</g>
+</svg>
diff --git a/src/client/app/desktop/assets/index.jpg b/src/client/app/desktop/assets/index.jpg Binary files differnew file mode 100644 index 0000000000..10c412efe2 --- /dev/null +++ b/src/client/app/desktop/assets/index.jpg diff --git a/src/client/app/desktop/assets/remove.png b/src/client/app/desktop/assets/remove.png Binary files differnew file mode 100644 index 0000000000..8b1f4c06c9 --- /dev/null +++ b/src/client/app/desktop/assets/remove.png diff --git a/src/client/app/desktop/script.ts b/src/client/app/desktop/script.ts new file mode 100644 index 0000000000..b95e168544 --- /dev/null +++ b/src/client/app/desktop/script.ts @@ -0,0 +1,167 @@ +/** + * Desktop Client + */ + +import VueRouter from 'vue-router'; + +// Style +import './style.styl'; +import '../../element.scss'; + +import init from '../init'; +import fuckAdBlock from '../common/scripts/fuck-ad-block'; +import { HomeStreamManager } from '../common/scripts/streaming/home'; +import composeNotification from '../common/scripts/compose-notification'; + +import chooseDriveFolder from './api/choose-drive-folder'; +import chooseDriveFile from './api/choose-drive-file'; +import dialog from './api/dialog'; +import input from './api/input'; +import post from './api/post'; +import notify from './api/notify'; +import updateAvatar from './api/update-avatar'; +import updateBanner from './api/update-banner'; + +import MkIndex from './views/pages/index.vue'; +import MkUser from './views/pages/user/user.vue'; +import MkSelectDrive from './views/pages/selectdrive.vue'; +import MkDrive from './views/pages/drive.vue'; +import MkHomeCustomize from './views/pages/home-customize.vue'; +import MkMessagingRoom from './views/pages/messaging-room.vue'; +import MkPost from './views/pages/post.vue'; +import MkSearch from './views/pages/search.vue'; +import MkOthello from './views/pages/othello.vue'; + +/** + * init + */ +init(async (launch) => { + // Register directives + require('./views/directives'); + + // Register components + require('./views/components'); + require('./views/widgets'); + + // Init router + const router = new VueRouter({ + mode: 'history', + routes: [ + { path: '/', name: 'index', component: MkIndex }, + { path: '/i/customize-home', component: MkHomeCustomize }, + { path: '/i/messaging/:user', component: MkMessagingRoom }, + { path: '/i/drive', component: MkDrive }, + { path: '/i/drive/folder/:folder', component: MkDrive }, + { path: '/selectdrive', component: MkSelectDrive }, + { path: '/search', component: MkSearch }, + { path: '/othello', component: MkOthello }, + { path: '/othello/:game', component: MkOthello }, + { path: '/@:user', component: MkUser }, + { path: '/@:user/:post', component: MkPost } + ] + }); + + // Launch the app + const [, os] = launch(router, os => ({ + chooseDriveFolder, + chooseDriveFile, + dialog, + input, + post, + notify, + updateAvatar: updateAvatar(os), + updateBanner: updateBanner(os) + })); + + /** + * Fuck AD Block + */ + fuckAdBlock(os); + + /** + * Init Notification + */ + if ('Notification' in window) { + // 許可を得ていなかったらリクエスト + if ((Notification as any).permission == 'default') { + await Notification.requestPermission(); + } + + if ((Notification as any).permission == 'granted') { + registerNotifications(os.stream); + } + } +}, true); + +function registerNotifications(stream: HomeStreamManager) { + if (stream == null) return; + + if (stream.hasConnection) { + attach(stream.borrow()); + } + + stream.on('connected', connection => { + attach(connection); + }); + + function attach(connection) { + connection.on('drive_file_created', file => { + const _n = composeNotification('drive_file_created', file); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + setTimeout(n.close.bind(n), 5000); + }); + + connection.on('mention', post => { + const _n = composeNotification('mention', post); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + setTimeout(n.close.bind(n), 6000); + }); + + connection.on('reply', post => { + const _n = composeNotification('reply', post); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + setTimeout(n.close.bind(n), 6000); + }); + + connection.on('quote', post => { + const _n = composeNotification('quote', post); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + setTimeout(n.close.bind(n), 6000); + }); + + connection.on('unread_messaging_message', message => { + const _n = composeNotification('unread_messaging_message', message); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + n.onclick = () => { + n.close(); + /*(riot as any).mount(document.body.appendChild(document.createElement('mk-messaging-room-window')), { + user: message.user + });*/ + }; + setTimeout(n.close.bind(n), 7000); + }); + + connection.on('othello_invited', matching => { + const _n = composeNotification('othello_invited', matching); + const n = new Notification(_n.title, { + body: _n.body, + icon: _n.icon + }); + }); + } +} diff --git a/src/client/app/desktop/style.styl b/src/client/app/desktop/style.styl new file mode 100644 index 0000000000..49f71fbde7 --- /dev/null +++ b/src/client/app/desktop/style.styl @@ -0,0 +1,50 @@ +@import "../app" +@import "../reset" + +@import "./ui" + +*::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 + height 100% + background #f7f7f7 + +body + display flex + flex-direction column + min-height 100% diff --git a/src/client/app/desktop/ui.styl b/src/client/app/desktop/ui.styl new file mode 100644 index 0000000000..5a8d1718e2 --- /dev/null +++ b/src/client/app/desktop/ui.styl @@ -0,0 +1,125 @@ +@import "../../const" + +button + font-family sans-serif + + * + pointer-events none + +button.ui +.button.ui + display inline-block + cursor pointer + padding 0 14px + margin 0 + min-width 100px + line-height 38px + font-size 14px + color #888 + text-decoration none + background linear-gradient(to bottom, #ffffff 0%, #f5f5f5 100%) + border solid 1px #e2e2e2 + border-radius 4px + outline none + + &.block + display block + + &: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 + + &:hover + background linear-gradient(to bottom, #f9f9f9 0%, #ececec 100%) + border-color #dcdcdc + + &:active + background #ececec + border-color #dcdcdc + + &.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 + +input:not([type]).ui +input[type='text'].ui +input[type='password'].ui +input[type='email'].ui +input[type='date'].ui +input[type='number'].ui +textarea.ui + display block + padding 10px + width 100% + height 40px + font-family sans-serif + font-size 16px + color #55595c + border solid 1px #dadada + border-radius 4px + + &:hover + border-color #b0b0b0 + + &:focus + border-color $theme-color + +textarea.ui + min-width 100% + max-width 100% + min-height 64px + +.ui.info + display block + margin 1em 0 + padding 0 1em + font-size 90% + color rgba(#000, 0.87) + background #f8f8f9 + border solid 1px rgba(34, 36, 38, 0.22) + border-radius 4px + + > p + opacity 0.8 + + > [data-fa]:first-child + margin-right 0.25em + + &.warn + color #573a08 + background #FFFAF3 + border-color #C9BA9B + +.ui.from.group + display block + margin 16px 0 + + > p:first-child + margin 0 0 6px 0 + font-size 90% + font-weight bold + color rgba(#373a3c, 0.9) diff --git a/src/client/app/desktop/views/components/activity.calendar.vue b/src/client/app/desktop/views/components/activity.calendar.vue new file mode 100644 index 0000000000..72233e9aca --- /dev/null +++ b/src/client/app/desktop/views/components/activity.calendar.vue @@ -0,0 +1,66 @@ +<template> +<svg viewBox="0 0 21 7" preserveAspectRatio="none"> + <rect v-for="record in data" class="day" + width="1" height="1" + :x="record.x" :y="record.date.weekday" + rx="1" ry="1" + fill="transparent"> + <title>{{ record.date.year }}/{{ record.date.month }}/{{ record.date.day }}</title> + </rect> + <rect v-for="record in data" class="day" + :width="record.v" :height="record.v" + :x="record.x + ((1 - record.v) / 2)" :y="record.date.weekday + ((1 - record.v) / 2)" + rx="1" ry="1" + :fill="record.color" + style="pointer-events: none;"/> + <rect class="today" + width="1" height="1" + :x="data[data.length - 1].x" :y="data[data.length - 1].date.weekday" + rx="1" ry="1" + fill="none" + stroke-width="0.1" + stroke="#f73520"/> +</svg> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['data'], + created() { + this.data.forEach(d => d.total = d.posts + d.replies + d.reposts); + const peak = Math.max.apply(null, this.data.map(d => d.total)); + + let x = 0; + this.data.reverse().forEach(d => { + d.x = x; + d.date.weekday = (new Date(d.date.year, d.date.month - 1, d.date.day)).getDay(); + + d.v = peak == 0 ? 0 : d.total / (peak / 2); + if (d.v > 1) d.v = 1; + const ch = d.date.weekday == 0 || d.date.weekday == 6 ? 275 : 170; + const cs = d.v * 100; + const cl = 15 + ((1 - d.v) * 80); + d.color = `hsl(${ch}, ${cs}%, ${cl}%)`; + + if (d.date.weekday == 6) x++; + }); + } +}); +</script> + +<style lang="stylus" scoped> +svg + display block + padding 10px + width 100% + + > rect + transform-origin center + + &.day + &:hover + fill rgba(0, 0, 0, 0.05) + +</style> diff --git a/src/client/app/desktop/views/components/activity.chart.vue b/src/client/app/desktop/views/components/activity.chart.vue new file mode 100644 index 0000000000..5057786ed4 --- /dev/null +++ b/src/client/app/desktop/views/components/activity.chart.vue @@ -0,0 +1,103 @@ +<template> +<svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" preserveAspectRatio="none" @mousedown.prevent="onMousedown"> + <title>Black ... Total<br/>Blue ... Posts<br/>Red ... Replies<br/>Green ... Reposts</title> + <polyline + :points="pointsPost" + fill="none" + stroke-width="1" + stroke="#41ddde"/> + <polyline + :points="pointsReply" + fill="none" + stroke-width="1" + stroke="#f7796c"/> + <polyline + :points="pointsRepost" + fill="none" + stroke-width="1" + stroke="#a1de41"/> + <polyline + :points="pointsTotal" + fill="none" + stroke-width="1" + stroke="#555" + stroke-dasharray="2 2"/> +</svg> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +function dragListen(fn) { + window.addEventListener('mousemove', fn); + window.addEventListener('mouseleave', dragClear.bind(null, fn)); + window.addEventListener('mouseup', dragClear.bind(null, fn)); +} + +function dragClear(fn) { + window.removeEventListener('mousemove', fn); + window.removeEventListener('mouseleave', dragClear); + window.removeEventListener('mouseup', dragClear); +} + +export default Vue.extend({ + props: ['data'], + data() { + return { + viewBoxX: 140, + viewBoxY: 60, + zoom: 1, + pos: 0, + pointsPost: null, + pointsReply: null, + pointsRepost: null, + pointsTotal: null + }; + }, + created() { + this.data.reverse(); + this.data.forEach(d => d.total = d.posts + d.replies + d.reposts); + this.render(); + }, + methods: { + render() { + const peak = Math.max.apply(null, this.data.map(d => d.total)); + if (peak != 0) { + this.pointsPost = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.posts / peak)) * this.viewBoxY}`).join(' '); + this.pointsReply = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.replies / peak)) * this.viewBoxY}`).join(' '); + this.pointsRepost = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.reposts / peak)) * this.viewBoxY}`).join(' '); + this.pointsTotal = this.data.map((d, i) => `${(i * this.zoom) + this.pos},${(1 - (d.total / peak)) * this.viewBoxY}`).join(' '); + } + }, + onMousedown(e) { + const clickX = e.clientX; + const clickY = e.clientY; + const baseZoom = this.zoom; + const basePos = this.pos; + + // 動かした時 + dragListen(me => { + let moveLeft = me.clientX - clickX; + let moveTop = me.clientY - clickY; + + this.zoom = baseZoom + (-moveTop / 20); + this.pos = basePos + moveLeft; + if (this.zoom < 1) this.zoom = 1; + if (this.pos > 0) this.pos = 0; + if (this.pos < -(((this.data.length - 1) * this.zoom) - this.viewBoxX)) this.pos = -(((this.data.length - 1) * this.zoom) - this.viewBoxX); + + this.render(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +svg + display block + padding 10px + width 100% + cursor all-scroll + +</style> diff --git a/src/client/app/desktop/views/components/activity.vue b/src/client/app/desktop/views/components/activity.vue new file mode 100644 index 0000000000..480b956ecc --- /dev/null +++ b/src/client/app/desktop/views/components/activity.vue @@ -0,0 +1,116 @@ +<template> +<div class="mk-activity" :data-melt="design == 2"> + <template v-if="design == 0"> + <p class="title">%fa:chart-bar%%i18n:desktop.tags.mk-activity-widget.title%</p> + <button @click="toggle" title="%i18n:desktop.tags.mk-activity-widget.toggle%">%fa:sort%</button> + </template> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <template v-else> + <x-calendar v-show="view == 0" :data="[].concat(activity)"/> + <x-chart v-show="view == 1" :data="[].concat(activity)"/> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XCalendar from './activity.calendar.vue'; +import XChart from './activity.chart.vue'; + +export default Vue.extend({ + components: { + XCalendar, + XChart + }, + props: { + design: { + default: 0 + }, + initView: { + default: 0 + }, + user: { + type: Object, + required: true + } + }, + data() { + return { + fetching: true, + activity: null, + view: this.initView + }; + }, + mounted() { + (this as any).api('aggregation/users/activity', { + userId: this.user.id, + limit: 20 * 7 + }).then(activity => { + this.activity = activity; + this.fetching = false; + }); + }, + methods: { + toggle() { + if (this.view == 1) { + this.view = 0; + this.$emit('viewChanged', this.view); + } else { + this.view++; + this.$emit('viewChanged', this.view); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-activity + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-melt] + background transparent !important + border none !important + + > .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) + + > [data-fa] + 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 + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/components/analog-clock.vue b/src/client/app/desktop/views/components/analog-clock.vue new file mode 100644 index 0000000000..81eec81598 --- /dev/null +++ b/src/client/app/desktop/views/components/analog-clock.vue @@ -0,0 +1,108 @@ +<template> +<canvas class="mk-analog-clock" ref="canvas" width="256" height="256"></canvas> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { themeColor } from '../../../config'; + +const Vec2 = function(this: any, x, y) { + this.x = x; + this.y = y; +}; + +export default Vue.extend({ + data() { + return { + clock: null + }; + }, + mounted() { + this.tick(); + this.clock = setInterval(this.tick, 1000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + tick() { + const canv = this.$refs.canvas as any; + + const now = new Date(); + const s = now.getSeconds(); + const m = now.getMinutes(); + const h = now.getHours(); + + const ctx = canv.getContext('2d'); + const canvW = canv.width; + const canvH = canv.height; + ctx.clearRect(0, 0, canvW, canvH); + + { // 背景 + const center = Math.min((canvW / 2), (canvH / 2)); + const lineStart = center * 0.90; + const shortLineEnd = center * 0.87; + const longLineEnd = center * 0.84; + for (let i = 0; i < 60; i++) { + const angle = Math.PI * i / 30; + const uv = new Vec2(Math.sin(angle), -Math.cos(angle)); + ctx.beginPath(); + ctx.lineWidth = 1; + ctx.moveTo((canvW / 2) + uv.x * lineStart, (canvH / 2) + uv.y * lineStart); + if (i % 5 == 0) { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.2)'; + ctx.lineTo((canvW / 2) + uv.x * longLineEnd, (canvH / 2) + uv.y * longLineEnd); + } else { + ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)'; + ctx.lineTo((canvW / 2) + uv.x * shortLineEnd, (canvH / 2) + uv.y * shortLineEnd); + } + ctx.stroke(); + } + } + + { // 分 + const angle = Math.PI * (m + s / 60) / 30; + const length = Math.min(canvW, canvH) / 2.6; + const uv = new Vec2(Math.sin(angle), -Math.cos(angle)); + ctx.beginPath(); + ctx.strokeStyle = '#ffffff'; + ctx.lineWidth = 2; + ctx.moveTo(canvW / 2 - uv.x * length / 5, canvH / 2 - uv.y * length / 5); + ctx.lineTo(canvW / 2 + uv.x * length, canvH / 2 + uv.y * length); + ctx.stroke(); + } + + { // 時 + const angle = Math.PI * (h % 12 + m / 60) / 6; + const length = Math.min(canvW, canvH) / 4; + const uv = new Vec2(Math.sin(angle), -Math.cos(angle)); + ctx.beginPath(); + ctx.strokeStyle = themeColor; + ctx.lineWidth = 2; + ctx.moveTo(canvW / 2 - uv.x * length / 5, canvH / 2 - uv.y * length / 5); + ctx.lineTo(canvW / 2 + uv.x * length, canvH / 2 + uv.y * length); + ctx.stroke(); + } + + { // 秒 + const angle = Math.PI * s / 30; + const length = Math.min(canvW, canvH) / 2.6; + const uv = new Vec2(Math.sin(angle), -Math.cos(angle)); + ctx.beginPath(); + ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)'; + ctx.lineWidth = 1; + ctx.moveTo(canvW / 2 - uv.x * length / 5, canvH / 2 - uv.y * length / 5); + ctx.lineTo(canvW / 2 + uv.x * length, canvH / 2 + uv.y * length); + ctx.stroke(); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-analog-clock + display block + width 256px + height 256px +</style> diff --git a/src/client/app/desktop/views/components/calendar.vue b/src/client/app/desktop/views/components/calendar.vue new file mode 100644 index 0000000000..71aab2e8a5 --- /dev/null +++ b/src/client/app/desktop/views/components/calendar.vue @@ -0,0 +1,252 @@ +<template> +<div class="mk-calendar" :data-melt="design == 4 || design == 5"> + <template v-if="design == 0 || design == 1"> + <button @click="prev" title="%i18n:desktop.tags.mk-calendar-widget.prev%">%fa:chevron-circle-left%</button> + <p class="title">{{ '%i18n:desktop.tags.mk-calendar-widget.title%'.replace('{1}', year).replace('{2}', month) }}</p> + <button @click="next" title="%i18n:desktop.tags.mk-calendar-widget.next%">%fa:chevron-circle-right%</button> + </template> + + <div class="calendar"> + <template v-if="design == 0 || design == 2 || design == 4"> + <div class="weekday" + v-for="(day, i) in Array(7).fill(0)" + :data-today="year == today.getFullYear() && month == today.getMonth() + 1 && today.getDay() == i" + :data-is-donichi="i == 0 || i == 6" + >{{ weekdayText[i] }}</div> + </template> + <div v-for="n in paddingDays"></div> + <div class="day" v-for="(day, i) in days" + :data-today="isToday(i + 1)" + :data-selected="isSelected(i + 1)" + :data-is-out-of-range="isOutOfRange(i + 1)" + :data-is-donichi="isDonichi(i + 1)" + @click="go(i + 1)" + :title="isOutOfRange(i + 1) ? null : '%i18n:desktop.tags.mk-calendar-widget.go%'" + > + <div>{{ i + 1 }}</div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +const eachMonthDays = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]; + +function isLeapYear(year) { + return (year % 400 == 0) ? true : + (year % 100 == 0) ? false : + (year % 4 == 0) ? true : + false; +} + +export default Vue.extend({ + props: { + design: { + default: 0 + }, + start: { + type: Date, + required: false + } + }, + data() { + return { + today: new Date(), + year: new Date().getFullYear(), + month: new Date().getMonth() + 1, + selected: new Date(), + weekdayText: [ + '%i18n:common.weekday-short.sunday%', + '%i18n:common.weekday-short.monday%', + '%i18n:common.weekday-short.tuesday%', + '%i18n:common.weekday-short.wednesday%', + '%i18n:common.weekday-short.thursday%', + '%i18n:common.weekday-short.friday%', + '%i18n:common.weekday-short.satruday%' + ] + }; + }, + computed: { + paddingDays(): number { + const date = new Date(this.year, this.month - 1, 1); + return date.getDay(); + }, + days(): number { + let days = eachMonthDays[this.month - 1]; + + // うるう年なら+1日 + if (this.month == 2 && isLeapYear(this.year)) days++; + + return days; + } + }, + methods: { + isToday(day) { + return this.year == this.today.getFullYear() && this.month == this.today.getMonth() + 1 && day == this.today.getDate(); + }, + + isSelected(day) { + return this.year == this.selected.getFullYear() && this.month == this.selected.getMonth() + 1 && day == this.selected.getDate(); + }, + + isOutOfRange(day) { + const test = (new Date(this.year, this.month - 1, day)).getTime(); + return test > this.today.getTime() || + (this.start ? test < (this.start as any).getTime() : false); + }, + + isDonichi(day) { + const weekday = (new Date(this.year, this.month - 1, day)).getDay(); + return weekday == 0 || weekday == 6; + }, + + prev() { + if (this.month == 1) { + this.year = this.year - 1; + this.month = 12; + } else { + this.month--; + } + }, + + next() { + if (this.month == 12) { + this.year = this.year + 1; + this.month = 1; + } else { + this.month++; + } + }, + + go(day) { + if (this.isOutOfRange(day)) return; + const date = new Date(this.year, this.month - 1, day, 23, 59, 59, 999); + this.selected = date; + this.$emit('chosen', this.selected); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-calendar + color #777 + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-melt] + background transparent !important + border none !important + + > .title + z-index 1 + margin 0 + padding 0 16px + text-align center + line-height 42px + font-size 0.9em + font-weight bold + color #888 + box-shadow 0 1px rgba(0, 0, 0, 0.07) + + > [data-fa] + margin-right 4px + + > button + position absolute + z-index 2 + top 0 + padding 0 + width 42px + font-size 0.9em + line-height 42px + color #ccc + + &:hover + color #aaa + + &:active + color #999 + + &:first-of-type + left 0 + + &:last-of-type + right 0 + + > .calendar + display flex + flex-wrap wrap + padding 16px + + * + user-select none + + > div + width calc(100% * (1/7)) + text-align center + line-height 32px + font-size 14px + + &.weekday + color #19a2a9 + + &[data-is-donichi] + color #ef95a0 + + &[data-today] + box-shadow 0 0 0 1px #19a2a9 inset + border-radius 6px + + &[data-is-donichi] + box-shadow 0 0 0 1px #ef95a0 inset + + &.day + cursor pointer + color #777 + + > div + border-radius 6px + + &:hover > div + background rgba(0, 0, 0, 0.025) + + &:active > div + background rgba(0, 0, 0, 0.05) + + &[data-is-donichi] + color #ef95a0 + + &[data-is-out-of-range] + cursor default + color rgba(#777, 0.5) + + &[data-is-donichi] + color rgba(#ef95a0, 0.5) + + &[data-selected] + font-weight bold + + > div + background rgba(0, 0, 0, 0.025) + + &:active > div + background rgba(0, 0, 0, 0.05) + + &[data-today] + > div + color $theme-color-foreground + background $theme-color + + &:hover > div + background lighten($theme-color, 10%) + + &:active > div + background darken($theme-color, 10%) + +</style> diff --git a/src/client/app/desktop/views/components/choose-file-from-drive-window.vue b/src/client/app/desktop/views/components/choose-file-from-drive-window.vue new file mode 100644 index 0000000000..9a1e9c958a --- /dev/null +++ b/src/client/app/desktop/views/components/choose-file-from-drive-window.vue @@ -0,0 +1,180 @@ +<template> +<mk-window ref="window" is-modal width="800px" height="500px" @closed="$destroy"> + <span slot="header"> + <span v-html="title" :class="$style.title"></span> + <span :class="$style.count" v-if="multiple && files.length > 0">({{ files.length }}ファイル選択中)</span> + </span> + + <mk-drive + ref="browser" + :class="$style.browser" + :multiple="multiple" + @selected="onSelected" + @change-selection="onChangeSelection" + /> + <div :class="$style.footer"> + <button :class="$style.upload" title="PCからドライブにファイルをアップロード" @click="upload">%fa:upload%</button> + <button :class="$style.cancel" @click="cancel">キャンセル</button> + <button :class="$style.ok" :disabled="multiple && files.length == 0" @click="ok">決定</button> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + multiple: { + default: false + }, + title: { + default: '%fa:R file%ファイルを選択' + } + }, + data() { + return { + files: [] + }; + }, + methods: { + onSelected(file) { + this.files = [file]; + this.ok(); + }, + onChangeSelection(files) { + this.files = files; + }, + upload() { + (this.$refs.browser as any).selectLocalFile(); + }, + ok() { + this.$emit('selected', this.multiple ? this.files : this.files[0]); + (this.$refs.window as any).close(); + }, + cancel() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +@import '~const.styl' + +.title + > [data-fa] + margin-right 4px + +.count + margin-left 8px + opacity 0.7 + +.browser + height calc(100% - 72px) + +.footer + 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 + +</style> + diff --git a/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue b/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue new file mode 100644 index 0000000000..f99533176d --- /dev/null +++ b/src/client/app/desktop/views/components/choose-folder-from-drive-window.vue @@ -0,0 +1,114 @@ +<template> +<mk-window ref="window" is-modal width="800px" height="500px" @closed="$destroy"> + <span slot="header"> + <span v-html="title" :class="$style.title"></span> + </span> + + <mk-drive + ref="browser" + :class="$style.browser" + :multiple="false" + /> + <div :class="$style.footer"> + <button :class="$style.cancel" @click="cancel">キャンセル</button> + <button :class="$style.ok" @click="ok">決定</button> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + title: { + default: '%fa:R folder%フォルダを選択' + } + }, + methods: { + ok() { + this.$emit('selected', (this.$refs.browser as any).folder); + (this.$refs.window as any).close(); + }, + cancel() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +@import '~const.styl' + +.title + > [data-fa] + margin-right 4px + +.browser + height calc(100% - 72px) + +.footer + 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 + +</style> diff --git a/src/client/app/desktop/views/components/context-menu.menu.vue b/src/client/app/desktop/views/components/context-menu.menu.vue new file mode 100644 index 0000000000..6359dbf1b4 --- /dev/null +++ b/src/client/app/desktop/views/components/context-menu.menu.vue @@ -0,0 +1,121 @@ +<template> +<ul class="menu"> + <li v-for="(item, i) in menu" :class="item.type"> + <template v-if="item.type == 'item'"> + <p @click="click(item)"><span :class="$style.icon" v-if="item.icon" v-html="item.icon"></span>{{ item.text }}</p> + </template> + <template v-if="item.type == 'link'"> + <a :href="item.href" :target="item.target" @click="click(item)"><span :class="$style.icon" v-if="item.icon" v-html="item.icon"></span>{{ item.text }}</a> + </template> + <template v-else-if="item.type == 'nest'"> + <p><span :class="$style.icon" v-if="item.icon" v-html="item.icon"></span>{{ item.text }}...<span class="caret">%fa:caret-right%</span></p> + <me-nu :menu="item.menu" @x="click"/> + </template> + </li> +</ul> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + name: 'me-nu', + props: ['menu'], + methods: { + click(item) { + this.$emit('x', item); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.menu + $width = 240px + $item-height = 38px + $padding = 10px + + margin 0 + padding $padding 0 + list-style none + + li + display block + + &.divider + margin-top $padding + padding-top $padding + border-top solid 1px #eee + + &.nest + > p + cursor default + + > .caret + position absolute + top 0 + right 8px + + > * + line-height $item-height + width 28px + text-align center + + &: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 + + &: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 + +</style> + +<style lang="stylus" module> +.icon + > * + width 28px + margin-left -28px + text-align center +</style> + diff --git a/src/client/app/desktop/views/components/context-menu.vue b/src/client/app/desktop/views/components/context-menu.vue new file mode 100644 index 0000000000..8bd9945840 --- /dev/null +++ b/src/client/app/desktop/views/components/context-menu.vue @@ -0,0 +1,74 @@ +<template> +<div class="context-menu" :style="{ left: `${x}px`, top: `${y}px` }" @contextmenu.prevent="() => {}"> + <x-menu :menu="menu" @x="click"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; +import contains from '../../../common/scripts/contains'; +import XMenu from './context-menu.menu.vue'; + +export default Vue.extend({ + components: { + XMenu + }, + props: ['x', 'y', 'menu'], + mounted() { + this.$nextTick(() => { + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + + this.$el.style.display = 'block'; + + anime({ + targets: this.$el, + opacity: [0, 1], + duration: 100, + easing: 'linear' + }); + }); + }, + methods: { + onMousedown(e) { + e.preventDefault(); + if (!contains(this.$el, e.target) && (this.$el != e.target)) this.close(); + return false; + }, + click(item) { + if (item.onClick) item.onClick(); + this.close(); + }, + close() { + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + + this.$emit('closed'); + this.$destroy(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.context-menu + $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) + opacity 0 + +</style> diff --git a/src/client/app/desktop/views/components/crop-window.vue b/src/client/app/desktop/views/components/crop-window.vue new file mode 100644 index 0000000000..eb6a55d959 --- /dev/null +++ b/src/client/app/desktop/views/components/crop-window.vue @@ -0,0 +1,178 @@ +<template> + <mk-window ref="window" is-modal width="800px" :can-close="false"> + <span slot="header">%fa:crop%{{ title }}</span> + <div class="body"> + <vue-cropper ref="cropper" + :src="image.url" + :view-mode="1" + :aspect-ratio="aspectRatio" + :container-style="{ width: '100%', 'max-height': '400px' }" + /> + </div> + <div :class="$style.actions"> + <button :class="$style.skip" @click="skip">クロップをスキップ</button> + <button :class="$style.cancel" @click="cancel">キャンセル</button> + <button :class="$style.ok" @click="ok">決定</button> + </div> + </mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import VueCropper from 'vue-cropperjs'; + +export default Vue.extend({ + components: { + VueCropper + }, + props: { + image: { + type: Object, + required: true + }, + title: { + type: String, + required: true + }, + aspectRatio: { + type: Number, + required: true + } + }, + methods: { + ok() { + (this.$refs.cropper as any).getCroppedCanvas().toBlob(blob => { + this.$emit('cropped', blob); + (this.$refs.window as any).close(); + }); + }, + + skip() { + this.$emit('skipped'); + (this.$refs.window as any).close(); + }, + + cancel() { + this.$emit('canceled'); + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +@import '~const.styl' + +.header + > [data-fa] + margin-right 4px + +.img + width 100% + max-height 400px + +.actions + 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 + +</style> + +<style lang="stylus"> +.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; +} + +@keyframes cropper-bg { + 0% { + background-position: 0 0; + } + + 100% { + background-position: -8px -8px; + } +} +</style> diff --git a/src/client/app/desktop/views/components/dialog.vue b/src/client/app/desktop/views/components/dialog.vue new file mode 100644 index 0000000000..fa17e4a9d2 --- /dev/null +++ b/src/client/app/desktop/views/components/dialog.vue @@ -0,0 +1,170 @@ +<template> +<div class="mk-dialog"> + <div class="bg" ref="bg" @click="onBgClick"></div> + <div class="main" ref="main"> + <header v-html="title" :class="$style.header"></header> + <div class="body" v-html="text"></div> + <div class="buttons"> + <button v-for="button in buttons" @click="click(button)">{{ button.text }}</button> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: { + title: { + type: String, + required: false + }, + text: { + type: String, + required: true + }, + buttons: { + type: Array, + default: () => { + return [{ + text: 'OK' + }]; + } + }, + modal: { + type: Boolean, + default: false + } + }, + mounted() { + this.$nextTick(() => { + (this.$refs.bg as any).style.pointerEvents = 'auto'; + anime({ + targets: this.$refs.bg, + opacity: 1, + duration: 100, + easing: 'linear' + }); + + anime({ + targets: this.$refs.main, + opacity: 1, + scale: [1.2, 1], + duration: 300, + easing: [0, 0.5, 0.5, 1] + }); + }); + }, + methods: { + click(button) { + this.$emit('clicked', button.id); + this.close(); + }, + close() { + (this.$refs.bg as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.bg, + opacity: 0, + duration: 300, + easing: 'linear' + }); + + (this.$refs.main as any).style.pointerEvents = 'none'; + anime({ + targets: this.$refs.main, + opacity: 0, + scale: 0.8, + duration: 300, + easing: [ 0.5, -0.5, 1, 0.5 ], + complete: () => this.$destroy() + }); + }, + onBgClick() { + if (!this.modal) { + this.close(); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-dialog + > .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 + opacity 0 + + > .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 + +</style> + +<style lang="stylus" module> +@import '~const.styl' + +.header + margin 1em 0 + color $theme-color + // color #43A4EC + font-weight bold + + &:empty + display none + + > i + margin-right 0.5em + +</style> diff --git a/src/client/app/desktop/views/components/drive-window.vue b/src/client/app/desktop/views/components/drive-window.vue new file mode 100644 index 0000000000..3a072f4794 --- /dev/null +++ b/src/client/app/desktop/views/components/drive-window.vue @@ -0,0 +1,56 @@ +<template> +<mk-window ref="window" @closed="$destroy" width="800px" height="500px" :popout-url="popout"> + <template slot="header"> + <p v-if="usage" :class="$style.info"><b>{{ usage.toFixed(1) }}%</b> %i18n:desktop.tags.mk-drive-browser-window.used%</p> + <span :class="$style.title">%fa:cloud%%i18n:desktop.tags.mk-drive-browser-window.drive%</span> + </template> + <mk-drive :class="$style.browser" multiple :init-folder="folder" ref="browser"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url } from '../../../config'; + +export default Vue.extend({ + props: ['folder'], + data() { + return { + usage: null + }; + }, + mounted() { + (this as any).api('drive').then(info => { + this.usage = info.usage / info.capacity * 100; + }); + }, + methods: { + popout() { + const folder = (this.$refs.browser as any) ? (this.$refs.browser as any).folder : null; + if (folder) { + return `${url}/i/drive/folder/${folder.id}`; + } else { + return `${url}/i/drive`; + } + } + } +}); +</script> + +<style lang="stylus" module> +.title + > [data-fa] + margin-right 4px + +.info + position absolute + top 0 + left 16px + margin 0 + font-size 80% + +.browser + height 100% + +</style> + diff --git a/src/client/app/desktop/views/components/drive.file.vue b/src/client/app/desktop/views/components/drive.file.vue new file mode 100644 index 0000000000..85f8361c9f --- /dev/null +++ b/src/client/app/desktop/views/components/drive.file.vue @@ -0,0 +1,317 @@ +<template> +<div class="root file" + :data-is-selected="isSelected" + :data-is-contextmenu-showing="isContextmenuShowing" + @click="onClick" + draggable="true" + @dragstart="onDragstart" + @dragend="onDragend" + @contextmenu.prevent.stop="onContextmenu" + :title="title" +> + <div class="label" v-if="os.i.avatarId == file.id"><img src="/assets/label.svg"/> + <p>%i18n:desktop.tags.mk-drive-browser-file.avatar%</p> + </div> + <div class="label" v-if="os.i.bannerId == file.id"><img src="/assets/label.svg"/> + <p>%i18n:desktop.tags.mk-drive-browser-file.banner%</p> + </div> + <div class="thumbnail" ref="thumbnail" :style="`background-color: ${ background }`"> + <img :src="`${file.url}?thumbnail&size=128`" alt="" @load="onThumbnailLoaded"/> + </div> + <p class="name"> + <span>{{ file.name.lastIndexOf('.') != -1 ? file.name.substr(0, file.name.lastIndexOf('.')) : file.name }}</span> + <span class="ext" v-if="file.name.lastIndexOf('.') != -1">{{ file.name.substr(file.name.lastIndexOf('.')) }}</span> + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; +import contextmenu from '../../api/contextmenu'; +import copyToClipboard from '../../../common/scripts/copy-to-clipboard'; + +export default Vue.extend({ + props: ['file'], + data() { + return { + isContextmenuShowing: false, + isDragging: false + }; + }, + computed: { + browser(): any { + return this.$parent; + }, + isSelected(): boolean { + return this.browser.selectedFiles.some(f => f.id == this.file.id); + }, + title(): string { + return `${this.file.name}\n${this.file.type} ${Vue.filter('bytes')(this.file.datasize)}`; + }, + background(): string { + return this.file.properties.avgColor + ? `rgb(${this.file.properties.avgColor.join(',')})` + : 'transparent'; + } + }, + methods: { + onClick() { + this.browser.chooseFile(this.file); + }, + + onContextmenu(e) { + this.isContextmenuShowing = true; + contextmenu(e, [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.rename%', + icon: '%fa:i-cursor%', + onClick: this.rename + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copy-url%', + icon: '%fa:link%', + onClick: this.copyUrl + }, { + type: 'link', + href: `${this.file.url}?download`, + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.download%', + icon: '%fa:download%', + }, { + type: 'divider', + }, { + type: 'item', + text: '%i18n:common.delete%', + icon: '%fa:R trash-alt%', + onClick: this.deleteFile + }, { + type: 'divider', + }, { + type: 'nest', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.else-files%', + menu: [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.set-as-avatar%', + onClick: this.setAsAvatar + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.set-as-banner%', + onClick: this.setAsBanner + }] + }, { + type: 'nest', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.open-in-app%', + menu: [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.add-app%...', + onClick: this.addApp + }] + }], { + closed: () => { + this.isContextmenuShowing = false; + } + }); + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('mk_drive_file', JSON.stringify(this.file)); + this.isDragging = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + this.browser.isDragSource = true; + }, + + onDragend(e) { + this.isDragging = false; + this.browser.isDragSource = false; + }, + + onThumbnailLoaded() { + if (this.file.properties.avgColor) { + anime({ + targets: this.$refs.thumbnail, + backgroundColor: `rgba(${this.file.properties.avgColor.join(',')}, 0)`, + duration: 100, + easing: 'linear' + }); + } + }, + + rename() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.rename-file%', + placeholder: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.input-new-file-name%', + default: this.file.name, + allowEmpty: false + }).then(name => { + (this as any).api('drive/files/update', { + fileId: this.file.id, + name: name + }) + }); + }, + + copyUrl() { + copyToClipboard(this.file.url); + (this as any).apis.dialog({ + title: '%fa:check%%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copied%', + text: '%i18n:desktop.tags.mk-drive-browser-file-contextmenu.copied-url-to-clipboard%', + actions: [{ + text: '%i18n:common.ok%' + }] + }); + }, + + setAsAvatar() { + (this as any).apis.updateAvatar(this.file); + }, + + setAsBanner() { + (this as any).apis.updateBanner(this.file); + }, + + addApp() { + alert('not implemented yet'); + }, + + deleteFile() { + alert('not implemented yet'); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.root.file + padding 8px 0 0 0 + 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] + 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] + &: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 + margin auto + + > 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 + +</style> diff --git a/src/client/app/desktop/views/components/drive.folder.vue b/src/client/app/desktop/views/components/drive.folder.vue new file mode 100644 index 0000000000..a926bf47b2 --- /dev/null +++ b/src/client/app/desktop/views/components/drive.folder.vue @@ -0,0 +1,267 @@ +<template> +<div class="root folder" + :data-is-contextmenu-showing="isContextmenuShowing" + :data-draghover="draghover" + @click="onClick" + @mouseover="onMouseover" + @mouseout="onMouseout" + @dragover.prevent.stop="onDragover" + @dragenter.prevent="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + draggable="true" + @dragstart="onDragstart" + @dragend="onDragend" + @contextmenu.prevent.stop="onContextmenu" + :title="title" +> + <p class="name"> + <template v-if="hover">%fa:R folder-open .fw%</template> + <template v-if="!hover">%fa:R folder .fw%</template> + {{ folder.name }} + </p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import contextmenu from '../../api/contextmenu'; + +export default Vue.extend({ + props: ['folder'], + data() { + return { + hover: false, + draghover: false, + isDragging: false, + isContextmenuShowing: false + }; + }, + computed: { + browser(): any { + return this.$parent; + }, + title(): string { + return this.folder.name; + } + }, + methods: { + onClick() { + this.browser.move(this.folder); + }, + + onContextmenu(e) { + this.isContextmenuShowing = true; + contextmenu(e, [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.move-to-this-folder%', + icon: '%fa:arrow-right%', + onClick: this.go + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.show-in-new-window%', + icon: '%fa:R window-restore%', + onClick: this.newWindow + }, { + type: 'divider', + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.rename%', + icon: '%fa:i-cursor%', + onClick: this.rename + }, { + type: 'divider', + }, { + type: 'item', + text: '%i18n:common.delete%', + icon: '%fa:R trash-alt%', + onClick: this.deleteFolder + }], { + closed: () => { + this.isContextmenuShowing = false; + } + }); + }, + + onMouseover() { + this.hover = true; + }, + + onMouseout() { + this.hover = false + }, + + onDragover(e) { + // 自分自身がドラッグされている場合 + if (this.isDragging) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + }, + + onDragenter() { + if (!this.isDragging) this.draghover = true; + }, + + onDragleave() { + this.draghover = false; + }, + + onDrop(e) { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + Array.from(e.dataTransfer.files).forEach(file => { + this.browser.upload(file, this.folder); + }); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.browser.removeFile(file.id); + (this as any).api('drive/files/update', { + fileId: file.id, + folderId: this.folder.id + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + + // 移動先が自分自身ならreject + if (folder.id == this.folder.id) return; + + this.browser.removeFolder(folder.id); + (this as any).api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder.id + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + (this as any).apis.dialog({ + title: '%fa:exclamation-triangle%%i18n:desktop.tags.mk-drive-browser-folder.unable-to-process%', + text: '%i18n:desktop.tags.mk-drive-browser-folder.circular-reference-detected%', + actions: [{ + text: '%i18n:common.ok%' + }] + }); + break; + default: + alert('%i18n:desktop.tags.mk-drive-browser-folder.unhandled-error% ' + err); + } + }); + } + //#endregion + }, + + onDragstart(e) { + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('mk_drive_folder', JSON.stringify(this.folder)); + this.isDragging = true; + + // 親ブラウザに対して、ドラッグが開始されたフラグを立てる + // (=あなたの子供が、ドラッグを開始しましたよ) + this.browser.isDragSource = true; + }, + + onDragend() { + this.isDragging = false; + this.browser.isDragSource = false; + }, + + go() { + this.browser.move(this.folder.id); + }, + + newWindow() { + this.browser.newWindow(this.folder); + }, + + rename() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.rename-folder%', + placeholder: '%i18n:desktop.tags.mk-drive-browser-folder-contextmenu.input-new-folder-name%', + default: this.folder.name + }).then(name => { + (this as any).api('drive/folders/update', { + folderId: this.folder.id, + name: name + }); + }); + }, + + deleteFolder() { + alert('not implemented yet'); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.root.folder + padding 8px + 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] + &[data-draghover] + &: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] + background lighten($theme-color, 90%) + + > .name + margin 0 + font-size 0.9em + color darken($theme-color, 30%) + + > [data-fa] + margin-right 4px + margin-left 2px + text-align left + +</style> diff --git a/src/client/app/desktop/views/components/drive.nav-folder.vue b/src/client/app/desktop/views/components/drive.nav-folder.vue new file mode 100644 index 0000000000..d885a72f7f --- /dev/null +++ b/src/client/app/desktop/views/components/drive.nav-folder.vue @@ -0,0 +1,113 @@ +<template> +<div class="root nav-folder" + :data-draghover="draghover" + @click="onClick" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <template v-if="folder == null">%fa:cloud%</template> + <span>{{ folder == null ? '%i18n:desktop.tags.mk-drive-browser-nav-folder.drive%' : folder.name }}</span> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['folder'], + data() { + return { + hover: false, + draghover: false + }; + }, + computed: { + browser(): any { + return this.$parent; + } + }, + methods: { + onClick() { + this.browser.move(this.folder); + }, + onMouseover() { + this.hover = true; + }, + onMouseout() { + this.hover = false; + }, + onDragover(e) { + // このフォルダがルートかつカレントディレクトリならドロップ禁止 + if (this.folder == null && this.browser.folder == null) { + e.dataTransfer.dropEffect = 'none'; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + + return false; + }, + onDragenter() { + if (this.folder || this.browser.folder) this.draghover = true; + }, + onDragleave() { + if (this.folder || this.browser.folder) this.draghover = false; + }, + onDrop(e) { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + Array.from(e.dataTransfer.files).forEach(file => { + this.browser.upload(file, this.folder); + }); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.browser.removeFile(file.id); + (this as any).api('drive/files/update', { + fileId: file.id, + folderId: this.folder ? this.folder.id : null + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + // 移動先が自分自身ならreject + if (this.folder && folder.id == this.folder.id) return; + this.browser.removeFolder(folder.id); + (this as any).api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder ? this.folder.id : null + }); + } + //#endregion + } + } +}); +</script> + +<style lang="stylus" scoped> +.root.nav-folder + > * + pointer-events none + + &[data-draghover] + background #eee + +</style> diff --git a/src/client/app/desktop/views/components/drive.vue b/src/client/app/desktop/views/components/drive.vue new file mode 100644 index 0000000000..c766dfec12 --- /dev/null +++ b/src/client/app/desktop/views/components/drive.vue @@ -0,0 +1,773 @@ +<template> +<div class="mk-drive"> + <nav> + <div class="path" @contextmenu.prevent.stop="() => {}"> + <x-nav-folder :class="{ current: folder == null }"/> + <template v-for="folder in hierarchyFolders"> + <span class="separator">%fa:angle-right%</span> + <x-nav-folder :folder="folder" :key="folder.id"/> + </template> + <span class="separator" v-if="folder != null">%fa:angle-right%</span> + <span class="folder current" v-if="folder != null">{{ folder.name }}</span> + </div> + <input class="search" type="search" placeholder=" %i18n:desktop.tags.mk-drive-browser.search%"/> + </nav> + <div class="main" :class="{ uploading: uploadings.length > 0, fetching }" + ref="main" + @mousedown="onMousedown" + @dragover.prevent.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.prevent.stop="onDrop" + @contextmenu.prevent.stop="onContextmenu" + > + <div class="selection" ref="selection"></div> + <div class="contents" ref="contents"> + <div class="folders" ref="foldersContainer" v-if="folders.length > 0"> + <x-folder v-for="folder in folders" :key="folder.id" class="folder" :folder="folder"/> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div class="padding" v-for="n in 16"></div> + <button v-if="moreFolders">%i18n:desktop.tags.mk-drive-browser.load-more%</button> + </div> + <div class="files" ref="filesContainer" v-if="files.length > 0"> + <x-file v-for="file in files" :key="file.id" class="file" :file="file"/> + <!-- SEE: https://stackoverflow.com/questions/18744164/flex-box-align-last-row-to-grid --> + <div class="padding" v-for="n in 16"></div> + <button v-if="moreFiles" @click="fetchMoreFiles">%i18n:desktop.tags.mk-drive-browser.load-more%</button> + </div> + <div class="empty" v-if="files.length == 0 && folders.length == 0 && !fetching"> + <p v-if="draghover">%i18n:desktop.tags.mk-drive-browser.empty-draghover%</p> + <p v-if="!draghover && folder == null"><strong>%i18n:desktop.tags.mk-drive-browser.empty-drive%</strong><br/>%i18n:desktop.tags.mk-drive-browser.empty-drive-description%</p> + <p v-if="!draghover && folder != null">%i18n:desktop.tags.mk-drive-browser.empty-folder%</p> + </div> + </div> + <div class="fetching" v-if="fetching"> + <div class="spinner"> + <div class="dot1"></div> + <div class="dot2"></div> + </div> + </div> + </div> + <div class="dropzone" v-if="draghover"></div> + <mk-uploader ref="uploader" @change="onChangeUploaderUploads" @uploaded="onUploaderUploaded"/> + <input ref="fileInput" type="file" accept="*/*" multiple="multiple" tabindex="-1" @change="onChangeFileInput"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkDriveWindow from './drive-window.vue'; +import XNavFolder from './drive.nav-folder.vue'; +import XFolder from './drive.folder.vue'; +import XFile from './drive.file.vue'; +import contains from '../../../common/scripts/contains'; +import contextmenu from '../../api/contextmenu'; +import { url } from '../../../config'; + +export default Vue.extend({ + components: { + XNavFolder, + XFolder, + XFile + }, + props: { + initFolder: { + type: Object, + required: false + }, + multiple: { + type: Boolean, + default: false + } + }, + data() { + return { + /** + * 現在の階層(フォルダ) + * * null でルートを表す + */ + folder: null, + + files: [], + folders: [], + moreFiles: false, + moreFolders: false, + hierarchyFolders: [], + selectedFiles: [], + uploadings: [], + connection: null, + connectionId: null, + + /** + * ドロップされようとしているか + */ + draghover: false, + + /** + * 自信の所有するアイテムがドラッグをスタートさせたか + * (自分自身の階層にドロップできないようにするためのフラグ) + */ + isDragSource: false, + + fetching: true + }; + }, + mounted() { + this.connection = (this as any).os.streams.driveStream.getConnection(); + this.connectionId = (this as any).os.streams.driveStream.use(); + + this.connection.on('file_created', this.onStreamDriveFileCreated); + this.connection.on('file_updated', this.onStreamDriveFileUpdated); + this.connection.on('folder_created', this.onStreamDriveFolderCreated); + this.connection.on('folder_updated', this.onStreamDriveFolderUpdated); + + if (this.initFolder) { + this.move(this.initFolder); + } else { + this.fetch(); + } + }, + beforeDestroy() { + this.connection.off('file_created', this.onStreamDriveFileCreated); + this.connection.off('file_updated', this.onStreamDriveFileUpdated); + this.connection.off('folder_created', this.onStreamDriveFolderCreated); + this.connection.off('folder_updated', this.onStreamDriveFolderUpdated); + (this as any).os.streams.driveStream.dispose(this.connectionId); + }, + methods: { + onContextmenu(e) { + contextmenu(e, [{ + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-base-contextmenu.create-folder%', + icon: '%fa:R folder%', + onClick: this.createFolder + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-base-contextmenu.upload%', + icon: '%fa:upload%', + onClick: this.selectLocalFile + }, { + type: 'item', + text: '%i18n:desktop.tags.mk-drive-browser-base-contextmenu.url-upload%', + icon: '%fa:cloud-upload-alt%', + onClick: this.urlUpload + }]); + }, + + onStreamDriveFileCreated(file) { + this.addFile(file, true); + }, + + onStreamDriveFileUpdated(file) { + const current = this.folder ? this.folder.id : null; + if (current != file.folderId) { + this.removeFile(file); + } else { + this.addFile(file, true); + } + }, + + onStreamDriveFolderCreated(folder) { + this.addFolder(folder, true); + }, + + onStreamDriveFolderUpdated(folder) { + const current = this.folder ? this.folder.id : null; + if (current != folder.parentId) { + this.removeFolder(folder); + } else { + this.addFolder(folder, true); + } + }, + + onChangeUploaderUploads(uploads) { + this.uploadings = uploads; + }, + + onUploaderUploaded(file) { + this.addFile(file, true); + }, + + onMousedown(e): any { + if (contains(this.$refs.foldersContainer, e.target) || contains(this.$refs.filesContainer, e.target)) return true; + + const main = this.$refs.main as any; + const selection = this.$refs.selection as any; + + const rect = main.getBoundingClientRect(); + + const left = e.pageX + main.scrollLeft - rect.left - window.pageXOffset + const top = e.pageY + main.scrollTop - rect.top - window.pageYOffset + + const move = e => { + selection.style.display = 'block'; + + const cursorX = e.pageX + main.scrollLeft - rect.left - window.pageXOffset; + const cursorY = e.pageY + main.scrollTop - rect.top - window.pageYOffset; + const w = cursorX - left; + const h = cursorY - top; + + if (w > 0) { + selection.style.width = w + 'px'; + selection.style.left = left + 'px'; + } else { + selection.style.width = -w + 'px'; + selection.style.left = cursorX + 'px'; + } + + if (h > 0) { + selection.style.height = h + 'px'; + selection.style.top = top + 'px'; + } else { + selection.style.height = -h + 'px'; + selection.style.top = cursorY + 'px'; + } + }; + + const up = e => { + document.documentElement.removeEventListener('mousemove', move); + document.documentElement.removeEventListener('mouseup', up); + + selection.style.display = 'none'; + }; + + document.documentElement.addEventListener('mousemove', move); + document.documentElement.addEventListener('mouseup', up); + }, + + onDragover(e): any { + // ドラッグ元が自分自身の所有するアイテムだったら + if (this.isDragSource) { + // 自分自身にはドロップさせない + e.dataTransfer.dropEffect = 'none'; + return; + } + + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + const isDriveFolder = e.dataTransfer.types[0] == 'mk_drive_folder'; + + if (isFile || isDriveFile || isDriveFolder) { + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } else { + e.dataTransfer.dropEffect = 'none'; + } + + return false; + }, + + onDragenter(e) { + if (!this.isDragSource) this.draghover = true; + }, + + onDragleave(e) { + this.draghover = false; + }, + + onDrop(e): any { + this.draghover = false; + + // ドロップされてきたものがファイルだったら + if (e.dataTransfer.files.length > 0) { + Array.from(e.dataTransfer.files).forEach(file => { + this.upload(file, this.folder); + }); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + if (this.files.some(f => f.id == file.id)) return; + this.removeFile(file.id); + (this as any).api('drive/files/update', { + fileId: file.id, + folderId: this.folder ? this.folder.id : null + }); + } + //#endregion + + //#region ドライブのフォルダ + const driveFolder = e.dataTransfer.getData('mk_drive_folder'); + if (driveFolder != null && driveFolder != '') { + const folder = JSON.parse(driveFolder); + + // 移動先が自分自身ならreject + if (this.folder && folder.id == this.folder.id) return false; + if (this.folders.some(f => f.id == folder.id)) return false; + this.removeFolder(folder.id); + (this as any).api('drive/folders/update', { + folderId: folder.id, + parentId: this.folder ? this.folder.id : null + }).then(() => { + // noop + }).catch(err => { + switch (err) { + case 'detected-circular-definition': + (this as any).apis.dialog({ + title: '%fa:exclamation-triangle%%i18n:desktop.tags.mk-drive-browser.unable-to-process%', + text: '%i18n:desktop.tags.mk-drive-browser.circular-reference-detected%', + actions: [{ + text: '%i18n:common.ok%' + }] + }); + break; + default: + alert('%i18n:desktop.tags.mk-drive-browser.unhandled-error% ' + err); + } + }); + } + //#endregion + }, + + selectLocalFile() { + (this.$refs.fileInput as any).click(); + }, + + urlUpload() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-drive-browser.url-upload%', + placeholder: '%i18n:desktop.tags.mk-drive-browser.url-of-file%' + }).then(url => { + (this as any).api('drive/files/upload_from_url', { + url: url, + folderId: this.folder ? this.folder.id : undefined + }); + + (this as any).apis.dialog({ + title: '%fa:check%%i18n:desktop.tags.mk-drive-browser.url-upload-requested%', + text: '%i18n:desktop.tags.mk-drive-browser.may-take-time%', + actions: [{ + text: '%i18n:common.ok%' + }] + }); + }); + }, + + createFolder() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-drive-browser.create-folder%', + placeholder: '%i18n:desktop.tags.mk-drive-browser.folder-name%' + }).then(name => { + (this as any).api('drive/folders/create', { + name: name, + folderId: this.folder ? this.folder.id : undefined + }).then(folder => { + this.addFolder(folder, true); + }); + }); + }, + + onChangeFileInput() { + Array.from((this.$refs.fileInput as any).files).forEach(file => { + this.upload(file, this.folder); + }); + }, + + upload(file, folder) { + if (folder && typeof folder == 'object') folder = folder.id; + (this.$refs.uploader as any).upload(file, folder); + }, + + chooseFile(file) { + const isAlreadySelected = this.selectedFiles.some(f => f.id == file.id); + if (this.multiple) { + if (isAlreadySelected) { + this.selectedFiles = this.selectedFiles.filter(f => f.id != file.id); + } else { + this.selectedFiles.push(file); + } + this.$emit('change-selection', this.selectedFiles); + } else { + if (isAlreadySelected) { + this.$emit('selected', file); + } else { + this.selectedFiles = [file]; + this.$emit('change-selection', [file]); + } + } + }, + + newWindow(folder) { + if (document.body.clientWidth > 800) { + (this as any).os.new(MkDriveWindow, { + folder: folder + }); + } else { + window.open(url + '/i/drive/folder/' + folder.id, + 'drive_window', + 'height=500, width=800'); + } + }, + + move(target) { + if (target == null) { + this.goRoot(); + return; + } else if (typeof target == 'object') { + target = target.id; + } + + this.fetching = true; + + (this as any).api('drive/folders/show', { + folderId: target + }).then(folder => { + this.folder = folder; + this.hierarchyFolders = []; + + const dive = folder => { + this.hierarchyFolders.unshift(folder); + if (folder.parent) dive(folder.parent); + }; + + if (folder.parent) dive(folder.parent); + + this.$emit('open-folder', folder); + this.fetch(); + }); + }, + + addFolder(folder, unshift = false) { + const current = this.folder ? this.folder.id : null; + if (current != folder.parentId) return; + + if (this.folders.some(f => f.id == folder.id)) { + const exist = this.folders.map(f => f.id).indexOf(folder.id); + Vue.set(this.folders, exist, folder); + return; + } + + if (unshift) { + this.folders.unshift(folder); + } else { + this.folders.push(folder); + } + }, + + addFile(file, unshift = false) { + const current = this.folder ? this.folder.id : null; + if (current != file.folderId) return; + + if (this.files.some(f => f.id == file.id)) { + const exist = this.files.map(f => f.id).indexOf(file.id); + Vue.set(this.files, exist, file); + return; + } + + if (unshift) { + this.files.unshift(file); + } else { + this.files.push(file); + } + }, + + removeFolder(folder) { + if (typeof folder == 'object') folder = folder.id; + this.folders = this.folders.filter(f => f.id != folder); + }, + + removeFile(file) { + if (typeof file == 'object') file = file.id; + this.files = this.files.filter(f => f.id != file); + }, + + appendFile(file) { + this.addFile(file); + }, + + appendFolder(folder) { + this.addFolder(folder); + }, + + prependFile(file) { + this.addFile(file, true); + }, + + prependFolder(folder) { + this.addFolder(folder, true); + }, + + goRoot() { + // 既にrootにいるなら何もしない + if (this.folder == null) return; + + this.folder = null; + this.hierarchyFolders = []; + this.$emit('move-root'); + this.fetch(); + }, + + fetch() { + this.folders = []; + this.files = []; + this.moreFolders = false; + this.moreFiles = false; + this.fetching = true; + + let fetchedFolders = null; + let fetchedFiles = null; + + const foldersMax = 30; + const filesMax = 30; + + // フォルダ一覧取得 + (this as any).api('drive/folders', { + folderId: this.folder ? this.folder.id : null, + limit: foldersMax + 1 + }).then(folders => { + if (folders.length == foldersMax + 1) { + this.moreFolders = true; + folders.pop(); + } + fetchedFolders = folders; + complete(); + }); + + // ファイル一覧取得 + (this as any).api('drive/files', { + folderId: this.folder ? this.folder.id : null, + limit: filesMax + 1 + }).then(files => { + if (files.length == filesMax + 1) { + this.moreFiles = true; + files.pop(); + } + fetchedFiles = files; + complete(); + }); + + let flag = false; + const complete = () => { + if (flag) { + fetchedFolders.forEach(this.appendFolder); + fetchedFiles.forEach(this.appendFile); + this.fetching = false; + } else { + flag = true; + } + }; + }, + + fetchMoreFiles() { + this.fetching = true; + + const max = 30; + + // ファイル一覧取得 + (this as any).api('drive/files', { + folderId: this.folder ? this.folder.id : null, + limit: max + 1 + }).then(files => { + if (files.length == max + 1) { + this.moreFiles = true; + files.pop(); + } else { + this.moreFiles = false; + } + files.forEach(this.appendFile); + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-drive + + > 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 + + > [data-fa] + 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-control-foreground-color + + > .main + padding 8px + height calc(100% - 38px) + overflow auto + + &, * + user-select none + + &.fetching + 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 + > .files + display flex + flex-wrap wrap + + > .folder + > .file + flex-grow 1 + width 144px + margin 4px + + > .padding + flex-grow 1 + pointer-events none + width 144px + 8px // 8px is margin + + > .empty + padding 16px + text-align center + color #999 + pointer-events none + + > p + margin 0 + + > .fetching + .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 + +</style> diff --git a/src/client/app/desktop/views/components/ellipsis-icon.vue b/src/client/app/desktop/views/components/ellipsis-icon.vue new file mode 100644 index 0000000000..c54a7db29d --- /dev/null +++ b/src/client/app/desktop/views/components/ellipsis-icon.vue @@ -0,0 +1,37 @@ +<template> +<div class="mk-ellipsis-icon"> + <div></div><div></div><div></div> +</div> +</template> + +<style lang="stylus" scoped> +.mk-ellipsis-icon + 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) + +</style> diff --git a/src/client/app/desktop/views/components/follow-button.vue b/src/client/app/desktop/views/components/follow-button.vue new file mode 100644 index 0000000000..9eb22b0fb8 --- /dev/null +++ b/src/client/app/desktop/views/components/follow-button.vue @@ -0,0 +1,164 @@ +<template> +<button class="mk-follow-button" + :class="{ wait, follow: !user.isFollowing, unfollow: user.isFollowing, big: size == 'big' }" + @click="onClick" + :disabled="wait" + :title="user.isFollowing ? 'フォロー解除' : 'フォローする'" +> + <template v-if="!wait && user.isFollowing"> + <template v-if="size == 'compact'">%fa:minus%</template> + <template v-if="size == 'big'">%fa:minus%フォロー解除</template> + </template> + <template v-if="!wait && !user.isFollowing"> + <template v-if="size == 'compact'">%fa:plus%</template> + <template v-if="size == 'big'">%fa:plus%フォロー</template> + </template> + <template v-if="wait">%fa:spinner .pulse .fw%</template> +</button> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + user: { + type: Object, + required: true + }, + size: { + type: String, + default: 'compact' + } + }, + data() { + return { + wait: false, + connection: null, + connectionId: null + }; + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('follow', this.onFollow); + this.connection.on('unfollow', this.onUnfollow); + }, + beforeDestroy() { + this.connection.off('follow', this.onFollow); + this.connection.off('unfollow', this.onUnfollow); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + + onFollow(user) { + if (user.id == this.user.id) { + this.user.isFollowing = user.isFollowing; + } + }, + + onUnfollow(user) { + if (user.id == this.user.id) { + this.user.isFollowing = user.isFollowing; + } + }, + + onClick() { + this.wait = true; + if (this.user.isFollowing) { + (this as any).api('following/delete', { + userId: this.user.id + }).then(() => { + this.user.isFollowing = false; + }).catch(err => { + console.error(err); + }).then(() => { + this.wait = false; + }); + } else { + (this as any).api('following/create', { + userId: this.user.id + }).then(() => { + this.user.isFollowing = true; + }).catch(err => { + console.error(err); + }).then(() => { + this.wait = false; + }); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-follow-button + 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 + + &.big + width 100% + height 38px + line-height 38px + + i + margin-right 8px + +</style> diff --git a/src/client/app/desktop/views/components/followers-window.vue b/src/client/app/desktop/views/components/followers-window.vue new file mode 100644 index 0000000000..623971fa33 --- /dev/null +++ b/src/client/app/desktop/views/components/followers-window.vue @@ -0,0 +1,26 @@ +<template> +<mk-window width="400px" height="550px" @closed="$destroy"> + <span slot="header" :class="$style.header"> + <img :src="`${user.avatarUrl}?thumbnail&size=64`" alt=""/>{{ user.name }}のフォロワー + </span> + <mk-followers :user="user"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'] +}); +</script> + +<style lang="stylus" module> +.header + > img + display inline-block + vertical-align bottom + height calc(100% - 10px) + margin 5px + border-radius 4px + +</style> diff --git a/src/client/app/desktop/views/components/followers.vue b/src/client/app/desktop/views/components/followers.vue new file mode 100644 index 0000000000..a1b98995d8 --- /dev/null +++ b/src/client/app/desktop/views/components/followers.vue @@ -0,0 +1,26 @@ +<template> +<mk-users-list + :fetch="fetch" + :count="user.followersCount" + :you-know-count="user.followersYouKnowCount" +> + フォロワーはいないようです。 +</mk-users-list> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + methods: { + fetch(iknow, limit, cursor, cb) { + (this as any).api('users/followers', { + userId: this.user.id, + iknow: iknow, + limit: limit, + cursor: cursor ? cursor : undefined + }).then(cb); + } + } +}); +</script> diff --git a/src/client/app/desktop/views/components/following-window.vue b/src/client/app/desktop/views/components/following-window.vue new file mode 100644 index 0000000000..612847b386 --- /dev/null +++ b/src/client/app/desktop/views/components/following-window.vue @@ -0,0 +1,26 @@ +<template> +<mk-window width="400px" height="550px" @closed="$destroy"> + <span slot="header" :class="$style.header"> + <img :src="`${user.avatarUrl}?thumbnail&size=64`" alt=""/>{{ user.name }}のフォロー + </span> + <mk-following :user="user"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'] +}); +</script> + +<style lang="stylus" module> +.header + > img + display inline-block + vertical-align bottom + height calc(100% - 10px) + margin 5px + border-radius 4px + +</style> diff --git a/src/client/app/desktop/views/components/following.vue b/src/client/app/desktop/views/components/following.vue new file mode 100644 index 0000000000..b7aedda84f --- /dev/null +++ b/src/client/app/desktop/views/components/following.vue @@ -0,0 +1,26 @@ +<template> +<mk-users-list + :fetch="fetch" + :count="user.followingCount" + :you-know-count="user.followingYouKnowCount" +> + フォロー中のユーザーはいないようです。 +</mk-users-list> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + methods: { + fetch(iknow, limit, cursor, cb) { + (this as any).api('users/following', { + userId: this.user.id, + iknow: iknow, + limit: limit, + cursor: cursor ? cursor : undefined + }).then(cb); + } + } +}); +</script> diff --git a/src/client/app/desktop/views/components/friends-maker.vue b/src/client/app/desktop/views/components/friends-maker.vue new file mode 100644 index 0000000000..fd9914b152 --- /dev/null +++ b/src/client/app/desktop/views/components/friends-maker.vue @@ -0,0 +1,171 @@ +<template> +<div class="mk-friends-maker"> + <p class="title">気になるユーザーをフォロー:</p> + <div class="users" v-if="!fetching && users.length > 0"> + <div class="user" v-for="user in users" :key="user.id"> + <router-link class="avatar-anchor" :to="`/@${getAcct(user)}`"> + <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="user.id"/> + </router-link> + <div class="body"> + <router-link class="name" :to="`/@${getAcct(user)}`" v-user-preview="user.id">{{ user.name }}</router-link> + <p class="username">@{{ getAcct(user) }}</p> + </div> + <mk-follow-button :user="user"/> + </div> + </div> + <p class="empty" v-if="!fetching && users.length == 0">おすすめのユーザーは見つかりませんでした。</p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%読み込んでいます<mk-ellipsis/></p> + <a class="refresh" @click="refresh">もっと見る</a> + <button class="close" @click="$destroy()" title="閉じる">%fa:times%</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + data() { + return { + users: [], + fetching: true, + limit: 6, + page: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + getAcct, + fetch() { + this.fetching = true; + this.users = []; + + (this as any).api('users/recommendation', { + limit: this.limit, + offset: this.limit * this.page + }).then(users => { + this.users = users; + this.fetching = false; + }); + }, + refresh() { + if (this.users.length < this.limit) { + this.page = 0; + } else { + this.page++; + } + this.fetch(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-friends-maker + padding 24px + + > .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 + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + 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 + + > [data-fa] + padding 14px + +</style> diff --git a/src/client/app/desktop/views/components/game-window.vue b/src/client/app/desktop/views/components/game-window.vue new file mode 100644 index 0000000000..3c8bf40e12 --- /dev/null +++ b/src/client/app/desktop/views/components/game-window.vue @@ -0,0 +1,37 @@ +<template> +<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:gamepad%オセロ</span> + <mk-othello :class="$style.content" @gamed="g => game = g"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url } from '../../../config'; + +export default Vue.extend({ + data() { + return { + game: null + }; + }, + computed: { + popout(): string { + return this.game + ? `${url}/othello/${this.game.id}` + : `${url}/othello`; + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +.content + height 100% + overflow auto + +</style> diff --git a/src/client/app/desktop/views/components/home.vue b/src/client/app/desktop/views/components/home.vue new file mode 100644 index 0000000000..7145ddce03 --- /dev/null +++ b/src/client/app/desktop/views/components/home.vue @@ -0,0 +1,357 @@ +<template> +<div class="mk-home" :data-customize="customize"> + <div class="customize" v-if="customize"> + <router-link to="/">%fa:check%完了</router-link> + <div> + <div class="adder"> + <p>ウィジェットを追加:</p> + <select v-model="widgetAdderSelected"> + <option value="profile">プロフィール</option> + <option value="calendar">カレンダー</option> + <option value="timemachine">カレンダー(タイムマシン)</option> + <option value="activity">アクティビティ</option> + <option value="rss">RSSリーダー</option> + <option value="trends">トレンド</option> + <option value="photo-stream">フォトストリーム</option> + <option value="slideshow">スライドショー</option> + <option value="version">バージョン</option> + <option value="broadcast">ブロードキャスト</option> + <option value="notifications">通知</option> + <option value="users">おすすめユーザー</option> + <option value="polls">投票</option> + <option value="post-form">投稿フォーム</option> + <option value="messaging">メッセージ</option> + <option value="channel">チャンネル</option> + <option value="access-log">アクセスログ</option> + <option value="server">サーバー情報</option> + <option value="donation">寄付のお願い</option> + <option value="nav">ナビゲーション</option> + <option value="tips">ヒント</option> + </select> + <button @click="addWidget">追加</button> + </div> + <div class="trash"> + <x-draggable v-model="trash" :options="{ group: 'x' }" @add="onTrash"></x-draggable> + <p>ゴミ箱</p> + </div> + </div> + </div> + <div class="main"> + <template v-if="customize"> + <x-draggable v-for="place in ['left', 'right']" + :list="widgets[place]" + :class="place" + :data-place="place" + :options="{ group: 'x', animation: 150 }" + @sort="onWidgetSort" + :key="place" + > + <div v-for="widget in widgets[place]" class="customize-container" :key="widget.id" @contextmenu.stop.prevent="onWidgetContextmenu(widget.id)"> + <component :is="`mkw-${widget.name}`" :widget="widget" :ref="widget.id" :is-customize-mode="true"/> + </div> + </x-draggable> + <div class="main"> + <a @click="hint">カスタマイズのヒント</a> + <div> + <mk-post-form v-if="os.i.account.clientSettings.showPostFormOnTopOfTl"/> + <mk-timeline ref="tl" @loaded="onTlLoaded"/> + </div> + </div> + </template> + <template v-else> + <div v-for="place in ['left', 'right']" :class="place"> + <component v-for="widget in widgets[place]" :is="`mkw-${widget.name}`" :key="widget.id" :ref="widget.id" :widget="widget" @chosen="warp"/> + </div> + <div class="main"> + <mk-post-form v-if="os.i.account.clientSettings.showPostFormOnTopOfTl"/> + <mk-timeline ref="tl" @loaded="onTlLoaded" v-if="mode == 'timeline'"/> + <mk-mentions @loaded="onTlLoaded" v-if="mode == 'mentions'"/> + </div> + </template> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as XDraggable from 'vuedraggable'; +import * as uuid from 'uuid'; + +export default Vue.extend({ + components: { + XDraggable + }, + props: { + customize: Boolean, + mode: { + type: String, + default: 'timeline' + } + }, + data() { + return { + connection: null, + connectionId: null, + widgetAdderSelected: null, + trash: [], + widgets: { + left: [], + right: [] + } + }; + }, + computed: { + home: { + get(): any[] { + //#region 互換性のため + (this as any).os.i.account.clientSettings.home.forEach(w => { + if (w.name == 'rss-reader') w.name = 'rss'; + if (w.name == 'user-recommendation') w.name = 'users'; + if (w.name == 'recommended-polls') w.name = 'polls'; + }); + //#endregion + return (this as any).os.i.account.clientSettings.home; + }, + set(value) { + (this as any).os.i.account.clientSettings.home = value; + } + }, + left(): any[] { + return this.home.filter(w => w.place == 'left'); + }, + right(): any[] { + return this.home.filter(w => w.place == 'right'); + } + }, + created() { + this.widgets.left = this.left; + this.widgets.right = this.right; + this.$watch('os.i.account.clientSettings', i => { + this.widgets.left = this.left; + this.widgets.right = this.right; + }, { + deep: true + }); + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('home_updated', this.onHomeUpdated); + }, + beforeDestroy() { + this.connection.off('home_updated', this.onHomeUpdated); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + hint() { + (this as any).apis.dialog({ + title: '%fa:info-circle%カスタマイズのヒント', + text: '<p>ホームのカスタマイズでは、ウィジェットを追加/削除したり、ドラッグ&ドロップして並べ替えたりすることができます。</p>' + + '<p>一部のウィジェットは、<strong><strong>右</strong>クリック</strong>することで表示を変更することができます。</p>' + + '<p>ウィジェットを削除するには、ヘッダーの<strong>「ゴミ箱」</strong>と書かれたエリアにウィジェットをドラッグ&ドロップします。</p>' + + '<p>カスタマイズを終了するには、右上の「完了」をクリックします。</p>', + actions: [{ + text: 'Got it!' + }] + }); + }, + onTlLoaded() { + this.$emit('loaded'); + }, + onHomeUpdated(data) { + if (data.home) { + (this as any).os.i.account.clientSettings.home = data.home; + this.widgets.left = data.home.filter(w => w.place == 'left'); + this.widgets.right = data.home.filter(w => w.place == 'right'); + } else { + const w = (this as any).os.i.account.clientSettings.home.find(w => w.id == data.id); + if (w != null) { + w.data = data.data; + this.$refs[w.id][0].preventSave = true; + this.$refs[w.id][0].props = w.data; + this.widgets.left = (this as any).os.i.account.clientSettings.home.filter(w => w.place == 'left'); + this.widgets.right = (this as any).os.i.account.clientSettings.home.filter(w => w.place == 'right'); + } + } + }, + onWidgetContextmenu(widgetId) { + const w = (this.$refs[widgetId] as any)[0]; + if (w.func) w.func(); + }, + onWidgetSort() { + this.saveHome(); + }, + onTrash(evt) { + this.saveHome(); + }, + addWidget() { + const widget = { + name: this.widgetAdderSelected, + id: uuid(), + place: 'left', + data: {} + }; + + this.widgets.left.unshift(widget); + this.saveHome(); + }, + saveHome() { + const left = this.widgets.left; + const right = this.widgets.right; + this.home = left.concat(right); + left.forEach(w => w.place = 'left'); + right.forEach(w => w.place = 'right'); + (this as any).api('i/update_home', { + home: this.home + }); + }, + warp(date) { + (this.$refs.tl as any).warp(date); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-home + display block + + &[data-customize] + padding-top 48px + background-image url('/assets/desktop/grid.svg') + + > .main > .main + > a + display block + margin-bottom 8px + text-align center + + > div + cursor not-allowed !important + + > * + pointer-events none + + &:not([data-customize]) + > .main > *:empty + display none + + > .customize + position fixed + z-index 1000 + top 0 + left 0 + width 100% + height 48px + background #f7f7f7 + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + > a + display block + position absolute + z-index 1001 + top 0 + right 0 + padding 0 16px + line-height 48px + text-decoration none + color $theme-color-foreground + background $theme-color + transition background 0.1s ease + + &:hover + background lighten($theme-color, 10%) + + &:active + background darken($theme-color, 10%) + transition background 0s ease + + > [data-fa] + margin-right 8px + + > div + display flex + margin 0 auto + max-width 1200px - 32px + + > div + width 50% + + &.adder + > p + display inline + line-height 48px + + &.trash + border-left solid 1px #ddd + + > div + width 100% + height 100% + + > p + position absolute + top 0 + left 0 + width 100% + line-height 48px + margin 0 + text-align center + pointer-events none + + > .main + display flex + justify-content center + margin 0 auto + max-width 1200px + + > * + .customize-container + cursor move + border-radius 6px + + &:hover + box-shadow 0 0 8px rgba(64, 120, 200, 0.3) + + > * + pointer-events none + + > .main + padding 16px + width calc(100% - 275px * 2) + order 2 + + .mk-post-form + margin-bottom 16px + border solid 1px #e5e5e5 + border-radius 4px + + > *:not(.main) + width 275px + padding 16px 0 16px 0 + + > *:not(:last-child) + margin-bottom 16px + + > .left + padding-left 16px + order 1 + + > .right + padding-right 16px + order 3 + + @media (max-width 1100px) + > *:not(.main) + display none + + > .main + float none + width 100% + max-width 700px + margin 0 auto + +</style> diff --git a/src/client/app/desktop/views/components/index.ts b/src/client/app/desktop/views/components/index.ts new file mode 100644 index 0000000000..3798bf6d2d --- /dev/null +++ b/src/client/app/desktop/views/components/index.ts @@ -0,0 +1,61 @@ +import Vue from 'vue'; + +import ui from './ui.vue'; +import uiNotification from './ui-notification.vue'; +import home from './home.vue'; +import timeline from './timeline.vue'; +import posts from './posts.vue'; +import subPostContent from './sub-post-content.vue'; +import window from './window.vue'; +import postFormWindow from './post-form-window.vue'; +import repostFormWindow from './repost-form-window.vue'; +import analogClock from './analog-clock.vue'; +import ellipsisIcon from './ellipsis-icon.vue'; +import mediaImage from './media-image.vue'; +import mediaImageDialog from './media-image-dialog.vue'; +import mediaVideo from './media-video.vue'; +import notifications from './notifications.vue'; +import postForm from './post-form.vue'; +import repostForm from './repost-form.vue'; +import followButton from './follow-button.vue'; +import postPreview from './post-preview.vue'; +import drive from './drive.vue'; +import postDetail from './post-detail.vue'; +import settings from './settings.vue'; +import calendar from './calendar.vue'; +import activity from './activity.vue'; +import friendsMaker from './friends-maker.vue'; +import followers from './followers.vue'; +import following from './following.vue'; +import usersList from './users-list.vue'; +import widgetContainer from './widget-container.vue'; + +Vue.component('mk-ui', ui); +Vue.component('mk-ui-notification', uiNotification); +Vue.component('mk-home', home); +Vue.component('mk-timeline', timeline); +Vue.component('mk-posts', posts); +Vue.component('mk-sub-post-content', subPostContent); +Vue.component('mk-window', window); +Vue.component('mk-post-form-window', postFormWindow); +Vue.component('mk-repost-form-window', repostFormWindow); +Vue.component('mk-analog-clock', analogClock); +Vue.component('mk-ellipsis-icon', ellipsisIcon); +Vue.component('mk-media-image', mediaImage); +Vue.component('mk-media-image-dialog', mediaImageDialog); +Vue.component('mk-media-video', mediaVideo); +Vue.component('mk-notifications', notifications); +Vue.component('mk-post-form', postForm); +Vue.component('mk-repost-form', repostForm); +Vue.component('mk-follow-button', followButton); +Vue.component('mk-post-preview', postPreview); +Vue.component('mk-drive', drive); +Vue.component('mk-post-detail', postDetail); +Vue.component('mk-settings', settings); +Vue.component('mk-calendar', calendar); +Vue.component('mk-activity', activity); +Vue.component('mk-friends-maker', friendsMaker); +Vue.component('mk-followers', followers); +Vue.component('mk-following', following); +Vue.component('mk-users-list', usersList); +Vue.component('mk-widget-container', widgetContainer); diff --git a/src/client/app/desktop/views/components/input-dialog.vue b/src/client/app/desktop/views/components/input-dialog.vue new file mode 100644 index 0000000000..e939fc1903 --- /dev/null +++ b/src/client/app/desktop/views/components/input-dialog.vue @@ -0,0 +1,180 @@ +<template> +<mk-window ref="window" is-modal width="500px" @before-close="beforeClose" @closed="$destroy"> + <span slot="header" :class="$style.header"> + %fa:i-cursor%{{ title }} + </span> + + <div :class="$style.body"> + <input ref="text" v-model="text" :type="type" @keydown="onKeydown" :placeholder="placeholder"/> + </div> + <div :class="$style.actions"> + <button :class="$style.cancel" @click="cancel">キャンセル</button> + <button :class="$style.ok" :disabled="!allowEmpty && text.length == 0" @click="ok">決定</button> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + title: { + type: String + }, + placeholder: { + type: String + }, + default: { + type: String + }, + allowEmpty: { + default: true + }, + type: { + default: 'text' + } + }, + data() { + return { + done: false, + text: '' + }; + }, + mounted() { + if (this.default) this.text = this.default; + this.$nextTick(() => { + (this.$refs.text as any).focus(); + }); + }, + methods: { + ok() { + if (!this.allowEmpty && this.text == '') return; + this.done = true; + (this.$refs.window as any).close(); + }, + cancel() { + this.done = false; + (this.$refs.window as any).close(); + }, + beforeClose() { + if (this.done) { + this.$emit('done', this.text); + } else { + this.$emit('canceled'); + } + }, + onKeydown(e) { + if (e.which == 13) { // Enter + e.preventDefault(); + e.stopPropagation(); + this.ok(); + } + } + } +}); +</script> + + +<style lang="stylus" module> +@import '~const.styl' + +.header + > [data-fa] + margin-right 4px + +.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) + +.actions + 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 + +</style> diff --git a/src/client/app/desktop/views/components/media-image-dialog.vue b/src/client/app/desktop/views/components/media-image-dialog.vue new file mode 100644 index 0000000000..dec140d1c9 --- /dev/null +++ b/src/client/app/desktop/views/components/media-image-dialog.vue @@ -0,0 +1,69 @@ +<template> +<div class="mk-media-image-dialog"> + <div class="bg" @click="close"></div> + <img :src="image.url" :alt="image.name" :title="image.name" @click="close"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['image'], + mounted() { + anime({ + targets: this.$el, + opacity: 1, + duration: 100, + easing: 'linear' + }); + }, + methods: { + close() { + anime({ + targets: this.$el, + opacity: 0, + duration: 100, + easing: 'linear', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-media-image-dialog + 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 + +</style> diff --git a/src/client/app/desktop/views/components/media-image.vue b/src/client/app/desktop/views/components/media-image.vue new file mode 100644 index 0000000000..51309a0578 --- /dev/null +++ b/src/client/app/desktop/views/components/media-image.vue @@ -0,0 +1,63 @@ +<template> +<a class="mk-media-image" + :href="image.url" + @mousemove="onMousemove" + @mouseleave="onMouseleave" + @click.prevent="onClick" + :style="style" + :title="image.name" +></a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkMediaImageDialog from './media-image-dialog.vue'; + +export default Vue.extend({ + props: ['image'], + computed: { + style(): any { + return { + 'background-color': this.image.properties.avgColor ? `rgb(${this.image.properties.avgColor.join(',')})` : 'transparent', + 'background-image': `url(${this.image.url}?thumbnail&size=512)` + }; + } + }, + methods: { + onMousemove(e) { + const rect = this.$el.getBoundingClientRect(); + const mouseX = e.clientX - rect.left; + const mouseY = e.clientY - rect.top; + const xp = mouseX / this.$el.offsetWidth * 100; + const yp = mouseY / this.$el.offsetHeight * 100; + this.$el.style.backgroundPosition = xp + '% ' + yp + '%'; + this.$el.style.backgroundImage = 'url("' + this.image.url + '?thumbnail")'; + }, + + onMouseleave() { + this.$el.style.backgroundPosition = ''; + }, + + onClick() { + (this as any).os.new(MkMediaImageDialog, { + image: this.image + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-media-image + display block + cursor zoom-in + overflow hidden + width 100% + height 100% + background-position center + border-radius 4px + + &:not(:hover) + background-size cover + +</style> diff --git a/src/client/app/desktop/views/components/media-video-dialog.vue b/src/client/app/desktop/views/components/media-video-dialog.vue new file mode 100644 index 0000000000..cbf862cd1c --- /dev/null +++ b/src/client/app/desktop/views/components/media-video-dialog.vue @@ -0,0 +1,70 @@ +<template> +<div class="mk-media-video-dialog"> + <div class="bg" @click="close"></div> + <video :src="video.url" :title="video.name" controls autoplay ref="video"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['video', 'start'], + mounted() { + anime({ + targets: this.$el, + opacity: 1, + duration: 100, + easing: 'linear' + }); + const videoTag = this.$refs.video as HTMLVideoElement + if (this.start) videoTag.currentTime = this.start + }, + methods: { + close() { + anime({ + targets: this.$el, + opacity: 0, + duration: 100, + easing: 'linear', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-media-video-dialog + 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) + + > video + position fixed + z-index 2 + top 0 + right 0 + bottom 0 + left 0 + max-width 80vw + max-height 80vh + margin auto + +</style> diff --git a/src/client/app/desktop/views/components/media-video.vue b/src/client/app/desktop/views/components/media-video.vue new file mode 100644 index 0000000000..4fd955a821 --- /dev/null +++ b/src/client/app/desktop/views/components/media-video.vue @@ -0,0 +1,67 @@ +<template> + <video class="mk-media-video" + :src="video.url" + :title="video.name" + controls + @dblclick.prevent="onClick" + ref="video" + v-if="inlinePlayable" /> + <a class="mk-media-video-thumbnail" + :href="video.url" + :style="imageStyle" + @click.prevent="onClick" + :title="video.name" + v-else> + %fa:R play-circle% + </a> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkMediaVideoDialog from './media-video-dialog.vue'; + +export default Vue.extend({ + props: ['video', 'inlinePlayable'], + computed: { + imageStyle(): any { + return { + 'background-image': `url(${this.video.url}?thumbnail&size=512)` + }; + } + }, + methods: { + onClick() { + const videoTag = this.$refs.video as (HTMLVideoElement | null) + var start = 0 + if (videoTag) { + start = videoTag.currentTime + videoTag.pause() + } + (this as any).os.new(MkMediaVideoDialog, { + video: this.video, + start, + }) + } + } +}) +</script> + +<style lang="stylus" scoped> +.mk-media-video + display block + width 100% + height 100% + border-radius 4px +.mk-media-video-thumbnail + display flex + justify-content center + align-items center + font-size 3.5em + + cursor zoom-in + overflow hidden + background-position center + background-size cover + width 100% + height 100% +</style> diff --git a/src/client/app/desktop/views/components/mentions.vue b/src/client/app/desktop/views/components/mentions.vue new file mode 100644 index 0000000000..90a92495b7 --- /dev/null +++ b/src/client/app/desktop/views/components/mentions.vue @@ -0,0 +1,125 @@ +<template> +<div class="mk-mentions"> + <header> + <span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて</span> + <span :data-is-active="mode == 'following'" @click="mode = 'following'">フォロー中</span> + </header> + <div class="fetching" v-if="fetching"> + <mk-ellipsis-icon/> + </div> + <p class="empty" v-if="posts.length == 0 && !fetching"> + %fa:R comments% + <span v-if="mode == 'all'">あなた宛ての投稿はありません。</span> + <span v-if="mode == 'following'">あなたがフォローしているユーザーからの言及はありません。</span> + </p> + <mk-posts :posts="posts" ref="timeline"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + moreFetching: false, + mode: 'all', + posts: [] + }; + }, + watch: { + mode() { + this.fetch(); + } + }, + mounted() { + document.addEventListener('keydown', this.onDocumentKeydown); + window.addEventListener('scroll', this.onScroll); + + this.fetch(() => this.$emit('loaded')); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onDocumentKeydown); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + onDocumentKeydown(e) { + if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { + if (e.which == 84) { // t + (this.$refs.timeline as any).focus(); + } + } + }, + onScroll() { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 8) this.more(); + }, + fetch(cb?) { + this.fetching = true; + this.posts = []; + (this as any).api('posts/mentions', { + following: this.mode == 'following' + }).then(posts => { + this.posts = posts; + this.fetching = false; + if (cb) cb(); + }); + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0) return; + this.moreFetching = true; + (this as any).api('posts/mentions', { + following: this.mode == 'following', + untilId: this.posts[this.posts.length - 1].id + }).then(posts => { + this.posts = this.posts.concat(posts); + this.moreFetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-mentions + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > 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 + + > .fetching + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > [data-fa] + display block + margin-bottom 16px + font-size 3em + color #ccc + +</style> diff --git a/src/client/app/desktop/views/components/messaging-room-window.vue b/src/client/app/desktop/views/components/messaging-room-window.vue new file mode 100644 index 0000000000..3735267811 --- /dev/null +++ b/src/client/app/desktop/views/components/messaging-room-window.vue @@ -0,0 +1,32 @@ +<template> +<mk-window ref="window" width="500px" height="560px" :popout-url="popout" @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:comments%メッセージ: {{ user.name }}</span> + <mk-messaging-room :user="user" :class="$style.content"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url } from '../../../config'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + computed: { + popout(): string { + return `${url}/i/messaging/${getAcct(this.user)}`; + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +.content + height 100% + overflow auto + +</style> diff --git a/src/client/app/desktop/views/components/messaging-window.vue b/src/client/app/desktop/views/components/messaging-window.vue new file mode 100644 index 0000000000..ac27465987 --- /dev/null +++ b/src/client/app/desktop/views/components/messaging-window.vue @@ -0,0 +1,32 @@ +<template> +<mk-window ref="window" width="500px" height="560px" @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:comments%メッセージ</span> + <mk-messaging :class="$style.content" @navigate="navigate"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkMessagingRoomWindow from './messaging-room-window.vue'; + +export default Vue.extend({ + methods: { + navigate(user) { + (this as any).os.new(MkMessagingRoomWindow, { + user: user + }); + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +.content + height 100% + overflow auto + +</style> diff --git a/src/client/app/desktop/views/components/notifications.vue b/src/client/app/desktop/views/components/notifications.vue new file mode 100644 index 0000000000..5e6db08c12 --- /dev/null +++ b/src/client/app/desktop/views/components/notifications.vue @@ -0,0 +1,317 @@ +<template> +<div class="mk-notifications"> + <div class="notifications" v-if="notifications.length != 0"> + <template v-for="(notification, i) in _notifications"> + <div class="notification" :class="notification.type" :key="notification.id"> + <mk-time :time="notification.createdAt"/> + <template v-if="notification.type == 'reaction'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id"> + <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p> + <mk-reaction-icon :reaction="notification.reaction"/> + <router-link :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">{{ notification.user.name }}</router-link> + </p> + <router-link class="post-ref" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right% + </router-link> + </div> + </template> + <template v-if="notification.type == 'repost'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId"> + <img class="avatar" :src="`${notification.post.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:retweet% + <router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">{{ notification.post.user.name }}</router-link> + </p> + <router-link class="post-ref" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post.repost) }}%fa:quote-right% + </router-link> + </div> + </template> + <template v-if="notification.type == 'quote'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId"> + <img class="avatar" :src="`${notification.post.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:quote-left% + <router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">{{ notification.post.user.name }}</router-link> + </p> + <router-link class="post-preview" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</router-link> + </div> + </template> + <template v-if="notification.type == 'follow'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id"> + <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:user-plus% + <router-link :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">{{ notification.user.name }}</router-link> + </p> + </div> + </template> + <template v-if="notification.type == 'reply'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId"> + <img class="avatar" :src="`${notification.post.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:reply% + <router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">{{ notification.post.user.name }}</router-link> + </p> + <router-link class="post-preview" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</router-link> + </div> + </template> + <template v-if="notification.type == 'mention'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId"> + <img class="avatar" :src="`${notification.post.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:at% + <router-link :to="`/@${getAcct(notification.post.user)}`" v-user-preview="notification.post.userId">{{ notification.post.user.name }}</router-link> + </p> + <a class="post-preview" :href="`/@${getAcct(notification.post.user)}/${notification.post.id}`">{{ getPostSummary(notification.post) }}</a> + </div> + </template> + <template v-if="notification.type == 'poll_vote'"> + <router-link class="avatar-anchor" :to="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id"> + <img class="avatar" :src="`${notification.user.avatarUrl}?thumbnail&size=48`" alt="avatar"/> + </router-link> + <div class="text"> + <p>%fa:chart-pie%<a :href="`/@${getAcct(notification.user)}`" v-user-preview="notification.user.id">{{ notification.user.name }}</a></p> + <router-link class="post-ref" :to="`/@${getAcct(notification.post.user)}/${notification.post.id}`"> + %fa:quote-left%{{ getPostSummary(notification.post) }}%fa:quote-right% + </router-link> + </div> + </template> + </div> + <p class="date" v-if="i != notifications.length - 1 && notification._date != _notifications[i + 1]._date" :key="notification.id + '-time'"> + <span>%fa:angle-up%{{ notification._datetext }}</span> + <span>%fa:angle-down%{{ _notifications[i + 1]._datetext }}</span> + </p> + </template> + </div> + <button class="more" :class="{ fetching: fetchingMoreNotifications }" v-if="moreNotifications" @click="fetchMoreNotifications" :disabled="fetchingMoreNotifications"> + <template v-if="fetchingMoreNotifications">%fa:spinner .pulse .fw%</template>{{ fetchingMoreNotifications ? '%i18n:common.loading%' : '%i18n:desktop.tags.mk-notifications.more%' }} + </button> + <p class="empty" v-if="notifications.length == 0 && !fetching">ありません!</p> + <p class="loading" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; +import getPostSummary from '../../../../../common/get-post-summary'; + +export default Vue.extend({ + data() { + return { + fetching: true, + fetchingMoreNotifications: false, + notifications: [], + moreNotifications: false, + connection: null, + connectionId: null, + getPostSummary + }; + }, + computed: { + _notifications(): any[] { + return (this.notifications as any).map(notification => { + const date = new Date(notification.createdAt).getDate(); + const month = new Date(notification.createdAt).getMonth() + 1; + notification._date = date; + notification._datetext = `${month}月 ${date}日`; + return notification; + }); + } + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('notification', this.onNotification); + + const max = 10; + + (this as any).api('i/notifications', { + limit: max + 1 + }).then(notifications => { + if (notifications.length == max + 1) { + this.moreNotifications = true; + notifications.pop(); + } + + this.notifications = notifications; + this.fetching = false; + }); + }, + beforeDestroy() { + this.connection.off('notification', this.onNotification); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + getAcct, + fetchMoreNotifications() { + this.fetchingMoreNotifications = true; + + const max = 30; + + (this as any).api('i/notifications', { + limit: max + 1, + untilId: this.notifications[this.notifications.length - 1].id + }).then(notifications => { + if (notifications.length == max + 1) { + this.moreNotifications = true; + notifications.pop(); + } else { + this.moreNotifications = false; + } + this.notifications = this.notifications.concat(notifications); + this.fetchingMoreNotifications = false; + }); + }, + onNotification(notification) { + // TODO: ユーザーが画面を見てないと思われるとき(ブラウザやタブがアクティブじゃないなど)は送信しない + this.connection.send({ + type: 'read_notification', + id: notification.id + }); + + this.notifications.unshift(notification); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-notifications + > .notifications + > .notification + margin 0 + padding 16px + overflow-wrap break-word + 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 + + &:after + content "" + display block + clear both + + > .avatar-anchor + display block + float left + position -webkit-sticky + position sticky + top 16px + + > img + display block + 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, .mk-reaction-icon + margin-right 4px + + .post-preview + color rgba(0, 0, 0, 0.7) + + .post-ref + color rgba(0, 0, 0, 0.7) + + [data-fa] + font-size 1em + font-weight normal + font-style normal + display inline-block + margin-right 3px + + &.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 + + [data-fa] + margin-right 8px + + > .more + display block + width 100% + padding 16px + color #555 + border-top solid 1px rgba(0, 0, 0, 0.05) + + &:hover + background rgba(0, 0, 0, 0.025) + + &:active + background rgba(0, 0, 0, 0.05) + + &.fetching + cursor wait + + > [data-fa] + margin-right 4px + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .loading + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/components/post-detail.sub.vue b/src/client/app/desktop/views/components/post-detail.sub.vue new file mode 100644 index 0000000000..35377e7c24 --- /dev/null +++ b/src/client/app/desktop/views/components/post-detail.sub.vue @@ -0,0 +1,126 @@ +<template> +<div class="sub" :title="title"> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="post.userId"/> + </router-link> + <div class="main"> + <header> + <div class="left"> + <router-link class="name" :to="`/@${acct}`" v-user-preview="post.userId">{{ post.user.name }}</router-link> + <span class="username">@{{ acct }}</span> + </div> + <div class="right"> + <router-link class="time" :to="`/@${acct}/${post.id}`"> + <mk-time :time="post.createdAt"/> + </router-link> + </div> + </header> + <div class="body"> + <mk-post-html v-if="post.ast" :ast="post.ast" :i="os.i" :class="$style.text"/> + <div class="media" v-if="post.media"> + <mk-media-list :media-list="post.media"/> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + }, + title(): string { + return dateStringify(this.post.createdAt); + } + } +}); +</script> + +<style lang="stylus" scoped> +.sub + 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 + +</style> + +<style lang="stylus" module> +.text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1em + color #717171 +</style> diff --git a/src/client/app/desktop/views/components/post-detail.vue b/src/client/app/desktop/views/components/post-detail.vue new file mode 100644 index 0000000000..5c7a7dfdbe --- /dev/null +++ b/src/client/app/desktop/views/components/post-detail.vue @@ -0,0 +1,433 @@ +<template> +<div class="mk-post-detail" :title="title"> + <button + class="read-more" + v-if="p.reply && p.reply.replyId && context == null" + title="会話をもっと読み込む" + @click="fetchContext" + :disabled="contextFetching" + > + <template v-if="!contextFetching">%fa:ellipsis-v%</template> + <template v-if="contextFetching">%fa:spinner .pulse%</template> + </button> + <div class="context"> + <x-sub v-for="post in context" :key="post.id" :post="post"/> + </div> + <div class="reply-to" v-if="p.reply"> + <x-sub :post="p.reply"/> + </div> + <div class="repost" v-if="isRepost"> + <p> + <router-link class="avatar-anchor" :to="`/@${acct}`" v-user-preview="post.userId"> + <img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=32`" alt="avatar"/> + </router-link> + %fa:retweet% + <router-link class="name" :href="`/@${acct}`">{{ post.user.name }}</router-link> + がRepost + </p> + </div> + <article> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/> + </router-link> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="p.user.id">{{ p.user.name }}</router-link> + <span class="username">@{{ acct }}</span> + <router-link class="time" :to="`/@${acct}/${p.id}`"> + <mk-time :time="p.createdAt"/> + </router-link> + </header> + <div class="body"> + <mk-post-html :class="$style.text" v-if="p.ast" :ast="p.ast" :i="os.i"/> + <div class="media" v-if="p.media"> + <mk-media-list :media-list="p.media"/> + </div> + <mk-poll v-if="p.poll" :post="p"/> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + <div class="tags" v-if="p.tags && p.tags.length > 0"> + <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link> + </div> + <a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a> + <div class="map" v-if="p.geo" ref="map"></div> + <div class="repost" v-if="p.repost"> + <mk-post-preview :post="p.repost"/> + </div> + </div> + <footer> + <mk-reactions-viewer :post="p"/> + <button @click="reply" title="返信"> + %fa:reply%<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> + </button> + <button @click="repost" title="Repost"> + %fa:retweet%<p class="count" v-if="p.repostCount > 0">{{ p.repostCount }}</p> + </button> + <button :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton" title="リアクション"> + %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> + </button> + <button @click="menu" ref="menuButton"> + %fa:ellipsis-h% + </button> + </footer> + </article> + <div class="replies" v-if="!compact"> + <x-sub v-for="post in replies" :key="post.id" :post="post"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; + +import MkPostFormWindow from './post-form-window.vue'; +import MkRepostFormWindow from './repost-form-window.vue'; +import MkPostMenu from '../../../common/views/components/post-menu.vue'; +import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; +import XSub from './post-detail.sub.vue'; + +export default Vue.extend({ + components: { + XSub + }, + props: { + post: { + type: Object, + required: true + }, + compact: { + default: false + } + }, + computed: { + acct() { + return getAcct(this.post.user); + } + }, + data() { + return { + context: [], + contextFetching: false, + replies: [], + }; + }, + computed: { + isRepost(): boolean { + return (this.post.repost && + this.post.text == null && + this.post.mediaIds == null && + this.post.poll == null); + }, + p(): any { + return this.isRepost ? this.post.repost : this.post; + }, + reactionsCount(): number { + return this.p.reactionCounts + ? Object.keys(this.p.reactionCounts) + .map(key => this.p.reactionCounts[key]) + .reduce((a, b) => a + b) + : 0; + }, + title(): string { + return dateStringify(this.p.createdAt); + }, + urls(): string[] { + if (this.p.ast) { + return this.p.ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + }, + mounted() { + // Get replies + if (!this.compact) { + (this as any).api('posts/replies', { + postId: this.p.id, + limit: 8 + }).then(replies => { + this.replies = replies; + }); + } + + // Draw map + if (this.p.geo) { + const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.account.clientSettings.showMaps : true; + if (shouldShowMap) { + (this as any).os.getGoogleMaps().then(maps => { + const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]); + const map = new maps.Map(this.$refs.map, { + center: uluru, + zoom: 15 + }); + new maps.Marker({ + position: uluru, + map: map + }); + }); + } + } + }, + methods: { + fetchContext() { + this.contextFetching = true; + + // Fetch context + (this as any).api('posts/context', { + postId: this.p.replyId + }).then(context => { + this.contextFetching = false; + this.context = context.reverse(); + }); + }, + reply() { + (this as any).os.new(MkPostFormWindow, { + reply: this.p + }); + }, + repost() { + (this as any).os.new(MkRepostFormWindow, { + post: this.p + }); + }, + react() { + (this as any).os.new(MkReactionPicker, { + source: this.$refs.reactButton, + post: this.p + }); + }, + menu() { + (this as any).os.new(MkPostMenu, { + source: this.$refs.menuButton, + post: this.p + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-post-detail + margin 0 + padding 0 + overflow hidden + text-align left + background #fff + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 8px + + > .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 + + [data-fa] + 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 + + > .repost + margin 8px 0 + + > .mk-post-preview + padding 16px + border dashed 1px #c0dac6 + border-radius 8px + + > .location + margin 4px 0 + font-size 12px + color #ccc + + > .map + width 100% + height 300px + + &:empty + display none + + > .mk-url-preview + margin-top 8px + + > .tags + margin 4px 0 0 0 + + > * + display inline-block + margin 0 8px 0 0 + padding 2px 8px 2px 16px + font-size 90% + color #8d969e + background #edf0f3 + border-radius 4px + + &:before + content "" + display block + position absolute + top 0 + bottom 0 + left 4px + width 8px + height 8px + margin auto 0 + background #fff + border-radius 100% + + &:hover + text-decoration none + background #e2e7ec + + > 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 + + &.reacted + color $theme-color + + > .replies + > * + border-top 1px solid #eef0f2 + +</style> + +<style lang="stylus" module> +.text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.5em + color #717171 +</style> diff --git a/src/client/app/desktop/views/components/post-form-window.vue b/src/client/app/desktop/views/components/post-form-window.vue new file mode 100644 index 0000000000..d0b115e852 --- /dev/null +++ b/src/client/app/desktop/views/components/post-form-window.vue @@ -0,0 +1,76 @@ +<template> +<mk-window ref="window" is-modal @closed="$destroy"> + <span slot="header"> + <span :class="$style.icon" v-if="geo">%fa:map-marker-alt%</span> + <span v-if="!reply">%i18n:desktop.tags.mk-post-form-window.post%</span> + <span v-if="reply">%i18n:desktop.tags.mk-post-form-window.reply%</span> + <span :class="$style.count" v-if="media.length != 0">{{ '%i18n:desktop.tags.mk-post-form-window.attaches%'.replace('{}', media.length) }}</span> + <span :class="$style.count" v-if="uploadings.length != 0">{{ '%i18n:desktop.tags.mk-post-form-window.uploading-media%'.replace('{}', uploadings.length) }}<mk-ellipsis/></span> + </span> + + <mk-post-preview v-if="reply" :class="$style.postPreview" :post="reply"/> + <mk-post-form ref="form" + :reply="reply" + @posted="onPosted" + @change-uploadings="onChangeUploadings" + @change-attached-media="onChangeMedia" + @geo-attached="onGeoAttached" + @geo-dettached="onGeoDettached"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['reply'], + data() { + return { + uploadings: [], + media: [], + geo: null + }; + }, + mounted() { + this.$nextTick(() => { + (this.$refs.form as any).focus(); + }); + }, + methods: { + onChangeUploadings(files) { + this.uploadings = files; + }, + onChangeMedia(media) { + this.media = media; + }, + onGeoAttached(geo) { + this.geo = geo; + }, + onGeoDettached() { + this.geo = null; + }, + onPosted() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +.icon + margin-right 8px + +.count + margin-left 8px + opacity 0.8 + + &:before + content '(' + + &:after + content ')' + +.postPreview + margin 16px 22px + +</style> diff --git a/src/client/app/desktop/views/components/post-form.vue b/src/client/app/desktop/views/components/post-form.vue new file mode 100644 index 0000000000..1c83a38b64 --- /dev/null +++ b/src/client/app/desktop/views/components/post-form.vue @@ -0,0 +1,536 @@ +<template> +<div class="mk-post-form" + @dragover.stop="onDragover" + @dragenter="onDragenter" + @dragleave="onDragleave" + @drop.stop="onDrop" +> + <div class="content"> + <textarea :class="{ with: (files.length != 0 || poll) }" + ref="text" v-model="text" :disabled="posting" + @keydown="onKeydown" @paste="onPaste" :placeholder="placeholder" + v-autocomplete="'text'" + ></textarea> + <div class="medias" :class="{ with: poll }" v-show="files.length != 0"> + <x-draggable :list="files" :options="{ animation: 150 }"> + <div v-for="file in files" :key="file.id"> + <div class="img" :style="{ backgroundImage: `url(${file.url}?thumbnail&size=64)` }" :title="file.name"></div> + <img class="remove" @click="detachMedia(file.id)" src="/assets/desktop/remove.png" title="%i18n:desktop.tags.mk-post-form.attach-cancel%" alt=""/> + </div> + </x-draggable> + <p class="remain">{{ 4 - files.length }}/4</p> + </div> + <mk-poll-editor v-if="poll" ref="poll" @destroyed="poll = false" @updated="saveDraft()"/> + </div> + <mk-uploader ref="uploader" @uploaded="attachMedia" @change="onChangeUploadings"/> + <button class="upload" title="%i18n:desktop.tags.mk-post-form.attach-media-from-local%" @click="chooseFile">%fa:upload%</button> + <button class="drive" title="%i18n:desktop.tags.mk-post-form.attach-media-from-drive%" @click="chooseFileFromDrive">%fa:cloud%</button> + <button class="kao" title="%i18n:desktop.tags.mk-post-form.insert-a-kao%" @click="kao">%fa:R smile%</button> + <button class="poll" title="%i18n:desktop.tags.mk-post-form.create-poll%" @click="poll = true">%fa:chart-pie%</button> + <button class="geo" title="位置情報を添付する" @click="geo ? removeGeo() : setGeo()">%fa:map-marker-alt%</button> + <p class="text-count" :class="{ over: text.length > 1000 }">{{ '%i18n:desktop.tags.mk-post-form.text-remain%'.replace('{}', 1000 - text.length) }}</p> + <button :class="{ posting }" class="submit" :disabled="!canPost" @click="post"> + {{ posting ? '%i18n:desktop.tags.mk-post-form.posting%' : submitText }}<mk-ellipsis v-if="posting"/> + </button> + <input ref="file" type="file" accept="image/*" multiple="multiple" tabindex="-1" @change="onChangeFile"/> + <div class="dropzone" v-if="draghover"></div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as XDraggable from 'vuedraggable'; +import getKao from '../../../common/scripts/get-kao'; + +export default Vue.extend({ + components: { + XDraggable + }, + props: ['reply', 'repost'], + data() { + return { + posting: false, + text: '', + files: [], + uploadings: [], + poll: false, + geo: null, + autocomplete: null, + draghover: false + }; + }, + computed: { + draftId(): string { + return this.repost + ? 'repost:' + this.repost.id + : this.reply + ? 'reply:' + this.reply.id + : 'post'; + }, + placeholder(): string { + return this.repost + ? '%i18n:desktop.tags.mk-post-form.quote-placeholder%' + : this.reply + ? '%i18n:desktop.tags.mk-post-form.reply-placeholder%' + : '%i18n:desktop.tags.mk-post-form.post-placeholder%'; + }, + submitText(): string { + return this.repost + ? '%i18n:desktop.tags.mk-post-form.repost%' + : this.reply + ? '%i18n:desktop.tags.mk-post-form.reply%' + : '%i18n:desktop.tags.mk-post-form.post%'; + }, + canPost(): boolean { + return !this.posting && (this.text.length != 0 || this.files.length != 0 || this.poll || this.repost); + } + }, + watch: { + text() { + this.saveDraft(); + }, + poll() { + this.saveDraft(); + }, + files() { + this.saveDraft(); + } + }, + mounted() { + this.$nextTick(() => { + // 書きかけの投稿を復元 + const draft = JSON.parse(localStorage.getItem('drafts') || '{}')[this.draftId]; + if (draft) { + this.text = draft.data.text; + this.files = draft.data.files; + if (draft.data.poll) { + this.poll = true; + this.$nextTick(() => { + (this.$refs.poll as any).set(draft.data.poll); + }); + } + this.$emit('change-attached-media', this.files); + } + }); + }, + methods: { + focus() { + (this.$refs.text as any).focus(); + }, + chooseFile() { + (this.$refs.file as any).click(); + }, + chooseFileFromDrive() { + (this as any).apis.chooseDriveFile({ + multiple: true + }).then(files => { + files.forEach(this.attachMedia); + }); + }, + attachMedia(driveFile) { + this.files.push(driveFile); + this.$emit('change-attached-media', this.files); + }, + detachMedia(id) { + this.files = this.files.filter(x => x.id != id); + this.$emit('change-attached-media', this.files); + }, + onChangeFile() { + Array.from((this.$refs.file as any).files).forEach(this.upload); + }, + upload(file) { + (this.$refs.uploader as any).upload(file); + }, + onChangeUploadings(uploads) { + this.$emit('change-uploadings', uploads); + }, + clear() { + this.text = ''; + this.files = []; + this.poll = false; + this.$emit('change-attached-media', this.files); + }, + onKeydown(e) { + if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post(); + }, + onPaste(e) { + Array.from(e.clipboardData.items).forEach((item: any) => { + if (item.kind == 'file') { + this.upload(item.getAsFile()); + } + }); + }, + onDragover(e) { + const isFile = e.dataTransfer.items[0].kind == 'file'; + const isDriveFile = e.dataTransfer.types[0] == 'mk_drive_file'; + if (isFile || isDriveFile) { + e.preventDefault(); + this.draghover = true; + e.dataTransfer.dropEffect = e.dataTransfer.effectAllowed == 'all' ? 'copy' : 'move'; + } + }, + onDragenter(e) { + this.draghover = true; + }, + onDragleave(e) { + this.draghover = false; + }, + onDrop(e): void { + this.draghover = false; + + // ファイルだったら + if (e.dataTransfer.files.length > 0) { + e.preventDefault(); + Array.from(e.dataTransfer.files).forEach(this.upload); + return; + } + + //#region ドライブのファイル + const driveFile = e.dataTransfer.getData('mk_drive_file'); + if (driveFile != null && driveFile != '') { + const file = JSON.parse(driveFile); + this.files.push(file); + this.$emit('change-attached-media', this.files); + e.preventDefault(); + } + //#endregion + }, + setGeo() { + if (navigator.geolocation == null) { + alert('お使いの端末は位置情報に対応していません'); + return; + } + + navigator.geolocation.getCurrentPosition(pos => { + this.geo = pos.coords; + this.$emit('geo-attached', this.geo); + }, err => { + alert('エラー: ' + err.message); + }, { + enableHighAccuracy: true + }); + }, + removeGeo() { + this.geo = null; + this.$emit('geo-dettached'); + }, + post() { + this.posting = true; + + (this as any).api('posts/create', { + text: this.text == '' ? undefined : this.text, + mediaIds: this.files.length > 0 ? this.files.map(f => f.id) : undefined, + replyId: this.reply ? this.reply.id : undefined, + repostId: this.repost ? this.repost.id : undefined, + poll: this.poll ? (this.$refs.poll as any).get() : undefined, + geo: this.geo ? { + coordinates: [this.geo.longitude, this.geo.latitude], + altitude: this.geo.altitude, + accuracy: this.geo.accuracy, + altitudeAccuracy: this.geo.altitudeAccuracy, + heading: isNaN(this.geo.heading) ? null : this.geo.heading, + speed: this.geo.speed, + } : null + }).then(data => { + this.clear(); + this.deleteDraft(); + this.$emit('posted'); + (this as any).apis.notify(this.repost + ? '%i18n:desktop.tags.mk-post-form.reposted%' + : this.reply + ? '%i18n:desktop.tags.mk-post-form.replied%' + : '%i18n:desktop.tags.mk-post-form.posted%'); + }).catch(err => { + (this as any).apis.notify(this.repost + ? '%i18n:desktop.tags.mk-post-form.repost-failed%' + : this.reply + ? '%i18n:desktop.tags.mk-post-form.reply-failed%' + : '%i18n:desktop.tags.mk-post-form.post-failed%'); + }).then(() => { + this.posting = false; + }); + }, + saveDraft() { + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + data[this.draftId] = { + updatedAt: new Date(), + data: { + text: this.text, + files: this.files, + poll: this.poll && this.$refs.poll ? (this.$refs.poll as any).get() : undefined + } + } + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + deleteDraft() { + const data = JSON.parse(localStorage.getItem('drafts') || '{}'); + + delete data[this.draftId]; + + localStorage.setItem('drafts', JSON.stringify(data)); + }, + kao() { + this.text += getKao(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-post-form + display block + padding 16px + background lighten($theme-color, 95%) + + &:after + content "" + display block + clear both + + > .content + + textarea + 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 + + & + * + & + * + * + 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 + + & + * + & + * + * + 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) + + &.with + border-bottom solid 1px rgba($theme-color, 0.1) !important + border-radius 4px 4px 0 0 + + > .medias + 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 + + &.with + border-bottom solid 1px rgba($theme-color, 0.1) !important + border-radius 0 + + > .remain + display block + position absolute + top 8px + right 8px + margin 0 + padding 0 + color rgba($theme-color, 0.4) + + > div + padding 4px + + &:after + content "" + display block + clear both + + > div + float left + border solid 4px transparent + 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 + + > .mk-poll-editor + 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 + + > .mk-uploader + margin 8px 0 0 0 + padding 8px + border solid 1px rgba($theme-color, 0.2) + border-radius 4px + + input[type='file'] + display none + + .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 + + .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;} + + > .upload + > .drive + > .kao + > .poll + > .geo + 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 + +</style> diff --git a/src/client/app/desktop/views/components/post-preview.vue b/src/client/app/desktop/views/components/post-preview.vue new file mode 100644 index 0000000000..0ac3223be2 --- /dev/null +++ b/src/client/app/desktop/views/components/post-preview.vue @@ -0,0 +1,103 @@ +<template> +<div class="mk-post-preview" :title="title"> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="post.userId"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="post.userId">{{ post.user.name }}</router-link> + <span class="username">@{{ acct }}</span> + <router-link class="time" :to="`/@${acct}/${post.id}`"> + <mk-time :time="post.createdAt"/> + </router-link> + </header> + <div class="body"> + <mk-sub-post-content class="text" :post="post"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + }, + title(): string { + return dateStringify(this.post.createdAt); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-post-preview + font-size 0.9em + background #fff + + &: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 + display flex + white-space nowrap + + > .name + margin 0 .5em 0 0 + padding 0 + color #607073 + font-size 1em + font-weight bold + text-decoration none + white-space normal + + &:hover + text-decoration underline + + > .username + margin 0 .5em 0 0 + color #d1d8da + + > .time + margin-left auto + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + +</style> diff --git a/src/client/app/desktop/views/components/posts.post.sub.vue b/src/client/app/desktop/views/components/posts.post.sub.vue new file mode 100644 index 0000000000..65d3017d3d --- /dev/null +++ b/src/client/app/desktop/views/components/posts.post.sub.vue @@ -0,0 +1,112 @@ +<template> +<div class="sub" :title="title"> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="post.userId"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="post.userId">{{ post.user.name }}</router-link> + <span class="username">@{{ acct }}</span> + <router-link class="created-at" :to="`/@${acct}/${post.id}`"> + <mk-time :time="post.createdAt"/> + </router-link> + </header> + <div class="body"> + <mk-sub-post-content class="text" :post="post"/> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + }, + title(): string { + return dateStringify(this.post.createdAt); + } + } +}); +</script> + +<style lang="stylus" scoped> +.sub + margin 0 + padding 16px + font-size 0.9em + + &: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 + display flex + margin-bottom 2px + white-space nowrap + line-height 21px + + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + color #607073 + font-size 1em + font-weight bold + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .username + margin 0 .5em 0 0 + color #d1d8da + + > .created-at + margin-left auto + color #b2b8bb + + > .body + + > .text + cursor default + margin 0 + padding 0 + font-size 1.1em + color #717171 + + pre + max-height 120px + font-size 80% + +</style> diff --git a/src/client/app/desktop/views/components/posts.post.vue b/src/client/app/desktop/views/components/posts.post.vue new file mode 100644 index 0000000000..37c6e63043 --- /dev/null +++ b/src/client/app/desktop/views/components/posts.post.vue @@ -0,0 +1,582 @@ +<template> +<div class="post" tabindex="-1" :title="title" @keydown="onKeydown"> + <div class="reply-to" v-if="p.reply"> + <x-sub :post="p.reply"/> + </div> + <div class="repost" v-if="isRepost"> + <p> + <router-link class="avatar-anchor" :to="`/@${acct}`" v-user-preview="post.userId"> + <img class="avatar" :src="`${post.user.avatarUrl}?thumbnail&size=32`" alt="avatar"/> + </router-link> + %fa:retweet% + <span>{{ '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr(0, '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('{')) }}</span> + <a class="name" :href="`/@${acct}`" v-user-preview="post.userId">{{ post.user.name }}</a> + <span>{{ '%i18n:desktop.tags.mk-timeline-post.reposted-by%'.substr('%i18n:desktop.tags.mk-timeline-post.reposted-by%'.indexOf('}') + 1) }}</span> + </p> + <mk-time :time="post.createdAt"/> + </div> + <article> + <router-link class="avatar-anchor" :to="`/@${acct}`"> + <img class="avatar" :src="`${p.user.avatarUrl}?thumbnail&size=64`" alt="avatar" v-user-preview="p.user.id"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="p.user.id">{{ acct }}</router-link> + <span class="is-bot" v-if="p.user.host === null && p.user.account.isBot">bot</span> + <span class="username">@{{ acct }}</span> + <div class="info"> + <span class="app" v-if="p.app">via <b>{{ p.app.name }}</b></span> + <span class="mobile" v-if="p.viaMobile">%fa:mobile-alt%</span> + <router-link class="created-at" :to="url"> + <mk-time :time="p.createdAt"/> + </router-link> + </div> + </header> + <div class="body"> + <p class="channel" v-if="p.channel"> + <a :href="`${_CH_URL_}/${p.channel.id}`" target="_blank">{{ p.channel.title }}</a>: + </p> + <div class="text"> + <a class="reply" v-if="p.reply">%fa:reply%</a> + <mk-post-html v-if="p.ast" :ast="p.ast" :i="os.i" :class="$style.text"/> + <a class="rp" v-if="p.repost">RP:</a> + </div> + <div class="media" v-if="p.media"> + <mk-media-list :media-list="p.media"/> + </div> + <mk-poll v-if="p.poll" :post="p" ref="pollViewer"/> + <div class="tags" v-if="p.tags && p.tags.length > 0"> + <router-link v-for="tag in p.tags" :key="tag" :to="`/search?q=#${tag}`">{{ tag }}</router-link> + </div> + <a class="location" v-if="p.geo" :href="`http://maps.google.com/maps?q=${p.geo.coordinates[1]},${p.geo.coordinates[0]}`" target="_blank">%fa:map-marker-alt% 位置情報</a> + <div class="map" v-if="p.geo" ref="map"></div> + <div class="repost" v-if="p.repost"> + <mk-post-preview :post="p.repost"/> + </div> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + </div> + <footer> + <mk-reactions-viewer :post="p" ref="reactionsViewer"/> + <button @click="reply" title="%i18n:desktop.tags.mk-timeline-post.reply%"> + %fa:reply%<p class="count" v-if="p.repliesCount > 0">{{ p.repliesCount }}</p> + </button> + <button @click="repost" title="%i18n:desktop.tags.mk-timeline-post.repost%"> + %fa:retweet%<p class="count" v-if="p.repostCount > 0">{{ p.repostCount }}</p> + </button> + <button :class="{ reacted: p.myReaction != null }" @click="react" ref="reactButton" title="%i18n:desktop.tags.mk-timeline-post.add-reaction%"> + %fa:plus%<p class="count" v-if="p.reactions_count > 0">{{ p.reactions_count }}</p> + </button> + <button @click="menu" ref="menuButton"> + %fa:ellipsis-h% + </button> + <button title="%i18n:desktop.tags.mk-timeline-post.detail"> + <template v-if="!isDetailOpened">%fa:caret-down%</template> + <template v-if="isDetailOpened">%fa:caret-up%</template> + </button> + </footer> + </div> + </article> + <div class="detail" v-if="isDetailOpened"> + <mk-post-status-graph width="462" height="130" :post="p"/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import dateStringify from '../../../common/scripts/date-stringify'; +import getAcct from '../../../../../common/user/get-acct'; +import MkPostFormWindow from './post-form-window.vue'; +import MkRepostFormWindow from './repost-form-window.vue'; +import MkPostMenu from '../../../common/views/components/post-menu.vue'; +import MkReactionPicker from '../../../common/views/components/reaction-picker.vue'; +import XSub from './posts.post.sub.vue'; + +function focus(el, fn) { + const target = fn(el); + if (target) { + if (target.hasAttribute('tabindex')) { + target.focus(); + } else { + focus(target, fn); + } + } +} + +export default Vue.extend({ + components: { + XSub + }, + props: ['post'], + data() { + return { + isDetailOpened: false, + connection: null, + connectionId: null + }; + }, + computed: { + acct() { + return getAcct(this.p.user); + }, + isRepost(): boolean { + return (this.post.repost && + this.post.text == null && + this.post.mediaIds == null && + this.post.poll == null); + }, + p(): any { + return this.isRepost ? this.post.repost : this.post; + }, + reactionsCount(): number { + return this.p.reactionCounts + ? Object.keys(this.p.reactionCounts) + .map(key => this.p.reactionCounts[key]) + .reduce((a, b) => a + b) + : 0; + }, + title(): string { + return dateStringify(this.p.createdAt); + }, + url(): string { + return `/@${this.acct}/${this.p.id}`; + }, + urls(): string[] { + if (this.p.ast) { + return this.p.ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + }, + created() { + if ((this as any).os.isSignedIn) { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + } + }, + mounted() { + this.capture(true); + + if ((this as any).os.isSignedIn) { + this.connection.on('_connected_', this.onStreamConnected); + } + + // Draw map + if (this.p.geo) { + const shouldShowMap = (this as any).os.isSignedIn ? (this as any).os.i.account.clientSettings.showMaps : true; + if (shouldShowMap) { + (this as any).os.getGoogleMaps().then(maps => { + const uluru = new maps.LatLng(this.p.geo.coordinates[1], this.p.geo.coordinates[0]); + const map = new maps.Map(this.$refs.map, { + center: uluru, + zoom: 15 + }); + new maps.Marker({ + position: uluru, + map: map + }); + }); + } + } + }, + beforeDestroy() { + this.decapture(true); + + if ((this as any).os.isSignedIn) { + this.connection.off('_connected_', this.onStreamConnected); + (this as any).os.stream.dispose(this.connectionId); + } + }, + methods: { + capture(withHandler = false) { + if ((this as any).os.isSignedIn) { + this.connection.send({ + type: 'capture', + id: this.p.id + }); + if (withHandler) this.connection.on('post-updated', this.onStreamPostUpdated); + } + }, + decapture(withHandler = false) { + if ((this as any).os.isSignedIn) { + this.connection.send({ + type: 'decapture', + id: this.p.id + }); + if (withHandler) this.connection.off('post-updated', this.onStreamPostUpdated); + } + }, + onStreamConnected() { + this.capture(); + }, + onStreamPostUpdated(data) { + const post = data.post; + if (post.id == this.post.id) { + this.$emit('update:post', post); + } else if (post.id == this.post.repostId) { + this.post.repost = post; + } + }, + reply() { + (this as any).os.new(MkPostFormWindow, { + reply: this.p + }); + }, + repost() { + (this as any).os.new(MkRepostFormWindow, { + post: this.p + }); + }, + react() { + (this as any).os.new(MkReactionPicker, { + source: this.$refs.reactButton, + post: this.p + }); + }, + menu() { + (this as any).os.new(MkPostMenu, { + source: this.$refs.menuButton, + post: this.p + }); + }, + onKeydown(e) { + let shouldBeCancel = true; + + switch (true) { + case e.which == 38: // [↑] + case e.which == 74: // [j] + case e.which == 9 && e.shiftKey: // [Shift] + [Tab] + focus(this.$el, e => e.previousElementSibling); + break; + + case e.which == 40: // [↓] + case e.which == 75: // [k] + case e.which == 9: // [Tab] + focus(this.$el, e => e.nextElementSibling); + break; + + case e.which == 81: // [q] + case e.which == 69: // [e] + this.repost(); + break; + + case e.which == 70: // [f] + case e.which == 76: // [l] + //this.like(); + break; + + case e.which == 82: // [r] + this.reply(); + break; + + default: + shouldBeCancel = false; + } + + if (shouldBeCancel) e.preventDefault(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.post + margin 0 + padding 0 + background #fff + border-bottom solid 1px #eaeaea + + &:first-child + border-top-left-radius 6px + border-top-right-radius 6px + + > .repost + border-top-left-radius 6px + border-top-right-radius 6px + + &:last-of-type + border-bottom none + + &: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 + + [data-fa] + 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 10px 0 + //position -webkit-sticky + //position sticky + //top 74px + + > .avatar + display block + width 58px + height 58px + margin 0 + border-radius 8px + vertical-align bottom + + > .main + float left + width calc(100% - 74px) + + > header + display flex + align-items center + margin-bottom 4px + white-space nowrap + + > .name + display block + margin 0 .5em 0 0 + padding 0 + overflow hidden + color #627079 + font-size 1em + font-weight bold + text-decoration none + text-overflow ellipsis + + &:hover + text-decoration underline + + > .is-bot + margin 0 .5em 0 0 + padding 1px 6px + font-size 12px + color #aaa + border solid 1px #ddd + border-radius 3px + + > .username + margin 0 .5em 0 0 + color #ccc + + > .info + margin-left auto + font-size 0.9em + + > .mobile + margin-right 8px + color #ccc + + > .app + margin-right 8px + padding-right 8px + color #ccc + border-right solid 1px #eaeaea + + > .created-at + color #c0c0c0 + + > .body + + > .text + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.1em + color #717171 + + >>> .quote + margin 8px + padding 6px 12px + color #aaa + border-left solid 3px #eee + + > .reply + margin-right 8px + color #717171 + + > .rp + margin-left 4px + font-style oblique + color #a0bf46 + + > .location + margin 4px 0 + font-size 12px + color #ccc + + > .map + width 100% + height 300px + + &:empty + display none + + > .tags + margin 4px 0 0 0 + + > * + display inline-block + margin 0 8px 0 0 + padding 2px 8px 2px 16px + font-size 90% + color #8d969e + background #edf0f3 + border-radius 4px + + &:before + content "" + display block + position absolute + top 0 + bottom 0 + left 4px + width 8px + height 8px + margin auto 0 + background #fff + border-radius 100% + + &:hover + text-decoration none + background #e2e7ec + + .mk-url-preview + margin-top 8px + + > .channel + margin 0 + + > .mk-poll + font-size 80% + + > .repost + margin 8px 0 + + > .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 + + &.reacted + color $theme-color + + &:last-child + position absolute + right 0 + margin 0 + + > .detail + padding-top 4px + background rgba(0, 0, 0, 0.0125) + +</style> + +<style lang="stylus" module> +.text + + code + padding 4px 8px + margin 0 0.5em + font-size 80% + color #525252 + background #f8f8f8 + border-radius 2px + + pre > code + padding 16px + margin 0 + + [data-is-me]:after + content "you" + padding 0 4px + margin-left 4px + font-size 80% + color $theme-color-foreground + background $theme-color + border-radius 4px +</style> diff --git a/src/client/app/desktop/views/components/posts.vue b/src/client/app/desktop/views/components/posts.vue new file mode 100644 index 0000000000..5031667c7c --- /dev/null +++ b/src/client/app/desktop/views/components/posts.vue @@ -0,0 +1,89 @@ +<template> +<div class="mk-posts"> + <template v-for="(post, i) in _posts"> + <x-post :post="post" :key="post.id" @update:post="onPostUpdated(i, $event)"/> + <p class="date" v-if="i != posts.length - 1 && post._date != _posts[i + 1]._date"> + <span>%fa:angle-up%{{ post._datetext }}</span> + <span>%fa:angle-down%{{ _posts[i + 1]._datetext }}</span> + </p> + </template> + <footer> + <slot name="footer"></slot> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XPost from './posts.post.vue'; + +export default Vue.extend({ + components: { + XPost + }, + props: { + posts: { + type: Array, + default: () => [] + } + }, + computed: { + _posts(): any[] { + return (this.posts as any).map(post => { + const date = new Date(post.createdAt).getDate(); + const month = new Date(post.createdAt).getMonth() + 1; + post._date = date; + post._datetext = `${month}月 ${date}日`; + return post; + }); + } + }, + methods: { + focus() { + (this.$el as any).children[0].focus(); + }, + onPostUpdated(i, post) { + Vue.set((this as any).posts, i, post); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-posts + + > .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 + + [data-fa] + margin-right 8px + + > footer + > * + display block + margin 0 + padding 16px + width 100% + text-align center + color #ccc + border-top solid 1px #eaeaea + border-bottom-left-radius 4px + border-bottom-right-radius 4px + + > button + &:hover + background #f5f5f5 + + &:active + background #eee +</style> diff --git a/src/client/app/desktop/views/components/progress-dialog.vue b/src/client/app/desktop/views/components/progress-dialog.vue new file mode 100644 index 0000000000..a4292e1aec --- /dev/null +++ b/src/client/app/desktop/views/components/progress-dialog.vue @@ -0,0 +1,95 @@ +<template> +<mk-window ref="window" :is-modal="false" :can-close="false" width="500px" @closed="$destroy"> + <span slot="header">{{ title }}<mk-ellipsis/></span> + <div :class="$style.body"> + <p :class="$style.init" v-if="isNaN(value)">待機中<mk-ellipsis/></p> + <p :class="$style.percentage" v-if="!isNaN(value)">{{ Math.floor((value / max) * 100) }}</p> + <progress :class="$style.progress" + v-if="!isNaN(value) && value < max" + :value="isNaN(value) ? 0 : value" + :max="max" + ></progress> + <div :class="[$style.progress, $style.waiting]" v-if="value >= max"></div> + </div> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['title', 'initValue', 'initMax'], + data() { + return { + value: this.initValue, + max: this.initMax + }; + }, + methods: { + update(value, max) { + this.value = parseInt(value, 10); + this.max = parseInt(max, 10); + }, + close() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +@import '~const.styl' + +.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 + 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) + +.waiting + 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;} + +</style> diff --git a/src/client/app/desktop/views/components/repost-form-window.vue b/src/client/app/desktop/views/components/repost-form-window.vue new file mode 100644 index 0000000000..7db5adbff3 --- /dev/null +++ b/src/client/app/desktop/views/components/repost-form-window.vue @@ -0,0 +1,42 @@ +<template> +<mk-window ref="window" is-modal @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:retweet%%i18n:desktop.tags.mk-repost-form-window.title%</span> + <mk-repost-form ref="form" :post="post" @posted="onPosted" @canceled="onCanceled"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['post'], + mounted() { + document.addEventListener('keydown', this.onDocumentKeydown); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onDocumentKeydown); + }, + methods: { + onDocumentKeydown(e) { + if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { + if (e.which == 27) { // Esc + (this.$refs.window as any).close(); + } + } + }, + onPosted() { + (this.$refs.window as any).close(); + }, + onCanceled() { + (this.$refs.window as any).close(); + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/components/repost-form.vue b/src/client/app/desktop/views/components/repost-form.vue new file mode 100644 index 0000000000..3a5e3a7c56 --- /dev/null +++ b/src/client/app/desktop/views/components/repost-form.vue @@ -0,0 +1,131 @@ +<template> +<div class="mk-repost-form"> + <mk-post-preview :post="post"/> + <template v-if="!quote"> + <footer> + <a class="quote" v-if="!quote" @click="onQuote">%i18n:desktop.tags.mk-repost-form.quote%</a> + <button class="cancel" @click="cancel">%i18n:desktop.tags.mk-repost-form.cancel%</button> + <button class="ok" @click="ok" :disabled="wait">{{ wait ? '%i18n:desktop.tags.mk-repost-form.reposting%' : '%i18n:desktop.tags.mk-repost-form.repost%' }}</button> + </footer> + </template> + <template v-if="quote"> + <mk-post-form ref="form" :repost="post" @posted="onChildFormPosted"/> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['post'], + data() { + return { + wait: false, + quote: false + }; + }, + methods: { + ok() { + this.wait = true; + (this as any).api('posts/create', { + repostId: this.post.id + }).then(data => { + this.$emit('posted'); + (this as any).apis.notify('%i18n:desktop.tags.mk-repost-form.success%'); + }).catch(err => { + (this as any).apis.notify('%i18n:desktop.tags.mk-repost-form.failure%'); + }).then(() => { + this.wait = false; + }); + }, + cancel() { + this.$emit('canceled'); + }, + onQuote() { + this.quote = true; + + this.$nextTick(() => { + (this.$refs.form as any).focus(); + }); + }, + onChildFormPosted() { + this.$emit('posted'); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-repost-form + + > .mk-post-preview + margin 16px 22px + + > 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 + +</style> diff --git a/src/client/app/desktop/views/components/settings-window.vue b/src/client/app/desktop/views/components/settings-window.vue new file mode 100644 index 0000000000..d5be177dcc --- /dev/null +++ b/src/client/app/desktop/views/components/settings-window.vue @@ -0,0 +1,24 @@ +<template> +<mk-window ref="window" is-modal width="700px" height="550px" @closed="$destroy"> + <span slot="header" :class="$style.header">%fa:cog%設定</span> + <mk-settings @done="close"/> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + methods: { + close() { + (this as any).$refs.window.close(); + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/components/settings.2fa.vue b/src/client/app/desktop/views/components/settings.2fa.vue new file mode 100644 index 0000000000..b8dd1dfd9b --- /dev/null +++ b/src/client/app/desktop/views/components/settings.2fa.vue @@ -0,0 +1,80 @@ +<template> +<div class="2fa"> + <p>%i18n:desktop.tags.mk-2fa-setting.intro%<a href="%i18n:desktop.tags.mk-2fa-setting.url%" target="_blank">%i18n:desktop.tags.mk-2fa-setting.detail%</a></p> + <div class="ui info warn"><p>%fa:exclamation-triangle%%i18n:desktop.tags.mk-2fa-setting.caution%</p></div> + <p v-if="!data && !os.i.account.twoFactorEnabled"><button @click="register" class="ui primary">%i18n:desktop.tags.mk-2fa-setting.register%</button></p> + <template v-if="os.i.account.twoFactorEnabled"> + <p>%i18n:desktop.tags.mk-2fa-setting.already-registered%</p> + <button @click="unregister" class="ui">%i18n:desktop.tags.mk-2fa-setting.unregister%</button> + </template> + <div v-if="data"> + <ol> + <li>%i18n:desktop.tags.mk-2fa-setting.authenticator% <a href="https://support.google.com/accounts/answer/1066447" target="_blank">%i18n:desktop.tags.mk-2fa-setting.howtoinstall%</a></li> + <li>%i18n:desktop.tags.mk-2fa-setting.scan%<br><img :src="data.qr"></li> + <li>%i18n:desktop.tags.mk-2fa-setting.done%<br> + <input type="number" v-model="token" class="ui"> + <button @click="submit" class="ui primary">%i18n:desktop.tags.mk-2fa-setting.submit%</button> + </li> + </ol> + <div class="ui info"><p>%fa:info-circle%%i18n:desktop.tags.mk-2fa-setting.info%</p></div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + data: null, + token: null + }; + }, + methods: { + register() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-2fa-setting.enter-password%', + type: 'password' + }).then(password => { + (this as any).api('i/2fa/register', { + password: password + }).then(data => { + this.data = data; + }); + }); + }, + + unregister() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-2fa-setting.enter-password%', + type: 'password' + }).then(password => { + (this as any).api('i/2fa/unregister', { + password: password + }).then(() => { + (this as any).apis.notify('%i18n:desktop.tags.mk-2fa-setting.unregistered%'); + (this as any).os.i.account.twoFactorEnabled = false; + }); + }); + }, + + submit() { + (this as any).api('i/2fa/done', { + token: this.token + }).then(() => { + (this as any).apis.notify('%i18n:desktop.tags.mk-2fa-setting.success%'); + (this as any).os.i.account.twoFactorEnabled = true; + }).catch(() => { + (this as any).apis.notify('%i18n:desktop.tags.mk-2fa-setting.failed%'); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.2fa + color #4a535a + +</style> diff --git a/src/client/app/desktop/views/components/settings.api.vue b/src/client/app/desktop/views/components/settings.api.vue new file mode 100644 index 0000000000..0d5921ab7f --- /dev/null +++ b/src/client/app/desktop/views/components/settings.api.vue @@ -0,0 +1,40 @@ +<template> +<div class="root api"> + <p>Token: <code>{{ os.i.account.token }}</code></p> + <p>%i18n:desktop.tags.mk-api-info.intro%</p> + <div class="ui info warn"><p>%fa:exclamation-triangle%%i18n:desktop.tags.mk-api-info.caution%</p></div> + <p>%i18n:desktop.tags.mk-api-info.regeneration-of-token%</p> + <button class="ui" @click="regenerateToken">%i18n:desktop.tags.mk-api-info.regenerate-token%</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + methods: { + regenerateToken() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-api-info.enter-password%', + type: 'password' + }).then(password => { + (this as any).api('i/regenerate_token', { + password: password + }); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.root.api + color #4a535a + + code + display inline-block + padding 4px 6px + color #555 + background #eee + border-radius 2px +</style> diff --git a/src/client/app/desktop/views/components/settings.apps.vue b/src/client/app/desktop/views/components/settings.apps.vue new file mode 100644 index 0000000000..0503b03abd --- /dev/null +++ b/src/client/app/desktop/views/components/settings.apps.vue @@ -0,0 +1,39 @@ +<template> +<div class="root"> + <div class="none ui info" v-if="!fetching && apps.length == 0"> + <p>%fa:info-circle%%i18n:common.tags.mk-authorized-apps.no-apps%</p> + </div> + <div class="apps" v-if="apps.length != 0"> + <div v-for="app in apps"> + <p><b>{{ app.name }}</b></p> + <p>{{ app.description }}</p> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + apps: [] + }; + }, + mounted() { + (this as any).api('i/authorized_apps').then(apps => { + this.apps = apps; + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root + > .apps + > div + padding 16px 0 0 0 + border-bottom solid 1px #eee +</style> diff --git a/src/client/app/desktop/views/components/settings.drive.vue b/src/client/app/desktop/views/components/settings.drive.vue new file mode 100644 index 0000000000..8bb0c760a7 --- /dev/null +++ b/src/client/app/desktop/views/components/settings.drive.vue @@ -0,0 +1,35 @@ +<template> +<div class="root"> + <template v-if="!fetching"> + <el-progress :text-inside="true" :stroke-width="18" :percentage="Math.floor((usage / capacity) * 100)"/> + <p><b>{{ capacity | bytes }}</b>中<b>{{ usage | bytes }}</b>使用中</p> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + usage: null, + capacity: null + }; + }, + mounted() { + (this as any).api('drive').then(info => { + this.capacity = info.capacity; + this.usage = info.usage; + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.root + > p + > b + margin 0 8px +</style> diff --git a/src/client/app/desktop/views/components/settings.mute.vue b/src/client/app/desktop/views/components/settings.mute.vue new file mode 100644 index 0000000000..a8dfe10604 --- /dev/null +++ b/src/client/app/desktop/views/components/settings.mute.vue @@ -0,0 +1,35 @@ +<template> +<div> + <div class="none ui info" v-if="!fetching && users.length == 0"> + <p>%fa:info-circle%%i18n:desktop.tags.mk-mute-setting.no-users%</p> + </div> + <div class="users" v-if="users.length != 0"> + <div v-for="user in users" :key="user.id"> + <p><b>{{ user.name }}</b> @{{ getAcct(user) }}</p> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + data() { + return { + fetching: true, + users: [] + }; + }, + methods: { + getAcct + }, + mounted() { + (this as any).api('mute/list').then(x => { + this.users = x.users; + this.fetching = false; + }); + } +}); +</script> diff --git a/src/client/app/desktop/views/components/settings.password.vue b/src/client/app/desktop/views/components/settings.password.vue new file mode 100644 index 0000000000..f883b54065 --- /dev/null +++ b/src/client/app/desktop/views/components/settings.password.vue @@ -0,0 +1,47 @@ +<template> +<div> + <button @click="reset" class="ui primary">%i18n:desktop.tags.mk-password-setting.reset%</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + methods: { + reset() { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-password-setting.enter-current-password%', + type: 'password' + }).then(currentPassword => { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-password-setting.enter-new-password%', + type: 'password' + }).then(newPassword => { + (this as any).apis.input({ + title: '%i18n:desktop.tags.mk-password-setting.enter-new-password-again%', + type: 'password' + }).then(newPassword2 => { + if (newPassword !== newPassword2) { + (this as any).apis.dialog({ + title: null, + text: '%i18n:desktop.tags.mk-password-setting.not-match%', + actions: [{ + text: 'OK' + }] + }); + return; + } + (this as any).api('i/change_password', { + currentPasword: currentPassword, + newPassword: newPassword + }).then(() => { + (this as any).apis.notify('%i18n:desktop.tags.mk-password-setting.changed%'); + }); + }); + }); + }); + } + } +}); +</script> diff --git a/src/client/app/desktop/views/components/settings.profile.vue b/src/client/app/desktop/views/components/settings.profile.vue new file mode 100644 index 0000000000..ba86286f87 --- /dev/null +++ b/src/client/app/desktop/views/components/settings.profile.vue @@ -0,0 +1,87 @@ +<template> +<div class="profile"> + <label class="avatar ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.avatar%</p> + <img class="avatar" :src="`${os.i.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + <button class="ui" @click="updateAvatar">%i18n:desktop.tags.mk-profile-setting.choice-avatar%</button> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.name%</p> + <input v-model="name" type="text" class="ui"/> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.location%</p> + <input v-model="location" type="text" class="ui"/> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.description%</p> + <textarea v-model="description" class="ui"></textarea> + </label> + <label class="ui from group"> + <p>%i18n:desktop.tags.mk-profile-setting.birthday%</p> + <el-date-picker v-model="birthday" type="date" value-format="yyyy-MM-dd"/> + </label> + <button class="ui primary" @click="save">%i18n:desktop.tags.mk-profile-setting.save%</button> + <section> + <h2>その他</h2> + <mk-switch v-model="os.i.account.isBot" @change="onChangeIsBot" text="このアカウントはbotです"/> + </section> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + name: null, + location: null, + description: null, + birthday: null, + }; + }, + created() { + this.name = (this as any).os.i.name; + this.location = (this as any).os.i.account.profile.location; + this.description = (this as any).os.i.description; + this.birthday = (this as any).os.i.account.profile.birthday; + }, + methods: { + updateAvatar() { + (this as any).apis.updateAvatar(); + }, + save() { + (this as any).api('i/update', { + name: this.name, + location: this.location || null, + description: this.description || null, + birthday: this.birthday || null + }).then(() => { + (this as any).apis.notify('プロフィールを更新しました'); + }); + }, + onChangeIsBot() { + (this as any).api('i/update', { + isBot: (this as any).os.i.account.isBot + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.profile + > .avatar + > img + display inline-block + vertical-align top + width 64px + height 64px + border-radius 4px + + > button + margin-left 8px + +</style> + diff --git a/src/client/app/desktop/views/components/settings.signins.vue b/src/client/app/desktop/views/components/settings.signins.vue new file mode 100644 index 0000000000..a414c95c27 --- /dev/null +++ b/src/client/app/desktop/views/components/settings.signins.vue @@ -0,0 +1,98 @@ +<template> +<div class="root"> +<div class="signins" v-if="signins.length != 0"> + <div v-for="signin in signins"> + <header @click="signin._show = !signin._show"> + <template v-if="signin.success">%fa:check%</template> + <template v-else>%fa:times%</template> + <span class="ip">{{ signin.ip }}</span> + <mk-time :time="signin.createdAt"/> + </header> + <div class="headers" v-show="signin._show"> + <tree-view :data="signin.headers"/> + </div> + </div> +</div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + fetching: true, + signins: [], + connection: null, + connectionId: null + }; + }, + mounted() { + (this as any).api('i/signin_history').then(signins => { + this.signins = signins; + this.fetching = false; + }); + + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('signin', this.onSignin); + }, + beforeDestroy() { + this.connection.off('signin', this.onSignin); + (this as any).os.stream.dispose(this.connectionId); + }, + methods: { + onSignin(signin) { + this.signins.unshift(signin); + } + } +}); +</script> + +<style lang="stylus" scoped> +.root + > .signins + > div + border-bottom solid 1px #eee + + > header + display flex + padding 8px 0 + line-height 32px + cursor pointer + + > [data-fa] + margin-right 8px + text-align left + + &.check + color #0fda82 + + &.times + color #ff3100 + + > .ip + display inline-block + text-align left + padding 8px + line-height 16px + font-family monospace + font-size 14px + color #444 + background #f8f8f8 + border-radius 4px + + > .mk-time + margin-left auto + text-align right + color #777 + + > .headers + overflow auto + margin 0 0 16px 0 + max-height 100px + white-space pre-wrap + word-break break-all + +</style> diff --git a/src/client/app/desktop/views/components/settings.vue b/src/client/app/desktop/views/components/settings.vue new file mode 100644 index 0000000000..fd82c171c1 --- /dev/null +++ b/src/client/app/desktop/views/components/settings.vue @@ -0,0 +1,419 @@ +<template> +<div class="mk-settings"> + <div class="nav"> + <p :class="{ active: page == 'profile' }" @mousedown="page = 'profile'">%fa:user .fw%%i18n:desktop.tags.mk-settings.profile%</p> + <p :class="{ active: page == 'web' }" @mousedown="page = 'web'">%fa:desktop .fw%Web</p> + <p :class="{ active: page == 'notification' }" @mousedown="page = 'notification'">%fa:R bell .fw%通知</p> + <p :class="{ active: page == 'drive' }" @mousedown="page = 'drive'">%fa:cloud .fw%%i18n:desktop.tags.mk-settings.drive%</p> + <p :class="{ active: page == 'mute' }" @mousedown="page = 'mute'">%fa:ban .fw%%i18n:desktop.tags.mk-settings.mute%</p> + <p :class="{ active: page == 'apps' }" @mousedown="page = 'apps'">%fa:puzzle-piece .fw%アプリ</p> + <p :class="{ active: page == 'twitter' }" @mousedown="page = 'twitter'">%fa:B twitter .fw%Twitter</p> + <p :class="{ active: page == 'security' }" @mousedown="page = 'security'">%fa:unlock-alt .fw%%i18n:desktop.tags.mk-settings.security%</p> + <p :class="{ active: page == 'api' }" @mousedown="page = 'api'">%fa:key .fw%API</p> + <p :class="{ active: page == 'other' }" @mousedown="page = 'other'">%fa:cogs .fw%%i18n:desktop.tags.mk-settings.other%</p> + </div> + <div class="pages"> + <section class="profile" v-show="page == 'profile'"> + <h1>%i18n:desktop.tags.mk-settings.profile%</h1> + <x-profile/> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>動作</h1> + <mk-switch v-model="os.i.account.clientSettings.fetchOnScroll" @change="onChangeFetchOnScroll" text="スクロールで自動読み込み"> + <span>ページを下までスクロールしたときに自動で追加のコンテンツを読み込みます。</span> + </mk-switch> + <mk-switch v-model="autoPopout" text="ウィンドウの自動ポップアウト"> + <span>ウィンドウが開かれるとき、ポップアウト(ブラウザ外に切り離す)可能なら自動でポップアウトします。この設定はブラウザに記憶されます。</span> + </mk-switch> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>デザインと表示</h1> + <div class="div"> + <button class="ui button" @click="customizeHome">ホームをカスタマイズ</button> + </div> + <mk-switch v-model="os.i.account.clientSettings.showPostFormOnTopOfTl" @change="onChangeShowPostFormOnTopOfTl" text="タイムライン上部に投稿フォームを表示する"/> + <mk-switch v-model="os.i.account.clientSettings.showMaps" @change="onChangeShowMaps" text="マップの自動展開"> + <span>位置情報が添付された投稿のマップを自動的に展開します。</span> + </mk-switch> + <mk-switch v-model="os.i.account.clientSettings.gradientWindowHeader" @change="onChangeGradientWindowHeader" text="ウィンドウのタイトルバーにグラデーションを使用"/> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>サウンド</h1> + <mk-switch v-model="enableSounds" text="サウンドを有効にする"> + <span>投稿やメッセージを送受信したときなどにサウンドを再生します。この設定はブラウザに記憶されます。</span> + </mk-switch> + <label>ボリューム</label> + <el-slider + v-model="soundVolume" + :show-input="true" + :format-tooltip="v => `${v}%`" + :disabled="!enableSounds" + /> + <button class="ui button" @click="soundTest">%fa:volume-up% テスト</button> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>モバイル</h1> + <mk-switch v-model="os.i.account.clientSettings.disableViaMobile" @change="onChangeDisableViaMobile" text="「モバイルからの投稿」フラグを付けない"/> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>言語</h1> + <el-select v-model="lang" placeholder="言語を選択"> + <el-option-group label="推奨"> + <el-option label="自動" value=""/> + </el-option-group> + <el-option-group label="言語を指定"> + <el-option label="ja-JP" value="ja"/> + <el-option label="en-US" value="en"/> + </el-option-group> + </el-select> + <div class="none ui info"> + <p>%fa:info-circle%変更はページの再度読み込み後に反映されます。</p> + </div> + </section> + + <section class="web" v-show="page == 'web'"> + <h1>キャッシュ</h1> + <button class="ui button" @click="clean">クリーンアップ</button> + <div class="none ui info warn"> + <p>%fa:exclamation-triangle%クリーンアップを行うと、ブラウザに記憶されたアカウント情報のキャッシュ、書きかけの投稿・返信・メッセージ、およびその他のデータ(設定情報含む)が削除されます。クリーンアップを行った後はページを再度読み込みする必要があります。</p> + </div> + </section> + + <section class="notification" v-show="page == 'notification'"> + <h1>通知</h1> + <mk-switch v-model="os.i.account.settings.autoWatch" @change="onChangeAutoWatch" text="投稿の自動ウォッチ"> + <span>リアクションしたり返信したりした投稿に関する通知を自動的に受け取るようにします。</span> + </mk-switch> + </section> + + <section class="drive" v-show="page == 'drive'"> + <h1>%i18n:desktop.tags.mk-settings.drive%</h1> + <x-drive/> + </section> + + <section class="mute" v-show="page == 'mute'"> + <h1>%i18n:desktop.tags.mk-settings.mute%</h1> + <x-mute/> + </section> + + <section class="apps" v-show="page == 'apps'"> + <h1>アプリケーション</h1> + <x-apps/> + </section> + + <section class="twitter" v-show="page == 'twitter'"> + <h1>Twitter</h1> + <mk-twitter-setting/> + </section> + + <section class="password" v-show="page == 'security'"> + <h1>%i18n:desktop.tags.mk-settings.password%</h1> + <x-password/> + </section> + + <section class="2fa" v-show="page == 'security'"> + <h1>%i18n:desktop.tags.mk-settings.2fa%</h1> + <x-2fa/> + </section> + + <section class="signin" v-show="page == 'security'"> + <h1>サインイン履歴</h1> + <x-signins/> + </section> + + <section class="api" v-show="page == 'api'"> + <h1>API</h1> + <x-api/> + </section> + + <section class="other" v-show="page == 'other'"> + <h1>Misskeyについて</h1> + <p v-if="meta">このサーバーの運営者: <i><a :href="meta.maintainer.url" target="_blank">{{ meta.maintainer.name }}</a></i></p> + </section> + + <section class="other" v-show="page == 'other'"> + <h1>Misskey Update</h1> + <p> + <span>バージョン: <i>{{ version }}</i></span> + <template v-if="latestVersion !== undefined"> + <br> + <span>最新のバージョン: <i>{{ latestVersion ? latestVersion : version }}</i></span> + </template> + </p> + <button class="ui button block" @click="checkForUpdate" :disabled="checkingForUpdate"> + <template v-if="checkingForUpdate">アップデートを確認中<mk-ellipsis/></template> + <template v-else>アップデートを確認</template> + </button> + <details> + <summary>詳細設定</summary> + <mk-switch v-model="preventUpdate" text="アップデートを延期する(非推奨)"> + <span>この設定をオンにしてもアップデートが反映される場合があります。この設定はこのデバイスのみ有効です。</span> + </mk-switch> + </details> + </section> + + <section class="other" v-show="page == 'other'"> + <h1>高度な設定</h1> + <mk-switch v-model="debug" text="デバッグモードを有効にする"> + <span>この設定はブラウザに記憶されます。</span> + </mk-switch> + <template v-if="debug"> + <mk-switch v-model="useRawScript" text="生のスクリプトを読み込む"> + <span>圧縮されていない「生の」スクリプトを使用します。サイズが大きいため、読み込みに時間がかかる場合があります。この設定はブラウザに記憶されます。</span> + </mk-switch> + <div class="none ui info"> + <p>%fa:info-circle%Misskeyはソースマップも提供しています。</p> + </div> + </template> + <mk-switch v-model="enableExperimental" text="実験的機能を有効にする"> + <span>実験的機能を有効にするとMisskeyの動作が不安定になる可能性があります。この設定はブラウザに記憶されます。</span> + </mk-switch> + <details v-if="debug"> + <summary>ツール</summary> + <button class="ui button block" @click="taskmngr">タスクマネージャ</button> + </details> + </section> + + <section class="other" v-show="page == 'other'"> + <h1>%i18n:desktop.tags.mk-settings.license%</h1> + <div v-html="license"></div> + <a :href="licenseUrl" target="_blank">サードパーティ</a> + </section> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XProfile from './settings.profile.vue'; +import XMute from './settings.mute.vue'; +import XPassword from './settings.password.vue'; +import X2fa from './settings.2fa.vue'; +import XApi from './settings.api.vue'; +import XApps from './settings.apps.vue'; +import XSignins from './settings.signins.vue'; +import XDrive from './settings.drive.vue'; +import { url, docsUrl, license, lang, version } from '../../../config'; +import checkForUpdate from '../../../common/scripts/check-for-update'; +import MkTaskManager from './taskmanager.vue'; + +export default Vue.extend({ + components: { + XProfile, + XMute, + XPassword, + X2fa, + XApi, + XApps, + XSignins, + XDrive + }, + data() { + return { + page: 'profile', + meta: null, + license, + version, + latestVersion: undefined, + checkingForUpdate: false, + enableSounds: localStorage.getItem('enableSounds') == 'true', + autoPopout: localStorage.getItem('autoPopout') == 'true', + soundVolume: localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) : 100, + lang: localStorage.getItem('lang') || '', + preventUpdate: localStorage.getItem('preventUpdate') == 'true', + debug: localStorage.getItem('debug') == 'true', + useRawScript: localStorage.getItem('useRawScript') == 'true', + enableExperimental: localStorage.getItem('enableExperimental') == 'true' + }; + }, + computed: { + licenseUrl(): string { + return `${docsUrl}/${lang}/license`; + } + }, + watch: { + autoPopout() { + localStorage.setItem('autoPopout', this.autoPopout ? 'true' : 'false'); + }, + enableSounds() { + localStorage.setItem('enableSounds', this.enableSounds ? 'true' : 'false'); + }, + soundVolume() { + localStorage.setItem('soundVolume', this.soundVolume.toString()); + }, + lang() { + localStorage.setItem('lang', this.lang); + }, + preventUpdate() { + localStorage.setItem('preventUpdate', this.preventUpdate ? 'true' : 'false'); + }, + debug() { + localStorage.setItem('debug', this.debug ? 'true' : 'false'); + }, + useRawScript() { + localStorage.setItem('useRawScript', this.useRawScript ? 'true' : 'false'); + }, + enableExperimental() { + localStorage.setItem('enableExperimental', this.enableExperimental ? 'true' : 'false'); + } + }, + created() { + (this as any).os.getMeta().then(meta => { + this.meta = meta; + }); + }, + methods: { + taskmngr() { + (this as any).os.new(MkTaskManager); + }, + customizeHome() { + this.$router.push('/i/customize-home'); + this.$emit('done'); + }, + onChangeFetchOnScroll(v) { + (this as any).api('i/update_client_setting', { + name: 'fetchOnScroll', + value: v + }); + }, + onChangeAutoWatch(v) { + (this as any).api('i/update', { + autoWatch: v + }); + }, + onChangeShowPostFormOnTopOfTl(v) { + (this as any).api('i/update_client_setting', { + name: 'showPostFormOnTopOfTl', + value: v + }); + }, + onChangeShowMaps(v) { + (this as any).api('i/update_client_setting', { + name: 'showMaps', + value: v + }); + }, + onChangeGradientWindowHeader(v) { + (this as any).api('i/update_client_setting', { + name: 'gradientWindowHeader', + value: v + }); + }, + onChangeDisableViaMobile(v) { + (this as any).api('i/update_client_setting', { + name: 'disableViaMobile', + value: v + }); + }, + checkForUpdate() { + this.checkingForUpdate = true; + checkForUpdate((this as any).os, true, true).then(newer => { + this.checkingForUpdate = false; + this.latestVersion = newer; + if (newer == null) { + (this as any).apis.dialog({ + title: '利用可能な更新はありません', + text: 'お使いのMisskeyは最新です。' + }); + } else { + (this as any).apis.dialog({ + title: '新しいバージョンが利用可能です', + text: 'ページを再度読み込みすると更新が適用されます。' + }); + } + }); + }, + clean() { + localStorage.clear(); + (this as any).apis.dialog({ + title: 'キャッシュを削除しました', + text: 'ページを再度読み込みしてください。' + }); + }, + soundTest() { + const sound = new Audio(`${url}/assets/message.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-settings + display flex + width 100% + height 100% + + > .nav + flex 0 0 200px + width 100% + height 100% + padding 16px 0 0 0 + overflow auto + border-right solid 1px #ddd + + > p + display block + padding 10px 16px + margin 0 + color #666 + cursor pointer + user-select none + transition margin-left 0.2s ease + + > [data-fa] + margin-right 4px + + &:hover + color #555 + + &.active + margin-left 8px + color $theme-color !important + + > .pages + width 100% + height 100% + flex auto + overflow auto + + > section + margin 32px + color #4a535a + + > h1 + margin 0 0 1em 0 + padding 0 0 8px 0 + font-size 1em + color #555 + border-bottom solid 1px #eee + + &, >>> * + .ui.button.block + margin 16px 0 + + > section + margin 32px 0 + + > h2 + margin 0 0 1em 0 + padding 0 0 8px 0 + font-size 1em + color #555 + border-bottom solid 1px #eee + + > .web + > .div + border-bottom solid 1px #eee + padding 0 0 16px 0 + margin 0 0 16px 0 + +</style> diff --git a/src/client/app/desktop/views/components/sub-post-content.vue b/src/client/app/desktop/views/components/sub-post-content.vue new file mode 100644 index 0000000000..f13822331b --- /dev/null +++ b/src/client/app/desktop/views/components/sub-post-content.vue @@ -0,0 +1,56 @@ +<template> +<div class="mk-sub-post-content"> + <div class="body"> + <a class="reply" v-if="post.replyId">%fa:reply%</a> + <mk-post-html :ast="post.ast" :i="os.i"/> + <a class="rp" v-if="post.repostId" :href="`/post:${post.repostId}`">RP: ...</a> + <mk-url-preview v-for="url in urls" :url="url" :key="url"/> + </div> + <details v-if="post.media"> + <summary>({{ post.media.length }}つのメディア)</summary> + <mk-media-list :media-list="post.media"/> + </details> + <details v-if="post.poll"> + <summary>投票</summary> + <mk-poll :post="post"/> + </details> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + props: ['post'], + computed: { + urls(): string[] { + if (this.post.ast) { + return this.post.ast + .filter(t => (t.type == 'url' || t.type == 'link') && !t.silent) + .map(t => t.url); + } else { + return null; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-sub-post-content + overflow-wrap break-word + + > .body + > .reply + margin-right 6px + color #717171 + + > .rp + margin-left 4px + font-style oblique + color #a0bf46 + + mk-poll + font-size 80% + +</style> diff --git a/src/client/app/desktop/views/components/taskmanager.vue b/src/client/app/desktop/views/components/taskmanager.vue new file mode 100644 index 0000000000..a00fabb047 --- /dev/null +++ b/src/client/app/desktop/views/components/taskmanager.vue @@ -0,0 +1,219 @@ +<template> +<mk-window ref="window" width="750px" height="500px" @closed="$destroy" name="TaskManager"> + <span slot="header" :class="$style.header">%fa:stethoscope%タスクマネージャ</span> + <el-tabs :class="$style.content"> + <el-tab-pane label="Requests"> + <el-table + :data="os.requests" + style="width: 100%" + :default-sort="{prop: 'date', order: 'descending'}" + > + <el-table-column type="expand"> + <template slot-scope="props"> + <pre>{{ props.row.data }}</pre> + <pre>{{ props.row.res }}</pre> + </template> + </el-table-column> + + <el-table-column + label="Requested at" + prop="date" + sortable + > + <template slot-scope="scope"> + <b style="margin-right: 8px">{{ scope.row.date.getTime() }}</b> + <span>(<mk-time :time="scope.row.date"/>)</span> + </template> + </el-table-column> + + <el-table-column + label="Name" + > + <template slot-scope="scope"> + <b>{{ scope.row.name }}</b> + </template> + </el-table-column> + + <el-table-column + label="Status" + > + <template slot-scope="scope"> + <span>{{ scope.row.status || '(pending)' }}</span> + </template> + </el-table-column> + </el-table> + </el-tab-pane> + + <el-tab-pane label="Streams"> + <el-table + :data="os.connections" + style="width: 100%" + > + <el-table-column + label="Uptime" + > + <template slot-scope="scope"> + <mk-timer v-if="scope.row.connectedAt" :time="scope.row.connectedAt"/> + <span v-else>-</span> + </template> + </el-table-column> + + <el-table-column + label="Name" + > + <template slot-scope="scope"> + <b>{{ scope.row.name == '' ? '[Home]' : scope.row.name }}</b> + </template> + </el-table-column> + + <el-table-column + label="User" + > + <template slot-scope="scope"> + <span>{{ scope.row.user || '(anonymous)' }}</span> + </template> + </el-table-column> + + <el-table-column + prop="state" + label="State" + /> + + <el-table-column + prop="in" + label="In" + /> + + <el-table-column + prop="out" + label="Out" + /> + </el-table> + </el-tab-pane> + + <el-tab-pane label="Streams (Inspect)"> + <el-tabs type="card" style="height:50%"> + <el-tab-pane v-for="c in os.connections" :label="c.name == '' ? '[Home]' : c.name" :key="c.id" :name="c.id" ref="connectionsTab"> + <div style="padding: 12px 0 0 12px"> + <el-button size="mini" @click="send(c)">Send</el-button> + <el-button size="mini" type="warning" @click="c.isSuspended = true" v-if="!c.isSuspended">Suspend</el-button> + <el-button size="mini" type="success" @click="c.isSuspended = false" v-else>Resume</el-button> + <el-button size="mini" type="danger" @click="c.close">Disconnect</el-button> + </div> + + <el-table + :data="c.inout" + style="width: 100%" + :default-sort="{prop: 'at', order: 'descending'}" + > + <el-table-column type="expand"> + <template slot-scope="props"> + <pre>{{ props.row.data }}</pre> + </template> + </el-table-column> + + <el-table-column + label="Date" + prop="at" + sortable + > + <template slot-scope="scope"> + <b style="margin-right: 8px">{{ scope.row.at.getTime() }}</b> + <span>(<mk-time :time="scope.row.at"/>)</span> + </template> + </el-table-column> + + <el-table-column + label="Type" + > + <template slot-scope="scope"> + <span>{{ getMessageType(scope.row.data) }}</span> + </template> + </el-table-column> + + <el-table-column + label="Incoming / Outgoing" + prop="type" + /> + </el-table> + </el-tab-pane> + </el-tabs> + </el-tab-pane> + + <el-tab-pane label="Windows"> + <el-table + :data="Array.from(os.windows.windows)" + style="width: 100%" + > + <el-table-column + label="Name" + > + <template slot-scope="scope"> + <b>{{ scope.row.name || '(unknown)' }}</b> + </template> + </el-table-column> + + <el-table-column + label="Operations" + > + <template slot-scope="scope"> + <el-button size="mini" type="danger" @click="scope.row.close">Close</el-button> + </template> + </el-table-column> + </el-table> + </el-tab-pane> + </el-tabs> +</mk-window> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + mounted() { + (this as any).os.windows.on('added', this.onWindowsChanged); + (this as any).os.windows.on('removed', this.onWindowsChanged); + }, + beforeDestroy() { + (this as any).os.windows.off('added', this.onWindowsChanged); + (this as any).os.windows.off('removed', this.onWindowsChanged); + }, + methods: { + getMessageType(data): string { + return data.type ? data.type : '-'; + }, + onWindowsChanged() { + this.$forceUpdate(); + }, + send(c) { + (this as any).apis.input({ + title: 'Send a JSON message', + allowEmpty: false + }).then(json => { + c.send(JSON.parse(json)); + }); + } + } +}); +</script> + +<style lang="stylus" module> +.header + > [data-fa] + margin-right 4px + +.content + height 100% + overflow auto + +</style> + +<style> +.el-tabs__header { + margin-bottom: 0 !important; +} + +.el-tabs__item { + padding: 0 20px !important; +} +</style> diff --git a/src/client/app/desktop/views/components/timeline.vue b/src/client/app/desktop/views/components/timeline.vue new file mode 100644 index 0000000000..65b4bd1c7a --- /dev/null +++ b/src/client/app/desktop/views/components/timeline.vue @@ -0,0 +1,156 @@ +<template> +<div class="mk-timeline"> + <mk-friends-maker v-if="alone"/> + <div class="fetching" v-if="fetching"> + <mk-ellipsis-icon/> + </div> + <p class="empty" v-if="posts.length == 0 && !fetching"> + %fa:R comments%自分の投稿や、自分がフォローしているユーザーの投稿が表示されます。 + </p> + <mk-posts :posts="posts" ref="timeline"> + <button slot="footer" @click="more" :disabled="moreFetching" :style="{ cursor: moreFetching ? 'wait' : 'pointer' }"> + <template v-if="!moreFetching">もっと見る</template> + <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> + </button> + </mk-posts> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { url } from '../../../config'; + +export default Vue.extend({ + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + posts: [], + connection: null, + connectionId: null, + date: null + }; + }, + computed: { + alone(): boolean { + return (this as any).os.i.followingCount == 0; + } + }, + mounted() { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('post', this.onPost); + this.connection.on('follow', this.onChangeFollowing); + this.connection.on('unfollow', this.onChangeFollowing); + + document.addEventListener('keydown', this.onKeydown); + window.addEventListener('scroll', this.onScroll); + + this.fetch(); + }, + beforeDestroy() { + this.connection.off('post', this.onPost); + this.connection.off('follow', this.onChangeFollowing); + this.connection.off('unfollow', this.onChangeFollowing); + (this as any).os.stream.dispose(this.connectionId); + + document.removeEventListener('keydown', this.onKeydown); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + fetch(cb?) { + this.fetching = true; + + (this as any).api('posts/timeline', { + limit: 11, + untilDate: this.date ? this.date.getTime() : undefined + }).then(posts => { + if (posts.length == 11) { + posts.pop(); + this.existMore = true; + } + this.posts = posts; + this.fetching = false; + this.$emit('loaded'); + if (cb) cb(); + }); + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0 || !this.existMore) return; + this.moreFetching = true; + (this as any).api('posts/timeline', { + limit: 11, + untilId: this.posts[this.posts.length - 1].id + }).then(posts => { + if (posts.length == 11) { + posts.pop(); + } else { + this.existMore = false; + } + this.posts = this.posts.concat(posts); + this.moreFetching = false; + }); + }, + onPost(post) { + // サウンドを再生する + if ((this as any).os.isEnableSounds) { + const sound = new Audio(`${url}/assets/post.mp3`); + sound.volume = localStorage.getItem('soundVolume') ? parseInt(localStorage.getItem('soundVolume'), 10) / 100 : 1; + sound.play(); + } + + this.posts.unshift(post); + }, + onChangeFollowing() { + this.fetch(); + }, + onScroll() { + if ((this as any).os.i.account.clientSettings.fetchOnScroll !== false) { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 8) this.more(); + } + }, + onKeydown(e) { + if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { + if (e.which == 84) { // t + (this.$refs.timeline as any).focus(); + } + } + }, + warp(date) { + this.date = date; + this.fetch(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-timeline + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .mk-friends-maker + border-bottom solid 1px #eee + + > .fetching + padding 64px 0 + + > .empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > [data-fa] + display block + margin-bottom 16px + font-size 3em + color #ccc + +</style> diff --git a/src/client/app/desktop/views/components/ui-notification.vue b/src/client/app/desktop/views/components/ui-notification.vue new file mode 100644 index 0000000000..9983f02c5e --- /dev/null +++ b/src/client/app/desktop/views/components/ui-notification.vue @@ -0,0 +1,61 @@ +<template> +<div class="mk-ui-notification"> + <p>{{ message }}</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +export default Vue.extend({ + props: ['message'], + mounted() { + this.$nextTick(() => { + anime({ + targets: this.$el, + opacity: 1, + translateY: [-64, 0], + easing: 'easeOutElastic', + duration: 500 + }); + + setTimeout(() => { + anime({ + targets: this.$el, + opacity: 0, + translateY: -64, + duration: 500, + easing: 'easeInElastic', + complete: () => this.$destroy() + }); + }, 6000); + }); + } +}); +</script> + +<style lang="stylus" scoped> +.mk-ui-notification + display block + position fixed + z-index 10000 + top -128px + left 0 + right 0 + margin 0 auto + padding 128px 0 0 0 + 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) + transform translateY(-64px) + opacity 0 + + > p + margin 0 + line-height 64px + text-align center + +</style> diff --git a/src/client/app/desktop/views/components/ui.header.account.vue b/src/client/app/desktop/views/components/ui.header.account.vue new file mode 100644 index 0000000000..ec4635f338 --- /dev/null +++ b/src/client/app/desktop/views/components/ui.header.account.vue @@ -0,0 +1,225 @@ +<template> +<div class="account"> + <button class="header" :data-active="isOpen" @click="toggle"> + <span class="username">{{ os.i.username }}<template v-if="!isOpen">%fa:angle-down%</template><template v-if="isOpen">%fa:angle-up%</template></span> + <img class="avatar" :src="`${ os.i.avatarUrl }?thumbnail&size=64`" alt="avatar"/> + </button> + <transition name="zoom-in-top"> + <div class="menu" v-if="isOpen"> + <ul> + <li> + <router-link :to="`/@${ os.i.username }`">%fa:user%%i18n:desktop.tags.mk-ui-header-account.profile%%fa:angle-right%</router-link> + </li> + <li @click="drive"> + <p>%fa:cloud%%i18n:desktop.tags.mk-ui-header-account.drive%%fa:angle-right%</p> + </li> + <li> + <a href="/i/mentions">%fa:at%%i18n:desktop.tags.mk-ui-header-account.mentions%%fa:angle-right%</a> + </li> + </ul> + <ul> + <li @click="settings"> + <p>%fa:cog%%i18n:desktop.tags.mk-ui-header-account.settings%%fa:angle-right%</p> + </li> + </ul> + <ul> + <li @click="signout"> + <p>%fa:power-off%%i18n:desktop.tags.mk-ui-header-account.signout%%fa:angle-right%</p> + </li> + </ul> + </div> + </transition> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import MkSettingsWindow from './settings-window.vue'; +import MkDriveWindow from './drive-window.vue'; +import contains from '../../../common/scripts/contains'; + +export default Vue.extend({ + data() { + return { + isOpen: false + }; + }, + beforeDestroy() { + this.close(); + }, + methods: { + toggle() { + this.isOpen ? this.close() : this.open(); + }, + open() { + this.isOpen = true; + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + }, + close() { + this.isOpen = false; + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + }, + onMousedown(e) { + e.preventDefault(); + if (!contains(this.$el, e.target) && this.$el != e.target) this.close(); + return false; + }, + drive() { + this.close(); + (this as any).os.new(MkDriveWindow); + }, + settings() { + this.close(); + (this as any).os.new(MkSettingsWindow); + }, + signout() { + (this as any).os.signout(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.account + > .header + display block + margin 0 + padding 0 + color #9eaba8 + border none + background transparent + cursor pointer + + * + pointer-events none + + &:hover + &[data-active='true'] + color darken(#9eaba8, 20%) + + > .avatar + filter saturate(150%) + + &:active + color darken(#9eaba8, 30%) + + > .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 + + [data-fa] + 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 + + > [data-fa]:first-of-type + margin-right 6px + + > [data-fa]: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 + + &:active + background darken($theme-color, 10%) + +.zoom-in-top-enter-active, +.zoom-in-top-leave-active { + transform-origin: center -16px; +} + +</style> diff --git a/src/client/app/desktop/views/components/ui.header.clock.vue b/src/client/app/desktop/views/components/ui.header.clock.vue new file mode 100644 index 0000000000..cd23a67506 --- /dev/null +++ b/src/client/app/desktop/views/components/ui.header.clock.vue @@ -0,0 +1,109 @@ +<template> +<div class="clock"> + <div class="header"> + <time ref="time"> + <span class="yyyymmdd">{{ yyyy }}/{{ mm }}/{{ dd }}</span> + <br> + <span class="hhnn">{{ hh }}<span :style="{ visibility: now.getSeconds() % 2 == 0 ? 'visible' : 'hidden' }">:</span>{{ nn }}</span> + </time> + </div> + <div class="content"> + <mk-analog-clock/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + now: new Date(), + clock: null + }; + }, + computed: { + yyyy(): number { + return this.now.getFullYear(); + }, + mm(): string { + return ('0' + (this.now.getMonth() + 1)).slice(-2); + }, + dd(): string { + return ('0' + this.now.getDate()).slice(-2); + }, + hh(): string { + return ('0' + this.now.getHours()).slice(-2); + }, + nn(): string { + return ('0' + this.now.getMinutes()).slice(-2); + } + }, + mounted() { + this.tick(); + this.clock = setInterval(this.tick, 1000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + tick() { + this.now = new Date(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.clock + display inline-block + overflow visible + + > .header + padding 0 12px + text-align center + font-size 10px + + &, * + 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 + +</style> diff --git a/src/client/app/desktop/views/components/ui.header.nav.vue b/src/client/app/desktop/views/components/ui.header.nav.vue new file mode 100644 index 0000000000..7582e8afce --- /dev/null +++ b/src/client/app/desktop/views/components/ui.header.nav.vue @@ -0,0 +1,175 @@ +<template> +<div class="nav"> + <ul> + <template v-if="os.isSignedIn"> + <li class="home" :class="{ active: $route.name == 'index' }"> + <router-link to="/"> + %fa:home% + <p>%i18n:desktop.tags.mk-ui-header-nav.home%</p> + </router-link> + </li> + <li class="messaging"> + <a @click="messaging"> + %fa:comments% + <p>%i18n:desktop.tags.mk-ui-header-nav.messaging%</p> + <template v-if="hasUnreadMessagingMessages">%fa:circle%</template> + </a> + </li> + <li class="game"> + <a @click="game"> + %fa:gamepad% + <p>ゲーム</p> + <template v-if="hasGameInvitations">%fa:circle%</template> + </a> + </li> + </template> + <li class="ch"> + <a :href="chUrl" target="_blank"> + %fa:tv% + <p>%i18n:desktop.tags.mk-ui-header-nav.ch%</p> + </a> + </li> + </ul> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { chUrl } from '../../../config'; +import MkMessagingWindow from './messaging-window.vue'; +import MkGameWindow from './game-window.vue'; + +export default Vue.extend({ + data() { + return { + hasUnreadMessagingMessages: false, + hasGameInvitations: false, + connection: null, + connectionId: null, + chUrl + }; + }, + mounted() { + if ((this as any).os.isSignedIn) { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.connection.on('unread_messaging_message', this.onUnreadMessagingMessage); + this.connection.on('othello_invited', this.onOthelloInvited); + this.connection.on('othello_no_invites', this.onOthelloNoInvites); + + // Fetch count of unread messaging messages + (this as any).api('messaging/unread').then(res => { + if (res.count > 0) { + this.hasUnreadMessagingMessages = true; + } + }); + } + }, + beforeDestroy() { + if ((this as any).os.isSignedIn) { + this.connection.off('read_all_messaging_messages', this.onReadAllMessagingMessages); + this.connection.off('unread_messaging_message', this.onUnreadMessagingMessage); + this.connection.off('othello_invited', this.onOthelloInvited); + this.connection.off('othello_no_invites', this.onOthelloNoInvites); + (this as any).os.stream.dispose(this.connectionId); + } + }, + methods: { + onUnreadMessagingMessage() { + this.hasUnreadMessagingMessages = true; + }, + + onReadAllMessagingMessages() { + this.hasUnreadMessagingMessages = false; + }, + + onOthelloInvited() { + this.hasGameInvitations = true; + }, + + onOthelloNoInvites() { + this.hasGameInvitations = false; + }, + + messaging() { + (this as any).os.new(MkMessagingWindow); + }, + + game() { + (this as any).os.new(MkGameWindow); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.nav + 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 13px + font-variant small-caps + color #9eaba8 + text-decoration none + transition none + cursor pointer + + * + pointer-events none + + &:hover + color darken(#9eaba8, 20%) + text-decoration none + + > [data-fa]:first-child + margin-right 8px + + > [data-fa]:last-child + margin-left 5px + 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 + +</style> diff --git a/src/client/app/desktop/views/components/ui.header.notifications.vue b/src/client/app/desktop/views/components/ui.header.notifications.vue new file mode 100644 index 0000000000..e829418d18 --- /dev/null +++ b/src/client/app/desktop/views/components/ui.header.notifications.vue @@ -0,0 +1,158 @@ +<template> +<div class="notifications"> + <button :data-active="isOpen" @click="toggle" title="%i18n:desktop.tags.mk-ui-header-notifications.title%"> + %fa:R bell%<template v-if="hasUnreadNotifications">%fa:circle%</template> + </button> + <div class="pop" v-if="isOpen"> + <mk-notifications/> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import contains from '../../../common/scripts/contains'; + +export default Vue.extend({ + data() { + return { + isOpen: false, + hasUnreadNotifications: false, + connection: null, + connectionId: null + }; + }, + mounted() { + if ((this as any).os.isSignedIn) { + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('read_all_notifications', this.onReadAllNotifications); + this.connection.on('unread_notification', this.onUnreadNotification); + + // Fetch count of unread notifications + (this as any).api('notifications/get_unread_count').then(res => { + if (res.count > 0) { + this.hasUnreadNotifications = true; + } + }); + } + }, + beforeDestroy() { + if ((this as any).os.isSignedIn) { + this.connection.off('read_all_notifications', this.onReadAllNotifications); + this.connection.off('unread_notification', this.onUnreadNotification); + (this as any).os.stream.dispose(this.connectionId); + } + }, + methods: { + onReadAllNotifications() { + this.hasUnreadNotifications = false; + }, + + onUnreadNotification() { + this.hasUnreadNotifications = true; + }, + + toggle() { + this.isOpen ? this.close() : this.open(); + }, + + open() { + this.isOpen = true; + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.addEventListener('mousedown', this.onMousedown); + }); + }, + + close() { + this.isOpen = false; + Array.from(document.querySelectorAll('body *')).forEach(el => { + el.removeEventListener('mousedown', this.onMousedown); + }); + }, + + onMousedown(e) { + e.preventDefault(); + if (!contains(this.$el, e.target) && this.$el != e.target) this.close(); + return false; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.notifications + + > button + display block + margin 0 + padding 0 + width 32px + color #9eaba8 + border none + background transparent + cursor pointer + + * + pointer-events none + + &:hover + &[data-active='true'] + color darken(#9eaba8, 20%) + + &:active + color darken(#9eaba8, 30%) + + > [data-fa].bell + font-size 1.2em + line-height 48px + + > [data-fa].circle + margin-left -5px + vertical-align super + font-size 10px + color $theme-color + + > .pop + 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 + +</style> diff --git a/src/client/app/desktop/views/components/ui.header.post.vue b/src/client/app/desktop/views/components/ui.header.post.vue new file mode 100644 index 0000000000..c2f0e07dd3 --- /dev/null +++ b/src/client/app/desktop/views/components/ui.header.post.vue @@ -0,0 +1,54 @@ +<template> +<div class="post"> + <button @click="post" title="%i18n:desktop.tags.mk-ui-header-post-button.post%">%fa:pencil-alt%</button> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + methods: { + post() { + (this as any).apis.post(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.post + 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 4px + 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 + +</style> diff --git a/src/client/app/desktop/views/components/ui.header.search.vue b/src/client/app/desktop/views/components/ui.header.search.vue new file mode 100644 index 0000000000..86215556ad --- /dev/null +++ b/src/client/app/desktop/views/components/ui.header.search.vue @@ -0,0 +1,70 @@ +<template> +<form class="search" @submit.prevent="onSubmit"> + %fa:search% + <input v-model="q" type="search" placeholder="%i18n:desktop.tags.mk-ui-header-search.placeholder%"/> + <div class="result"></div> +</form> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + q: '' + }; + }, + methods: { + onSubmit() { + location.href = `/search?q=${encodeURIComponent(this.q)}`; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.search + + > [data-fa] + display block + position absolute + top 0 + left 0 + width 48px + text-align center + line-height 48px + color #9eaba8 + pointer-events none + + > * + vertical-align middle + + > input + user-select text + cursor auto + margin 8px 0 0 0 + padding 6px 18px 6px 36px + width 14em + height 32px + font-size 1em + background rgba(0, 0, 0, 0.05) + outline none + //border solid 1px #ddd + border none + border-radius 16px + transition color 0.5s ease, border 0.5s ease + font-family FontAwesome, sans-serif + + &::placeholder + color #9eaba8 + + &:hover + background rgba(0, 0, 0, 0.08) + + &:focus + box-shadow 0 0 0 2px rgba($theme-color, 0.5) !important + +</style> diff --git a/src/client/app/desktop/views/components/ui.header.vue b/src/client/app/desktop/views/components/ui.header.vue new file mode 100644 index 0000000000..7e337d2ae5 --- /dev/null +++ b/src/client/app/desktop/views/components/ui.header.vue @@ -0,0 +1,172 @@ +<template> +<div class="header"> + <mk-special-message/> + <div class="main" ref="main"> + <div class="backdrop"></div> + <div class="main"> + <p ref="welcomeback" v-if="os.isSignedIn">おかえりなさい、<b>{{ os.i.name }}</b>さん</p> + <div class="container" ref="mainContainer"> + <div class="left"> + <x-nav/> + </div> + <div class="right"> + <x-search/> + <x-account v-if="os.isSignedIn"/> + <x-notifications v-if="os.isSignedIn"/> + <x-post v-if="os.isSignedIn"/> + <x-clock/> + </div> + </div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; + +import XNav from './ui.header.nav.vue'; +import XSearch from './ui.header.search.vue'; +import XAccount from './ui.header.account.vue'; +import XNotifications from './ui.header.notifications.vue'; +import XPost from './ui.header.post.vue'; +import XClock from './ui.header.clock.vue'; + +export default Vue.extend({ + components: { + XNav, + XSearch, + XAccount, + XNotifications, + XPost, + XClock, + }, + mounted() { + if ((this as any).os.isSignedIn) { + const ago = (new Date().getTime() - new Date((this as any).os.i.account.lastUsedAt).getTime()) / 1000 + const isHisasiburi = ago >= 3600; + (this as any).os.i.account.lastUsedAt = new Date(); + if (isHisasiburi) { + (this.$refs.welcomeback as any).style.display = 'block'; + (this.$refs.main as any).style.overflow = 'hidden'; + + anime({ + targets: this.$refs.welcomeback, + top: '0', + opacity: 1, + delay: 1000, + duration: 500, + easing: 'easeOutQuad' + }); + + anime({ + targets: this.$refs.mainContainer, + opacity: 0, + delay: 1000, + duration: 500, + easing: 'easeOutQuad' + }); + + setTimeout(() => { + anime({ + targets: this.$refs.welcomeback, + top: '-48px', + opacity: 0, + duration: 500, + complete: () => { + (this.$refs.welcomeback as any).style.display = 'none'; + (this.$refs.main as any).style.overflow = 'initial'; + }, + easing: 'easeInQuad' + }); + + anime({ + targets: this.$refs.mainContainer, + opacity: 1, + duration: 500, + easing: 'easeInQuad' + }); + }, 2500); + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.header + position -webkit-sticky + position sticky + top 0 + z-index 1000 + width 100% + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + > .main + height 48px + + > .backdrop + position absolute + top 0 + z-index 1000 + width 100% + height 48px + background #f7f7f7 + + > .main + z-index 1001 + margin 0 + padding 0 + background-clip content-box + font-size 0.9rem + user-select none + + > p + display none + position absolute + top 48px + width 100% + line-height 48px + margin 0 + text-align center + color #888 + opacity 0 + + > .container + display flex + width 100% + max-width 1300px + margin 0 auto + + &:before + content "" + position absolute + top 0 + left 0 + display block + width 100% + height 48px + background-image url(/assets/desktop/header-logo.svg) + background-size 46px + background-position center + background-repeat no-repeat + opacity 0.3 + + > .left + margin 0 auto 0 0 + height 48px + + > .right + margin 0 0 0 auto + height 48px + + > * + display inline-block + vertical-align top + + @media (max-width 1100px) + > .mk-ui-header-search + display none + +</style> diff --git a/src/client/app/desktop/views/components/ui.vue b/src/client/app/desktop/views/components/ui.vue new file mode 100644 index 0000000000..87f932ff14 --- /dev/null +++ b/src/client/app/desktop/views/components/ui.vue @@ -0,0 +1,37 @@ +<template> +<div> + <x-header/> + <div class="content"> + <slot></slot> + </div> + <mk-stream-indicator v-if="os.isSignedIn"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XHeader from './ui.header.vue'; + +export default Vue.extend({ + components: { + XHeader + }, + mounted() { + document.addEventListener('keydown', this.onKeydown); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onKeydown); + }, + methods: { + onKeydown(e) { + if (e.target.tagName == 'INPUT' || e.target.tagName == 'TEXTAREA') return; + + if (e.which == 80 || e.which == 78) { // p or n + e.preventDefault(); + (this as any).apis.post(); + } + } + } +}); +</script> + diff --git a/src/client/app/desktop/views/components/user-preview.vue b/src/client/app/desktop/views/components/user-preview.vue new file mode 100644 index 0000000000..8c86b2efe8 --- /dev/null +++ b/src/client/app/desktop/views/components/user-preview.vue @@ -0,0 +1,173 @@ +<template> +<div class="mk-user-preview"> + <template v-if="u != null"> + <div class="banner" :style="u.bannerUrl ? `background-image: url(${u.bannerUrl}?thumbnail&size=512)` : ''"></div> + <router-link class="avatar" :to="`/@${acct}`"> + <img :src="`${u.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="title"> + <router-link class="name" :to="`/@${acct}`">{{ u.name }}</router-link> + <p class="username">@{{ acct }}</p> + </div> + <div class="description">{{ u.description }}</div> + <div class="status"> + <div> + <p>投稿</p><a>{{ u.postsCount }}</a> + </div> + <div> + <p>フォロー</p><a>{{ u.followingCount }}</a> + </div> + <div> + <p>フォロワー</p><a>{{ u.followersCount }}</a> + </div> + </div> + <mk-follow-button v-if="os.isSignedIn && user.id != os.i.id" :user="u"/> + </template> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; +import getAcct from '../../../../../common/user/get-acct'; +import parseAcct from '../../../../../common/user/parse-acct'; + +export default Vue.extend({ + props: { + user: { + type: [Object, String], + required: true + } + }, + computed: { + acct() { + return getAcct(this.u); + } + }, + data() { + return { + u: null + }; + }, + mounted() { + if (typeof this.user == 'object') { + this.u = this.user; + this.$nextTick(() => { + this.open(); + }); + } else { + const query = this.user[0] == '@' ? + parseAcct(this.user[0].substr(1)) : + { userId: this.user[0] }; + + (this as any).api('users/show', query).then(user => { + this.u = user; + this.open(); + }); + } + }, + methods: { + open() { + anime({ + targets: this.$el, + opacity: 1, + 'margin-top': 0, + duration: 200, + easing: 'easeOutQuad' + }); + }, + close() { + anime({ + targets: this.$el, + opacity: 0, + 'margin-top': '-8px', + duration: 200, + easing: 'easeOutQuad', + complete: () => this.$destroy() + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-user-preview + position absolute + z-index 2048 + margin-top -8px + width 250px + background #fff + background-clip content-box + border solid 1px rgba(0, 0, 0, 0.1) + border-radius 4px + overflow hidden + opacity 0 + + > .banner + height 84px + background-color #f5f5f5 + background-size cover + background-position center + + > .avatar + display block + position absolute + top 62px + left 13px + z-index 2 + + > img + display block + width 58px + height 58px + margin 0 + border solid 3px #fff + border-radius 8px + + > .title + display block + padding 8px 0 8px 82px + + > .name + display inline-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 + + > .description + 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 + +</style> diff --git a/src/client/app/desktop/views/components/users-list.item.vue b/src/client/app/desktop/views/components/users-list.item.vue new file mode 100644 index 0000000000..d2bfc117da --- /dev/null +++ b/src/client/app/desktop/views/components/users-list.item.vue @@ -0,0 +1,107 @@ +<template> +<div class="root item"> + <router-link class="avatar-anchor" :to="`/@${acct}`" v-user-preview="user.id"> + <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + </router-link> + <div class="main"> + <header> + <router-link class="name" :to="`/@${acct}`" v-user-preview="user.id">{{ user.name }}</router-link> + <span class="username">@{{ acct }}</span> + </header> + <div class="body"> + <p class="followed" v-if="user.isFollowed">フォローされています</p> + <div class="description">{{ user.description }}</div> + </div> + </div> + <mk-follow-button :user="user"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + computed: { + acct() { + return getAcct(this.user); + } + } +}); +</script> + +<style lang="stylus" scoped> +.root.item + 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 + + > .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 + + > .description + cursor default + display block + margin 0 + padding 0 + overflow-wrap break-word + font-size 1.1em + color #717171 + + > .mk-follow-button + position absolute + top 16px + right 16px + +</style> diff --git a/src/client/app/desktop/views/components/users-list.vue b/src/client/app/desktop/views/components/users-list.vue new file mode 100644 index 0000000000..a08e76f573 --- /dev/null +++ b/src/client/app/desktop/views/components/users-list.vue @@ -0,0 +1,143 @@ +<template> +<div class="mk-users-list"> + <nav> + <div> + <span :data-is-active="mode == 'all'" @click="mode = 'all'">すべて<span>{{ count }}</span></span> + <span v-if="os.isSignedIn && youKnowCount" :data-is-active="mode == 'iknow'" @click="mode = 'iknow'">知り合い<span>{{ youKnowCount }}</span></span> + </div> + </nav> + <div class="users" v-if="!fetching && users.length != 0"> + <div v-for="u in users" :key="u.id"> + <x-item :user="u"/> + </div> + </div> + <button class="more" v-if="!fetching && next != null" @click="more" :disabled="moreFetching"> + <span v-if="!moreFetching">もっと</span> + <span v-if="moreFetching">読み込み中<mk-ellipsis/></span> + </button> + <p class="no" v-if="!fetching && users.length == 0"> + <slot></slot> + </p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%読み込んでいます<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XItem from './users-list.item.vue'; + +export default Vue.extend({ + components: { + XItem + }, + props: ['fetch', 'count', 'youKnowCount'], + data() { + return { + limit: 30, + mode: 'all', + fetching: true, + moreFetching: false, + users: [], + next: null + }; + }, + mounted() { + this._fetch(() => { + this.$emit('loaded'); + }); + }, + methods: { + _fetch(cb) { + this.fetching = true; + this.fetch(this.mode == 'iknow', this.limit, null, obj => { + this.users = obj.users; + this.next = obj.next; + this.fetching = false; + if (cb) cb(); + }); + }, + more() { + this.moreFetching = true; + this.fetch(this.mode == 'iknow', this.limit, this.next, obj => { + this.moreFetching = false; + this.users = this.users.concat(obj.users); + this.next = obj.next; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-users-list + 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 + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/components/widget-container.vue b/src/client/app/desktop/views/components/widget-container.vue new file mode 100644 index 0000000000..68c5bcb8dc --- /dev/null +++ b/src/client/app/desktop/views/components/widget-container.vue @@ -0,0 +1,85 @@ +<template> +<div class="mk-widget-container" :class="{ naked }"> + <header :class="{ withGradient }" v-if="showHeader"> + <div class="title"><slot name="header"></slot></div> + <slot name="func"></slot> + </header> + <slot></slot> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: { + showHeader: { + type: Boolean, + default: true + }, + naked: { + type: Boolean, + default: false + } + }, + computed: { + withGradient(): boolean { + return (this as any).os.isSignedIn + ? (this as any).os.i.account.clientSettings.gradientWindowHeader != null + ? (this as any).os.i.account.clientSettings.gradientWindowHeader + : false + : false; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-widget-container + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + + &.naked + background transparent !important + border none !important + + > header + > .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) + + > [data-fa] + margin-right 4px + + &:empty + display none + + > 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 + + &.withGradient + > .title + background linear-gradient(to bottom, #fff, #ececec) + box-shadow 0 1px rgba(#000, 0.11) +</style> diff --git a/src/client/app/desktop/views/components/window.vue b/src/client/app/desktop/views/components/window.vue new file mode 100644 index 0000000000..48dc46febd --- /dev/null +++ b/src/client/app/desktop/views/components/window.vue @@ -0,0 +1,635 @@ +<template> +<div class="mk-window" :data-flexible="isFlexible" @dragover="onDragover"> + <div class="bg" ref="bg" v-show="isModal" @click="onBgClick"></div> + <div class="main" ref="main" tabindex="-1" :data-is-modal="isModal" @mousedown="onBodyMousedown" @keydown="onKeydown" :style="{ width, height }"> + <div class="body"> + <header ref="header" + :class="{ withGradient }" + @contextmenu.prevent="() => {}" @mousedown.prevent="onHeaderMousedown" + > + <h1><slot name="header"></slot></h1> + <div> + <button class="popout" v-if="popoutUrl" @mousedown.stop="() => {}" @click="popout" title="ポップアウト">%fa:R window-restore%</button> + <button class="close" v-if="canClose" @mousedown.stop="() => {}" @click="close" title="閉じる">%fa:times%</button> + </div> + </header> + <div class="content"> + <slot></slot> + </div> + </div> + <div class="handle top" v-if="canResize" @mousedown.prevent="onTopHandleMousedown"></div> + <div class="handle right" v-if="canResize" @mousedown.prevent="onRightHandleMousedown"></div> + <div class="handle bottom" v-if="canResize" @mousedown.prevent="onBottomHandleMousedown"></div> + <div class="handle left" v-if="canResize" @mousedown.prevent="onLeftHandleMousedown"></div> + <div class="handle top-left" v-if="canResize" @mousedown.prevent="onTopLeftHandleMousedown"></div> + <div class="handle top-right" v-if="canResize" @mousedown.prevent="onTopRightHandleMousedown"></div> + <div class="handle bottom-right" v-if="canResize" @mousedown.prevent="onBottomRightHandleMousedown"></div> + <div class="handle bottom-left" v-if="canResize" @mousedown.prevent="onBottomLeftHandleMousedown"></div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as anime from 'animejs'; +import contains from '../../../common/scripts/contains'; + +const minHeight = 40; +const minWidth = 200; + +function dragListen(fn) { + window.addEventListener('mousemove', fn); + window.addEventListener('mouseleave', dragClear.bind(null, fn)); + window.addEventListener('mouseup', dragClear.bind(null, fn)); +} + +function dragClear(fn) { + window.removeEventListener('mousemove', fn); + window.removeEventListener('mouseleave', dragClear); + window.removeEventListener('mouseup', dragClear); +} + +export default Vue.extend({ + props: { + isModal: { + type: Boolean, + default: false + }, + canClose: { + type: Boolean, + default: true + }, + width: { + type: String, + default: '530px' + }, + height: { + type: String, + default: 'auto' + }, + popoutUrl: { + type: [String, Function], + default: null + }, + name: { + type: String, + default: null + } + }, + + data() { + return { + preventMount: false + }; + }, + + computed: { + isFlexible(): boolean { + return this.height == null; + }, + canResize(): boolean { + return !this.isFlexible; + }, + withGradient(): boolean { + return (this as any).os.isSignedIn + ? (this as any).os.i.account.clientSettings.gradientWindowHeader != null + ? (this as any).os.i.account.clientSettings.gradientWindowHeader + : false + : false; + } + }, + + created() { + if (localStorage.getItem('autoPopout') == 'true' && this.popoutUrl) { + this.popout(); + this.preventMount = true; + } else { + // ウィンドウをウィンドウシステムに登録 + (this as any).os.windows.add(this); + } + }, + + mounted() { + if (this.preventMount) { + this.$destroy(); + return; + } + + this.$nextTick(() => { + const main = this.$refs.main as any; + main.style.top = '15%'; + main.style.left = (window.innerWidth / 2) - (main.offsetWidth / 2) + 'px'; + + window.addEventListener('resize', this.onBrowserResize); + + this.open(); + }); + }, + + destroyed() { + // ウィンドウをウィンドウシステムから削除 + (this as any).os.windows.remove(this); + + window.removeEventListener('resize', this.onBrowserResize); + }, + + methods: { + open() { + this.$emit('opening'); + + this.top(); + + const bg = this.$refs.bg as any; + const main = this.$refs.main as any; + + if (this.isModal) { + bg.style.pointerEvents = 'auto'; + anime({ + targets: bg, + opacity: 1, + duration: 100, + easing: 'linear' + }); + } + + main.style.pointerEvents = 'auto'; + anime({ + targets: main, + opacity: 1, + scale: [1.1, 1], + duration: 200, + easing: 'easeOutQuad' + }); + + if (focus) main.focus(); + + setTimeout(() => { + this.$emit('opened'); + }, 300); + }, + + close() { + this.$emit('before-close'); + + const bg = this.$refs.bg as any; + const main = this.$refs.main as any; + + if (this.isModal) { + bg.style.pointerEvents = 'none'; + anime({ + targets: bg, + opacity: 0, + duration: 300, + easing: 'linear' + }); + } + + main.style.pointerEvents = 'none'; + + anime({ + targets: main, + opacity: 0, + scale: 0.8, + duration: 300, + easing: [0.5, -0.5, 1, 0.5] + }); + + setTimeout(() => { + this.$destroy(); + this.$emit('closed'); + }, 300); + }, + + popout() { + const url = typeof this.popoutUrl == 'function' ? this.popoutUrl() : this.popoutUrl; + + const main = this.$refs.main as any; + + if (main) { + const position = main.getBoundingClientRect(); + + const width = parseInt(getComputedStyle(main, '').width, 10); + const height = parseInt(getComputedStyle(main, '').height, 10); + const x = window.screenX + position.left; + const y = window.screenY + position.top; + + window.open(url, url, + `width=${width}, height=${height}, top=${y}, left=${x}`); + + this.close(); + } else { + const x = window.top.outerHeight / 2 + window.top.screenY - (parseInt(this.height, 10) / 2); + const y = window.top.outerWidth / 2 + window.top.screenX - (parseInt(this.width, 10) / 2); + window.open(url, url, + `width=${this.width}, height=${this.height}, top=${x}, left=${y}`); + } + }, + + // 最前面へ移動 + top() { + let z = 0; + + (this as any).os.windows.getAll().forEach(w => { + if (w == this) return; + const m = w.$refs.main; + const mz = Number(document.defaultView.getComputedStyle(m, null).zIndex); + if (mz > z) z = mz; + }); + + if (z > 0) { + (this.$refs.main as any).style.zIndex = z + 1; + if (this.isModal) (this.$refs.bg as any).style.zIndex = z + 1; + } + }, + + onBgClick() { + if (this.canClose) this.close(); + }, + + onBodyMousedown() { + this.top(); + }, + + onHeaderMousedown(e) { + const main = this.$refs.main as any; + + if (!contains(main, document.activeElement)) main.focus(); + + const position = main.getBoundingClientRect(); + + const clickX = e.clientX; + const clickY = e.clientY; + const moveBaseX = clickX - position.left; + const moveBaseY = clickY - position.top; + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + + // 動かした時 + dragListen(me => { + let moveLeft = me.clientX - moveBaseX; + let moveTop = me.clientY - moveBaseY; + + // 上はみ出し + if (moveTop < 0) moveTop = 0; + + // 左はみ出し + if (moveLeft < 0) moveLeft = 0; + + // 下はみ出し + if (moveTop + windowHeight > browserHeight) moveTop = browserHeight - windowHeight; + + // 右はみ出し + if (moveLeft + windowWidth > browserWidth) moveLeft = browserWidth - windowWidth; + + main.style.left = moveLeft + 'px'; + main.style.top = moveTop + 'px'; + }); + }, + + // 上ハンドル掴み時 + onTopHandleMousedown(e) { + const main = this.$refs.main as any; + + const base = e.clientY; + const height = parseInt(getComputedStyle(main, '').height, 10); + const top = parseInt(getComputedStyle(main, '').top, 10); + + // 動かした時 + dragListen(me => { + const move = me.clientY - base; + if (top + move > 0) { + if (height + -move > minHeight) { + this.applyTransformHeight(height + -move); + this.applyTransformTop(top + move); + } else { // 最小の高さより小さくなろうとした時 + this.applyTransformHeight(minHeight); + this.applyTransformTop(top + (height - minHeight)); + } + } else { // 上のはみ出し時 + this.applyTransformHeight(top + height); + this.applyTransformTop(0); + } + }); + }, + + // 右ハンドル掴み時 + onRightHandleMousedown(e) { + const main = this.$refs.main as any; + + const base = e.clientX; + const width = parseInt(getComputedStyle(main, '').width, 10); + const left = parseInt(getComputedStyle(main, '').left, 10); + const browserWidth = window.innerWidth; + + // 動かした時 + dragListen(me => { + const move = me.clientX - base; + if (left + width + move < browserWidth) { + if (width + move > minWidth) { + this.applyTransformWidth(width + move); + } else { // 最小の幅より小さくなろうとした時 + this.applyTransformWidth(minWidth); + } + } else { // 右のはみ出し時 + this.applyTransformWidth(browserWidth - left); + } + }); + }, + + // 下ハンドル掴み時 + onBottomHandleMousedown(e) { + const main = this.$refs.main as any; + + const base = e.clientY; + const height = parseInt(getComputedStyle(main, '').height, 10); + const top = parseInt(getComputedStyle(main, '').top, 10); + const browserHeight = window.innerHeight; + + // 動かした時 + dragListen(me => { + const move = me.clientY - base; + if (top + height + move < browserHeight) { + if (height + move > minHeight) { + this.applyTransformHeight(height + move); + } else { // 最小の高さより小さくなろうとした時 + this.applyTransformHeight(minHeight); + } + } else { // 下のはみ出し時 + this.applyTransformHeight(browserHeight - top); + } + }); + }, + + // 左ハンドル掴み時 + onLeftHandleMousedown(e) { + const main = this.$refs.main as any; + + const base = e.clientX; + const width = parseInt(getComputedStyle(main, '').width, 10); + const left = parseInt(getComputedStyle(main, '').left, 10); + + // 動かした時 + dragListen(me => { + const move = me.clientX - base; + if (left + move > 0) { + if (width + -move > minWidth) { + this.applyTransformWidth(width + -move); + this.applyTransformLeft(left + move); + } else { // 最小の幅より小さくなろうとした時 + this.applyTransformWidth(minWidth); + this.applyTransformLeft(left + (width - minWidth)); + } + } else { // 左のはみ出し時 + this.applyTransformWidth(left + width); + this.applyTransformLeft(0); + } + }); + }, + + // 左上ハンドル掴み時 + onTopLeftHandleMousedown(e) { + this.onTopHandleMousedown(e); + this.onLeftHandleMousedown(e); + }, + + // 右上ハンドル掴み時 + onTopRightHandleMousedown(e) { + this.onTopHandleMousedown(e); + this.onRightHandleMousedown(e); + }, + + // 右下ハンドル掴み時 + onBottomRightHandleMousedown(e) { + this.onBottomHandleMousedown(e); + this.onRightHandleMousedown(e); + }, + + // 左下ハンドル掴み時 + onBottomLeftHandleMousedown(e) { + this.onBottomHandleMousedown(e); + this.onLeftHandleMousedown(e); + }, + + // 高さを適用 + applyTransformHeight(height) { + (this.$refs.main as any).style.height = height + 'px'; + }, + + // 幅を適用 + applyTransformWidth(width) { + (this.$refs.main as any).style.width = width + 'px'; + }, + + // Y座標を適用 + applyTransformTop(top) { + (this.$refs.main as any).style.top = top + 'px'; + }, + + // X座標を適用 + applyTransformLeft(left) { + (this.$refs.main as any).style.left = left + 'px'; + }, + + onDragover(e) { + e.dataTransfer.dropEffect = 'none'; + }, + + onKeydown(e) { + if (e.which == 27) { // Esc + if (this.canClose) { + e.preventDefault(); + e.stopPropagation(); + this.close(); + } + } + }, + + onBrowserResize() { + const main = this.$refs.main as any; + const position = main.getBoundingClientRect(); + const browserWidth = window.innerWidth; + const browserHeight = window.innerHeight; + const windowWidth = main.offsetWidth; + const windowHeight = main.offsetHeight; + if (position.left < 0) main.style.left = 0; + if (position.top < 0) main.style.top = 0; + if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; + if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mk-window + display block + + > .bg + display block + position fixed + z-index 2000 + 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 2000 + 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 + $header-height = 40px + + z-index 1001 + height $header-height + overflow hidden + white-space nowrap + cursor move + background #fff + border-radius 6px 6px 0 0 + box-shadow 0 1px 0 rgba(#000, 0.1) + + &.withGradient + background linear-gradient(to bottom, #fff, #ececec) + box-shadow 0 1px 0 rgba(#000, 0.15) + + &, * + user-select none + + > h1 + pointer-events none + display block + margin 0 auto + overflow hidden + height $header-height + text-overflow ellipsis + text-align center + font-size 1em + line-height $header-height + font-weight normal + color #666 + + > div:last-child + position absolute + top 0 + right 0 + display block + z-index 1 + + > * + display inline-block + margin 0 + padding 0 + cursor pointer + font-size 1em + color rgba(#000, 0.4) + border none + outline none + background transparent + + &:hover + color rgba(#000, 0.6) + + &:active + color darken(#000, 30%) + + > [data-fa] + padding 0 + width $header-height + line-height $header-height + text-align center + + > .content + height 100% + + &:not([flexible]) + > .main > .body > .content + height calc(100% - 40px) + +</style> diff --git a/src/client/app/desktop/views/directives/index.ts b/src/client/app/desktop/views/directives/index.ts new file mode 100644 index 0000000000..324e07596d --- /dev/null +++ b/src/client/app/desktop/views/directives/index.ts @@ -0,0 +1,6 @@ +import Vue from 'vue'; + +import userPreview from './user-preview'; + +Vue.directive('userPreview', userPreview); +Vue.directive('user-preview', userPreview); diff --git a/src/client/app/desktop/views/directives/user-preview.ts b/src/client/app/desktop/views/directives/user-preview.ts new file mode 100644 index 0000000000..8a4035881a --- /dev/null +++ b/src/client/app/desktop/views/directives/user-preview.ts @@ -0,0 +1,72 @@ +/** + * マウスオーバーするとユーザーがプレビューされる要素を設定します + */ + +import MkUserPreview from '../components/user-preview.vue'; + +export default { + bind(el, binding, vn) { + const self = el._userPreviewDirective_ = {} as any; + + self.user = binding.value; + self.tag = null; + self.showTimer = null; + self.hideTimer = null; + + self.close = () => { + if (self.tag) { + self.tag.close(); + self.tag = null; + } + }; + + const show = () => { + if (self.tag) return; + + self.tag = new MkUserPreview({ + parent: vn.context, + propsData: { + user: self.user + } + }).$mount(); + + const preview = self.tag.$el; + const rect = el.getBoundingClientRect(); + const x = rect.left + el.offsetWidth + window.pageXOffset; + const y = rect.top + window.pageYOffset; + + preview.style.top = y + 'px'; + preview.style.left = x + 'px'; + + preview.addEventListener('mouseover', () => { + clearTimeout(self.hideTimer); + }); + + preview.addEventListener('mouseleave', () => { + clearTimeout(self.showTimer); + self.hideTimer = setTimeout(self.close, 500); + }); + + document.body.appendChild(preview); + }; + + el.addEventListener('mouseover', () => { + clearTimeout(self.showTimer); + clearTimeout(self.hideTimer); + self.showTimer = setTimeout(show, 500); + }); + + el.addEventListener('mouseleave', () => { + clearTimeout(self.showTimer); + clearTimeout(self.hideTimer); + self.hideTimer = setTimeout(self.close, 500); + }); + }, + + unbind(el, binding, vn) { + const self = el._userPreviewDirective_; + clearTimeout(self.showTimer); + clearTimeout(self.hideTimer); + self.close(); + } +}; diff --git a/src/client/app/desktop/views/pages/drive.vue b/src/client/app/desktop/views/pages/drive.vue new file mode 100644 index 0000000000..353f59b703 --- /dev/null +++ b/src/client/app/desktop/views/pages/drive.vue @@ -0,0 +1,52 @@ +<template> +<div class="mk-drive-page"> + <mk-drive :init-folder="folder" @move-root="onMoveRoot" @open-folder="onOpenFolder"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + folder: null + }; + }, + created() { + this.folder = this.$route.params.folder; + }, + mounted() { + document.title = 'Misskey Drive'; + }, + methods: { + onMoveRoot() { + const title = 'Misskey Drive'; + + // Rewrite URL + history.pushState(null, title, '/i/drive'); + + document.title = title; + }, + onOpenFolder(folder) { + const title = folder.name + ' | Misskey Drive'; + + // Rewrite URL + history.pushState(null, title, '/i/drive/folder/' + folder.id); + + document.title = title; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-drive-page + position fixed + width 100% + height 100% + background #fff + + > .mk-drive + height 100% +</style> + diff --git a/src/client/app/desktop/views/pages/home-customize.vue b/src/client/app/desktop/views/pages/home-customize.vue new file mode 100644 index 0000000000..8aa06be57f --- /dev/null +++ b/src/client/app/desktop/views/pages/home-customize.vue @@ -0,0 +1,12 @@ +<template> +<mk-home customize/> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + mounted() { + document.title = 'Misskey - ホームのカスタマイズ'; + } +}); +</script> diff --git a/src/client/app/desktop/views/pages/home.vue b/src/client/app/desktop/views/pages/home.vue new file mode 100644 index 0000000000..69e134f79f --- /dev/null +++ b/src/client/app/desktop/views/pages/home.vue @@ -0,0 +1,62 @@ +<template> +<mk-ui> + <mk-home :mode="mode" @loaded="loaded"/> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; +import getPostSummary from '../../../../../common/get-post-summary'; + +export default Vue.extend({ + props: { + mode: { + type: String, + default: 'timeline' + } + }, + data() { + return { + connection: null, + connectionId: null, + unreadCount: 0 + }; + }, + mounted() { + document.title = 'Misskey'; + + this.connection = (this as any).os.stream.getConnection(); + this.connectionId = (this as any).os.stream.use(); + + this.connection.on('post', this.onStreamPost); + document.addEventListener('visibilitychange', this.onVisibilitychange, false); + + Progress.start(); + }, + beforeDestroy() { + this.connection.off('post', this.onStreamPost); + (this as any).os.stream.dispose(this.connectionId); + document.removeEventListener('visibilitychange', this.onVisibilitychange); + }, + methods: { + loaded() { + Progress.done(); + }, + + onStreamPost(post) { + if (document.hidden && post.userId != (this as any).os.i.id) { + this.unreadCount++; + document.title = `(${this.unreadCount}) ${getPostSummary(post)}`; + } + }, + + onVisibilitychange() { + if (!document.hidden) { + this.unreadCount = 0; + document.title = 'Misskey'; + } + } + } +}); +</script> diff --git a/src/client/app/desktop/views/pages/index.vue b/src/client/app/desktop/views/pages/index.vue new file mode 100644 index 0000000000..0ea47d913b --- /dev/null +++ b/src/client/app/desktop/views/pages/index.vue @@ -0,0 +1,16 @@ +<template> +<component :is="os.isSignedIn ? 'home' : 'welcome'"></component> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Home from './home.vue'; +import Welcome from './welcome.vue'; + +export default Vue.extend({ + components: { + Home, + Welcome + } +}); +</script> diff --git a/src/client/app/desktop/views/pages/messaging-room.vue b/src/client/app/desktop/views/pages/messaging-room.vue new file mode 100644 index 0000000000..0cab1e0d10 --- /dev/null +++ b/src/client/app/desktop/views/pages/messaging-room.vue @@ -0,0 +1,54 @@ +<template> +<div class="mk-messaging-room-page"> + <mk-messaging-room v-if="user" :user="user" :is-naked="true"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; +import parseAcct from '../../../../../common/user/parse-acct'; + +export default Vue.extend({ + data() { + return { + fetching: true, + user: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + mounted() { + document.documentElement.style.background = '#fff'; + }, + methods: { + fetch() { + Progress.start(); + this.fetching = true; + + (this as any).api('users/show', parseAcct(this.$route.params.user)).then(user => { + this.user = user; + this.fetching = false; + + document.title = 'メッセージ: ' + this.user.name; + + Progress.done(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mk-messaging-room-page + display flex + flex 1 + flex-direction column + min-height 100% + background #fff + +</style> diff --git a/src/client/app/desktop/views/pages/othello.vue b/src/client/app/desktop/views/pages/othello.vue new file mode 100644 index 0000000000..0d8e987dd9 --- /dev/null +++ b/src/client/app/desktop/views/pages/othello.vue @@ -0,0 +1,50 @@ +<template> +<component :is="ui ? 'mk-ui' : 'div'"> + <mk-othello v-if="!fetching" :init-game="game" @gamed="onGamed"/> +</component> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +export default Vue.extend({ + props: { + ui: { + default: false + } + }, + data() { + return { + fetching: false, + game: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + if (this.$route.params.game == null) return; + + Progress.start(); + this.fetching = true; + + (this as any).api('othello/games/show', { + gameId: this.$route.params.game + }).then(game => { + this.game = game; + this.fetching = false; + + Progress.done(); + }); + }, + onGamed(game) { + history.pushState(null, null, '/othello/' + game.id); + } + } +}); +</script> diff --git a/src/client/app/desktop/views/pages/post.vue b/src/client/app/desktop/views/pages/post.vue new file mode 100644 index 0000000000..dbd707e049 --- /dev/null +++ b/src/client/app/desktop/views/pages/post.vue @@ -0,0 +1,67 @@ +<template> +<mk-ui> + <main v-if="!fetching"> + <a v-if="post.next" :href="post.next">%fa:angle-up%%i18n:desktop.tags.mk-post-page.next%</a> + <mk-post-detail :post="post"/> + <a v-if="post.prev" :href="post.prev">%fa:angle-down%%i18n:desktop.tags.mk-post-page.prev%</a> + </main> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; + +export default Vue.extend({ + data() { + return { + fetching: true, + post: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + Progress.start(); + this.fetching = true; + + (this as any).api('posts/show', { + postId: this.$route.params.post + }).then(post => { + this.post = post; + this.fetching = false; + + Progress.done(); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +main + padding 16px + text-align center + + > a + display inline-block + + &:first-child + margin-bottom 4px + + &:last-child + margin-top 4px + + > [data-fa] + margin-right 4px + + > .mk-post-detail + margin 0 auto + width 640px + +</style> diff --git a/src/client/app/desktop/views/pages/search.vue b/src/client/app/desktop/views/pages/search.vue new file mode 100644 index 0000000000..afd37c8cee --- /dev/null +++ b/src/client/app/desktop/views/pages/search.vue @@ -0,0 +1,138 @@ +<template> +<mk-ui> + <header :class="$style.header"> + <h1>{{ q }}</h1> + </header> + <div :class="$style.loading" v-if="fetching"> + <mk-ellipsis-icon/> + </div> + <p :class="$style.empty" v-if="!fetching && empty">%fa:search%「{{ q }}」に関する投稿は見つかりませんでした。</p> + <mk-posts ref="timeline" :class="$style.posts" :posts="posts"> + <div slot="footer"> + <template v-if="!moreFetching">%fa:search%</template> + <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> + </div> + </mk-posts> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import Progress from '../../../common/scripts/loading'; +import parse from '../../../common/scripts/parse-search-query'; + +const limit = 20; + +export default Vue.extend({ + data() { + return { + fetching: true, + moreFetching: false, + existMore: false, + offset: 0, + posts: [] + }; + }, + watch: { + $route: 'fetch' + }, + computed: { + empty(): boolean { + return this.posts.length == 0; + }, + q(): string { + return this.$route.query.q; + } + }, + mounted() { + document.addEventListener('keydown', this.onDocumentKeydown); + window.addEventListener('scroll', this.onScroll); + + this.fetch(); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onDocumentKeydown); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + onDocumentKeydown(e) { + if (e.target.tagName != 'INPUT' && e.target.tagName != 'TEXTAREA') { + if (e.which == 84) { // t + (this.$refs.timeline as any).focus(); + } + } + }, + fetch() { + this.fetching = true; + Progress.start(); + + (this as any).api('posts/search', Object.assign({ + limit: limit + 1, + offset: this.offset + }, parse(this.q))).then(posts => { + if (posts.length == limit + 1) { + posts.pop(); + this.existMore = true; + } + this.posts = posts; + this.fetching = false; + Progress.done(); + }); + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0 || !this.existMore) return; + this.offset += limit; + this.moreFetching = true; + return (this as any).api('posts/search', Object.assign({ + limit: limit + 1, + offset: this.offset + }, parse(this.q))).then(posts => { + if (posts.length == limit + 1) { + posts.pop(); + } else { + this.existMore = false; + } + this.posts = this.posts.concat(posts); + this.moreFetching = false; + }); + }, + onScroll() { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 16) this.more(); + } + } +}); +</script> + +<style lang="stylus" module> +.header + width 100% + max-width 600px + margin 0 auto + color #555 + +.posts + max-width 600px + margin 0 auto + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + +.loading + padding 64px 0 + +.empty + display block + margin 0 auto + padding 32px + max-width 400px + text-align center + color #999 + + > [data-fa] + display block + margin-bottom 16px + font-size 3em + color #ccc + +</style> diff --git a/src/client/app/desktop/views/pages/selectdrive.vue b/src/client/app/desktop/views/pages/selectdrive.vue new file mode 100644 index 0000000000..4f0b86014b --- /dev/null +++ b/src/client/app/desktop/views/pages/selectdrive.vue @@ -0,0 +1,177 @@ +<template> +<div class="mkp-selectdrive"> + <mk-drive ref="browser" + :multiple="multiple" + @selected="onSelected" + @change-selection="onChangeSelection" + /> + <footer> + <button class="upload" title="%i18n:desktop.tags.mk-selectdrive-page.upload%" @click="upload">%fa:upload%</button> + <button class="cancel" @click="close">%i18n:desktop.tags.mk-selectdrive-page.cancel%</button> + <button class="ok" @click="ok">%i18n:desktop.tags.mk-selectdrive-page.ok%</button> + </footer> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; + +export default Vue.extend({ + data() { + return { + files: [] + }; + }, + computed: { + multiple(): boolean { + const q = (new URL(location.toString())).searchParams; + return q.get('multiple') == 'true'; + } + }, + mounted() { + document.title = '%i18n:desktop.tags.mk-selectdrive-page.title%'; + }, + methods: { + onSelected(file) { + this.files = [file]; + this.ok(); + }, + onChangeSelection(files) { + this.files = files; + }, + upload() { + (this.$refs.browser as any).selectLocalFile(); + }, + close() { + window.close(); + }, + ok() { + window.opener.cb(this.multiple ? this.files : this.files[0]); + this.close(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mkp-selectdrive + display block + position fixed + width 100% + height 100% + background #fff + + > .mk-drive + height calc(100% - 72px) + + > footer + position fixed + bottom 0 + left 0 + width 100% + 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 + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.followers-you-know.vue b/src/client/app/desktop/views/pages/user/user.followers-you-know.vue new file mode 100644 index 0000000000..d0dab6c3df --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.followers-you-know.vue @@ -0,0 +1,84 @@ +<template> +<div class="followers-you-know"> + <p class="title">%fa:users%%i18n:desktop.tags.mk-user.followers-you-know.title%</p> + <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:desktop.tags.mk-user.followers-you-know.loading%<mk-ellipsis/></p> + <div v-if="!fetching && users.length > 0"> + <router-link v-for="user in users" :to="`/@${getAcct(user)}`" :key="user.id"> + <img :src="`${user.avatarUrl}?thumbnail&size=64`" :alt="user.name" v-user-preview="user.id"/> + </router-link> + </div> + <p class="empty" v-if="!fetching && users.length == 0">%i18n:desktop.tags.mk-user.followers-you-know.no-users%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + data() { + return { + users: [], + fetching: true + }; + }, + method() { + getAcct + }, + mounted() { + (this as any).api('users/followers', { + userId: this.user.id, + iknow: true, + limit: 16 + }).then(x => { + this.users = x.users; + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.followers-you-know + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .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 + + > div + padding 8px + + > a + display inline-block + margin 4px + + > img + width 48px + height 48px + vertical-align bottom + border-radius 100% + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.friends.vue b/src/client/app/desktop/views/pages/user/user.friends.vue new file mode 100644 index 0000000000..3ec30fb438 --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.friends.vue @@ -0,0 +1,124 @@ +<template> +<div class="friends"> + <p class="title">%fa:users%%i18n:desktop.tags.mk-user.frequently-replied-users.title%</p> + <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:desktop.tags.mk-user.frequently-replied-users.loading%<mk-ellipsis/></p> + <template v-if="!fetching && users.length != 0"> + <div class="user" v-for="friend in users"> + <router-link class="avatar-anchor" :to="`/@${getAcct(friend)}`"> + <img class="avatar" :src="`${friend.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="friend.id"/> + </router-link> + <div class="body"> + <router-link class="name" :to="`/@${getAcct(friend)}`" v-user-preview="friend.id">{{ friend.name }}</router-link> + <p class="username">@{{ getAcct(friend) }}</p> + </div> + <mk-follow-button :user="friend"/> + </div> + </template> + <p class="empty" v-if="!fetching && users.length == 0">%i18n:desktop.tags.mk-user.frequently-replied-users.no-users%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + data() { + return { + users: [], + fetching: true + }; + }, + method() { + getAcct + }, + mounted() { + (this as any).api('users/get_frequently_replied_users', { + userId: this.user.id, + limit: 4 + }).then(docs => { + this.users = docs.map(doc => doc.user); + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.friends + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .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 + + > .initializing + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > i + margin-right 4px + + > .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 + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.header.vue b/src/client/app/desktop/views/pages/user/user.header.vue new file mode 100644 index 0000000000..54f431fd2e --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.header.vue @@ -0,0 +1,196 @@ +<template> +<div class="header" :data-is-dark-background="user.bannerUrl != null"> + <div class="banner-container" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=2048)` : ''"> + <div class="banner" ref="banner" :style="user.bannerUrl ? `background-image: url(${user.bannerUrl}?thumbnail&size=2048)` : ''" @click="onBannerClick"></div> + </div> + <div class="fade"></div> + <div class="container"> + <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=150`" alt="avatar"/> + <div class="title"> + <p class="name">{{ user.name }}</p> + <p class="username">@{{ acct }}</p> + <p class="location" v-if="user.host === null && user.account.profile.location">%fa:map-marker%{{ user.account.profile.location }}</p> + </div> + <footer> + <router-link :to="`/@${acct}`" :data-active="$parent.page == 'home'">%fa:home%概要</router-link> + <router-link :to="`/@${acct}/media`" :data-active="$parent.page == 'media'">%fa:image%メディア</router-link> + <router-link :to="`/@${acct}/graphs`" :data-active="$parent.page == 'graphs'">%fa:chart-bar%グラフ</router-link> + </footer> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['user'], + computed: { + acct() { + return getAcct(this.user); + } + }, + mounted() { + window.addEventListener('load', this.onScroll); + window.addEventListener('scroll', this.onScroll); + window.addEventListener('resize', this.onScroll); + }, + beforeDestroy() { + window.removeEventListener('load', this.onScroll); + window.removeEventListener('scroll', this.onScroll); + window.removeEventListener('resize', this.onScroll); + }, + methods: { + onScroll() { + const banner = this.$refs.banner as any; + + const top = window.scrollY; + + const z = 1.25; // 奥行き(小さいほど奥) + const pos = -(top / z); + banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; + + const blur = top / 32 + if (blur <= 10) banner.style.filter = `blur(${blur}px)`; + }, + + onBannerClick() { + if (!(this as any).os.isSignedIn || (this as any).os.i.id != this.user.id) return; + + (this as any).apis.updateBanner((this as any).os.i, i => { + this.user.bannerUrl = i.bannerUrl; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.header + $banner-height = 320px + $footer-height = 58px + + overflow hidden + background #f7f7f7 + box-shadow 0 1px 1px rgba(0, 0, 0, 0.075) + + &[data-is-dark-background] + > .banner-container + > .banner + background-color #383838 + + > .fade + background linear-gradient(transparent, rgba(0, 0, 0, 0.7)) + + > .container + > .title + color #fff + + > .name + text-shadow 0 0 8px #000 + + > .banner-container + height $banner-height + overflow hidden + background-size cover + background-position center + + > .banner + height 100% + background-color #f5f5f5 + background-size cover + background-position center + + > .fade + $fade-hight = 78px + + position absolute + top ($banner-height - $fade-hight) + left 0 + width 100% + height $fade-hight + + > .container + max-width 1200px + margin 0 auto + + > .avatar + display block + position absolute + bottom 16px + left 16px + z-index 2 + width 160px + height 160px + 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 + + > a + display inline-block + margin 0 + padding 0 16px + height $footer-height + line-height $footer-height + color #555 + + &[data-active] + border-bottom solid 4px $theme-color + + > i + margin-right 6px + + > 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 + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.home.vue b/src/client/app/desktop/views/pages/user/user.home.vue new file mode 100644 index 0000000000..071c9bb61c --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.home.vue @@ -0,0 +1,103 @@ +<template> +<div class="home"> + <div> + <div ref="left"> + <x-profile :user="user"/> + <x-photos :user="user"/> + <x-followers-you-know v-if="os.isSignedIn && os.i.id != user.id" :user="user"/> + <p v-if="user.host === null">%i18n:desktop.tags.mk-user.last-used-at%: <b><mk-time :time="user.account.lastUsedAt"/></b></p> + </div> + </div> + <main> + <mk-post-detail v-if="user.pinnedPost" :post="user.pinnedPost" :compact="true"/> + <x-timeline class="timeline" ref="tl" :user="user"/> + </main> + <div> + <div ref="right"> + <mk-calendar @chosen="warp" :start="new Date(user.createdAt)"/> + <mk-activity :user="user"/> + <x-friends :user="user"/> + <div class="nav"><mk-nav/></div> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import XTimeline from './user.timeline.vue'; +import XProfile from './user.profile.vue'; +import XPhotos from './user.photos.vue'; +import XFollowersYouKnow from './user.followers-you-know.vue'; +import XFriends from './user.friends.vue'; + +export default Vue.extend({ + components: { + XTimeline, + XProfile, + XPhotos, + XFollowersYouKnow, + XFriends + }, + props: ['user'], + methods: { + warp(date) { + (this.$refs.tl as any).warp(date); + } + } +}); +</script> + +<style lang="stylus" scoped> +.home + display flex + justify-content center + margin 0 auto + max-width 1200px + + > main + > div > div + > *:not(:last-child) + margin-bottom 16px + + > main + padding 16px + width calc(100% - 275px * 2) + + > .timeline + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > div + width 275px + margin 0 + + &:first-child > div + padding 16px 0 16px 16px + + > p + display block + margin 0 + padding 0 12px + text-align center + font-size 0.8em + color #aaa + + &:last-child > div + padding 16px 16px 16px 0 + + > .nav + padding 16px + font-size 12px + color #aaa + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + a + color #999 + + i + color #ccc + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.photos.vue b/src/client/app/desktop/views/pages/user/user.photos.vue new file mode 100644 index 0000000000..1ff79b4aee --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.photos.vue @@ -0,0 +1,88 @@ +<template> +<div class="photos"> + <p class="title">%fa:camera%%i18n:desktop.tags.mk-user.photos.title%</p> + <p class="initializing" v-if="fetching">%fa:spinner .pulse .fw%%i18n:desktop.tags.mk-user.photos.loading%<mk-ellipsis/></p> + <div class="stream" v-if="!fetching && images.length > 0"> + <div v-for="image in images" class="img" + :style="`background-image: url(${image.url}?thumbnail&size=256)`" + ></div> + </div> + <p class="empty" v-if="!fetching && images.length == 0">%i18n:desktop.tags.mk-user.photos.no-photos%</p> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + data() { + return { + images: [], + fetching: true + }; + }, + mounted() { + (this as any).api('users/posts', { + userId: this.user.id, + withMedia: true, + limit: 9 + }).then(posts => { + posts.forEach(post => { + post.media.forEach(media => { + if (this.images.length < 9) this.images.push(media); + }); + }); + this.fetching = false; + }); + } +}); +</script> + +<style lang="stylus" scoped> +.photos + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .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 + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.profile.vue b/src/client/app/desktop/views/pages/user/user.profile.vue new file mode 100644 index 0000000000..f5562d0915 --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.profile.vue @@ -0,0 +1,138 @@ +<template> +<div class="profile"> + <div class="friend-form" v-if="os.isSignedIn && os.i.id != user.id"> + <mk-follow-button :user="user" size="big"/> + <p class="followed" v-if="user.isFollowed">%i18n:desktop.tags.mk-user.follows-you%</p> + <p v-if="user.isMuted">%i18n:desktop.tags.mk-user.muted% <a @click="unmute">%i18n:desktop.tags.mk-user.unmute%</a></p> + <p v-if="!user.isMuted"><a @click="mute">%i18n:desktop.tags.mk-user.mute%</a></p> + </div> + <div class="description" v-if="user.description">{{ user.description }}</div> + <div class="birthday" v-if="user.host === null && user.account.profile.birthday"> + <p>%fa:birthday-cake%{{ user.account.profile.birthday.replace('-', '年').replace('-', '月') + '日' }} ({{ age }}歳)</p> + </div> + <div class="twitter" v-if="user.host === null && user.account.twitter"> + <p>%fa:B twitter%<a :href="`https://twitter.com/${user.account.twitter.screenName}`" target="_blank">@{{ user.account.twitter.screenName }}</a></p> + </div> + <div class="status"> + <p class="posts-count">%fa:angle-right%<a>{{ user.postsCount }}</a><b>投稿</b></p> + <p class="following">%fa:angle-right%<a @click="showFollowing">{{ user.followingCount }}</a>人を<b>フォロー</b></p> + <p class="followers">%fa:angle-right%<a @click="showFollowers">{{ user.followersCount }}</a>人の<b>フォロワー</b></p> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import * as age from 's-age'; +import MkFollowingWindow from '../../components/following-window.vue'; +import MkFollowersWindow from '../../components/followers-window.vue'; + +export default Vue.extend({ + props: ['user'], + computed: { + age(): number { + return age(this.user.account.profile.birthday); + } + }, + methods: { + showFollowing() { + (this as any).os.new(MkFollowingWindow, { + user: this.user + }); + }, + + showFollowers() { + (this as any).os.new(MkFollowersWindow, { + user: this.user + }); + }, + + mute() { + (this as any).api('mute/create', { + userId: this.user.id + }).then(() => { + this.user.isMuted = true; + }, () => { + alert('error'); + }); + }, + + unmute() { + (this as any).api('mute/delete', { + userId: this.user.id + }).then(() => { + this.user.isMuted = false; + }, () => { + alert('error'); + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.profile + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > *: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 + + > .description + padding 16px + color #555 + border-top solid 1px #eee + + > .birthday + padding 16px + color #555 + border-top solid 1px #eee + + > p + margin 0 + + > i + margin-right 8px + + > .twitter + padding 16px + color #555 + border-top solid 1px #eee + + > p + margin 0 + + > i + margin-right 8px + + > .status + padding 16px + color #555 + border-top solid 1px #eee + + > p + margin 8px 0 + + > i + margin-left 8px + margin-right 8px + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.timeline.vue b/src/client/app/desktop/views/pages/user/user.timeline.vue new file mode 100644 index 0000000000..134ad423ce --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.timeline.vue @@ -0,0 +1,139 @@ +<template> +<div class="timeline"> + <header> + <span :data-is-active="mode == 'default'" @click="mode = 'default'">投稿</span> + <span :data-is-active="mode == 'with-replies'" @click="mode = 'with-replies'">投稿と返信</span> + </header> + <div class="loading" v-if="fetching"> + <mk-ellipsis-icon/> + </div> + <p class="empty" v-if="empty">%fa:R comments%このユーザーはまだ何も投稿していないようです。</p> + <mk-posts ref="timeline" :posts="posts"> + <div slot="footer"> + <template v-if="!moreFetching">%fa:moon%</template> + <template v-if="moreFetching">%fa:spinner .pulse .fw%</template> + </div> + </mk-posts> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + props: ['user'], + data() { + return { + fetching: true, + moreFetching: false, + mode: 'default', + unreadCount: 0, + posts: [], + date: null + }; + }, + watch: { + mode() { + this.fetch(); + } + }, + computed: { + empty(): boolean { + return this.posts.length == 0; + } + }, + mounted() { + document.addEventListener('keydown', this.onDocumentKeydown); + window.addEventListener('scroll', this.onScroll); + + this.fetch(() => this.$emit('loaded')); + }, + beforeDestroy() { + document.removeEventListener('keydown', this.onDocumentKeydown); + window.removeEventListener('scroll', this.onScroll); + }, + methods: { + onDocumentKeydown(e) { + if (e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') { + if (e.which == 84) { // [t] + (this.$refs.timeline as any).focus(); + } + } + }, + fetch(cb?) { + (this as any).api('users/posts', { + userId: this.user.id, + untilDate: this.date ? this.date.getTime() : undefined, + with_replies: this.mode == 'with-replies' + }).then(posts => { + this.posts = posts; + this.fetching = false; + if (cb) cb(); + }); + }, + more() { + if (this.moreFetching || this.fetching || this.posts.length == 0) return; + this.moreFetching = true; + (this as any).api('users/posts', { + userId: this.user.id, + with_replies: this.mode == 'with-replies', + untilId: this.posts[this.posts.length - 1].id + }).then(posts => { + this.moreFetching = false; + this.posts = this.posts.concat(posts); + }); + }, + onScroll() { + const current = window.scrollY + window.innerHeight; + if (current > document.body.offsetHeight - 16/*遊び*/) { + this.more(); + } + }, + warp(date) { + this.date = date; + this.fetch(); + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.timeline + 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 + + > [data-fa] + display block + margin-bottom 16px + font-size 3em + color #ccc + +</style> diff --git a/src/client/app/desktop/views/pages/user/user.vue b/src/client/app/desktop/views/pages/user/user.vue new file mode 100644 index 0000000000..67cef93269 --- /dev/null +++ b/src/client/app/desktop/views/pages/user/user.vue @@ -0,0 +1,53 @@ +<template> +<mk-ui> + <div class="user" v-if="!fetching"> + <x-header :user="user"/> + <x-home v-if="page == 'home'" :user="user"/> + </div> +</mk-ui> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import parseAcct from '../../../../../../common/user/parse-acct'; +import Progress from '../../../../common/scripts/loading'; +import XHeader from './user.header.vue'; +import XHome from './user.home.vue'; + +export default Vue.extend({ + components: { + XHeader, + XHome + }, + props: { + page: { + default: 'home' + } + }, + data() { + return { + fetching: true, + user: null + }; + }, + watch: { + $route: 'fetch' + }, + created() { + this.fetch(); + }, + methods: { + fetch() { + this.fetching = true; + Progress.start(); + (this as any).api('users/show', parseAcct(this.$route.params.user)).then(user => { + this.user = user; + this.fetching = false; + Progress.done(); + document.title = user.name + ' | Misskey'; + }); + } + } +}); +</script> + diff --git a/src/client/app/desktop/views/pages/welcome.vue b/src/client/app/desktop/views/pages/welcome.vue new file mode 100644 index 0000000000..34c28854b1 --- /dev/null +++ b/src/client/app/desktop/views/pages/welcome.vue @@ -0,0 +1,319 @@ +<template> +<div class="mk-welcome"> + <main> + <div class="top"> + <div> + <div> + <h1>Share<br><span ref="share">Everything!</span><span class="cursor">_</span></h1> + <p>ようこそ! <b>Misskey</b>はTwitter風ミニブログSNSです。思ったことや皆と共有したいことを投稿しましょう。タイムラインを見れば、皆の関心事をすぐにチェックすることもできます。<a :href="aboutUrl">詳しく...</a></p> + <p><button class="signup" @click="signup">はじめる</button><button class="signin" @click="signin">ログイン</button></p> + <div class="users"> + <router-link v-for="user in users" :key="user.id" class="avatar-anchor" :to="`/@${getAcct(user)}`" v-user-preview="user.id"> + <img class="avatar" :src="`${user.avatarUrl}?thumbnail&size=64`" alt="avatar"/> + </router-link> + </div> + </div> + <div> + <div> + <header>%fa:comments R% タイムライン<div><span></span><span></span><span></span></div></header> + <mk-welcome-timeline/> + </div> + </div> + </div> + </div> + </main> + <mk-forkit/> + <footer> + <div> + <mk-nav :class="$style.nav"/> + <p class="c">{{ copyright }}</p> + </div> + </footer> + <modal name="signup" width="500px" height="auto" scrollable> + <header :class="$style.signupFormHeader">新規登録</header> + <mk-signup :class="$style.signupForm"/> + </modal> + <modal name="signin" width="500px" height="auto" scrollable> + <header :class="$style.signinFormHeader">ログイン</header> + <mk-signin :class="$style.signinForm"/> + </modal> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import { docsUrl, copyright, lang } from '../../../config'; +import getAcct from '../../../../../common/user/get-acct'; + +const shares = [ + 'Everything!', + 'Webpages', + 'Photos', + 'Interests', + 'Favorites' +]; + +export default Vue.extend({ + data() { + return { + aboutUrl: `${docsUrl}/${lang}/about`, + copyright, + users: [], + clock: null, + i: 0 + }; + }, + mounted() { + (this as any).api('users', { + sort: '+follower', + limit: 20 + }).then(users => { + this.users = users; + }); + + this.clock = setInterval(() => { + if (++this.i == shares.length) this.i = 0; + const speed = 70; + const text = (this.$refs.share as any).innerText; + for (let i = 0; i < text.length; i++) { + setTimeout(() => { + if (this.$refs.share) { + (this.$refs.share as any).innerText = text.substr(0, text.length - i); + } + }, i * speed) + } + setTimeout(() => { + const newText = shares[this.i]; + for (let i = 0; i <= newText.length; i++) { + setTimeout(() => { + if (this.$refs.share) { + (this.$refs.share as any).innerText = newText.substr(0, i); + } + }, i * speed) + } + }, text.length * speed); + }, 4000); + }, + beforeDestroy() { + clearInterval(this.clock); + }, + methods: { + getAcct, + signup() { + this.$modal.show('signup'); + }, + signin() { + this.$modal.show('signin'); + } + } +}); +</script> + +<style> +#wait { + right: auto; + left: 15px; +} +</style> + +<style lang="stylus" scoped> +@import '~const.styl' + +@import url('https://fonts.googleapis.com/css?family=Sarpanch:700') + +.mk-welcome + display flex + flex-direction column + flex 1 + $width = 1000px + + background-image url('/assets/welcome-bg.svg') + background-size cover + background-position top center + + &:before + content "" + display block + position fixed + bottom 0 + left 0 + width 100% + height 100% + background-image url('/assets/welcome-fg.svg') + background-size cover + background-position bottom center + + > main + display flex + flex 1 + + > .top + display flex + width 100% + + > div + display flex + max-width $width + 64px + margin 0 auto + padding 80px 32px 0 32px + + > * + margin-bottom 48px + + > div:first-child + margin-right 48px + color #fff + text-shadow 0 0 12px #172062 + + > h1 + margin 0 + font-weight bold + //font-variant small-caps + letter-spacing 12px + font-family 'Sarpanch', sans-serif + font-size 42px + line-height 48px + + > .cursor + animation cursor 1s infinite linear both + + @keyframes cursor + 0% + opacity 1 + 50% + opacity 0 + + > p + margin 1em 0 + line-height 2em + + button + padding 8px 16px + font-size inherit + + .signup + color $theme-color + border solid 2px $theme-color + border-radius 4px + + &:focus + box-shadow 0 0 0 3px rgba($theme-color, 0.2) + + &:hover + color $theme-color-foreground + background $theme-color + + &:active + color $theme-color-foreground + background darken($theme-color, 10%) + border-color darken($theme-color, 10%) + + .signin + &:hover + color #fff + + > .users + margin 16px 0 0 0 + + > * + display inline-block + margin 4px + + > * + display inline-block + width 38px + height 38px + vertical-align top + border-radius 6px + + > div:last-child + + > div + width 410px + background #fff + border-radius 8px + box-shadow 0 0 0 12px rgba(0, 0, 0, 0.1) + overflow hidden + + > header + z-index 1 + padding 12px 16px + color #888d94 + box-shadow 0 1px 0px rgba(0, 0, 0, 0.1) + + > div + position absolute + top 0 + right 0 + padding inherit + + > span + display inline-block + height 11px + width 11px + margin-left 6px + background #ccc + border-radius 100% + vertical-align middle + + &:nth-child(1) + background #5BCC8B + + &:nth-child(2) + background #E6BB46 + + &:nth-child(3) + background #DF7065 + + > .mk-welcome-timeline + max-height 350px + overflow auto + + > footer + font-size 12px + color #949ea5 + + > div + max-width $width + margin 0 auto + padding 0 0 42px 0 + text-align center + + > .c + margin 16px 0 0 0 + font-size 10px + opacity 0.7 + +</style> + +<style lang="stylus" module> +.signupForm + padding 24px 48px 48px 48px + +.signupFormHeader + padding 48px 0 12px 0 + margin: 0 48px + font-size 1.5em + color #777 + border-bottom solid 1px #eee + +.signinForm + padding 24px 48px 48px 48px + +.signinFormHeader + padding 48px 0 12px 0 + margin: 0 48px + font-size 1.5em + color #777 + border-bottom solid 1px #eee + +.nav + a + color #666 +</style> + +<style lang="stylus"> +html +body + background linear-gradient(to bottom, #1e1d65, #bd6659) +</style> diff --git a/src/client/app/desktop/views/widgets/activity.vue b/src/client/app/desktop/views/widgets/activity.vue new file mode 100644 index 0000000000..0bdf4622af --- /dev/null +++ b/src/client/app/desktop/views/widgets/activity.vue @@ -0,0 +1,31 @@ +<template> +<mk-activity + :design="props.design" + :init-view="props.view" + :user="os.i" + @view-changed="viewChanged"/> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'activity', + props: () => ({ + design: 0, + view: 0 + }) +}).extend({ + methods: { + func() { + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + }, + viewChanged(view) { + this.props.view = view; + } + } +}); +</script> diff --git a/src/client/app/desktop/views/widgets/channel.channel.form.vue b/src/client/app/desktop/views/widgets/channel.channel.form.vue new file mode 100644 index 0000000000..aaf327f1ef --- /dev/null +++ b/src/client/app/desktop/views/widgets/channel.channel.form.vue @@ -0,0 +1,67 @@ +<template> +<div class="form"> + <input v-model="text" :disabled="wait" @keydown="onKeydown" placeholder="書いて"> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +export default Vue.extend({ + data() { + return { + text: '', + wait: false + }; + }, + methods: { + onKeydown(e) { + if (e.which == 10 || e.which == 13) this.post(); + }, + post() { + this.wait = true; + + let reply = null; + + if (/^>>([0-9]+) /.test(this.text)) { + const index = this.text.match(/^>>([0-9]+) /)[1]; + reply = (this.$parent as any).posts.find(p => p.index.toString() == index); + this.text = this.text.replace(/^>>([0-9]+) /, ''); + } + + (this as any).api('posts/create', { + text: this.text, + replyId: reply ? reply.id : undefined, + channelId: (this.$parent as any).channel.id + }).then(data => { + this.text = ''; + }).catch(err => { + alert('失敗した'); + }).then(() => { + this.wait = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.form + width 100% + height 38px + padding 4px + border-top solid 1px #ddd + + > input + padding 0 8px + width 100% + height 100% + font-size 14px + color #55595c + border solid 1px #dadada + border-radius 4px + + &:hover + &:focus + border-color #aeaeae + +</style> diff --git a/src/client/app/desktop/views/widgets/channel.channel.post.vue b/src/client/app/desktop/views/widgets/channel.channel.post.vue new file mode 100644 index 0000000000..433f9a00aa --- /dev/null +++ b/src/client/app/desktop/views/widgets/channel.channel.post.vue @@ -0,0 +1,71 @@ +<template> +<div class="post"> + <header> + <a class="index" @click="reply">{{ post.index }}:</a> + <router-link class="name" :to="`/@${acct}`" v-user-preview="post.user.id"><b>{{ post.user.name }}</b></router-link> + <span>ID:<i>{{ acct }}</i></span> + </header> + <div> + <a v-if="post.reply">>>{{ post.reply.index }}</a> + {{ post.text }} + <div class="media" v-if="post.media"> + <a v-for="file in post.media" :href="file.url" target="_blank"> + <img :src="`${file.url}?thumbnail&size=512`" :alt="file.name" :title="file.name"/> + </a> + </div> + </div> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import getAcct from '../../../../../common/user/get-acct'; + +export default Vue.extend({ + props: ['post'], + computed: { + acct() { + return getAcct(this.post.user); + } + }, + methods: { + reply() { + this.$emit('reply', this.post); + } + } +}); +</script> + +<style lang="stylus" scoped> +.post + margin 0 + padding 0 + color #444 + + > header + position -webkit-sticky + position sticky + z-index 1 + top 0 + padding 8px 4px 4px 16px + background rgba(255, 255, 255, 0.9) + + > .index + margin-right 0.25em + + > .name + margin-right 0.5em + color #008000 + + > div + padding 0 16px 16px 16px + + > .media + > a + display inline-block + + > img + max-width 100% + vertical-align bottom + +</style> diff --git a/src/client/app/desktop/views/widgets/channel.channel.vue b/src/client/app/desktop/views/widgets/channel.channel.vue new file mode 100644 index 0000000000..e9fb9e3fd7 --- /dev/null +++ b/src/client/app/desktop/views/widgets/channel.channel.vue @@ -0,0 +1,106 @@ +<template> +<div class="channel"> + <p v-if="fetching">読み込み中<mk-ellipsis/></p> + <div v-if="!fetching" ref="posts" class="posts"> + <p v-if="posts.length == 0">まだ投稿がありません</p> + <x-post class="post" v-for="post in posts.slice().reverse()" :post="post" :key="post.id" @reply="reply"/> + </div> + <x-form class="form" ref="form"/> +</div> +</template> + +<script lang="ts"> +import Vue from 'vue'; +import ChannelStream from '../../../common/scripts/streaming/channel'; +import XForm from './channel.channel.form.vue'; +import XPost from './channel.channel.post.vue'; + +export default Vue.extend({ + components: { + XForm, + XPost + }, + props: ['channel'], + data() { + return { + fetching: true, + posts: [], + connection: null + }; + }, + watch: { + channel() { + this.zap(); + } + }, + mounted() { + this.zap(); + }, + beforeDestroy() { + this.disconnect(); + }, + methods: { + zap() { + this.fetching = true; + + (this as any).api('channels/posts', { + channelId: this.channel.id + }).then(posts => { + this.posts = posts; + this.fetching = false; + + this.$nextTick(() => { + this.scrollToBottom(); + }); + + this.disconnect(); + this.connection = new ChannelStream((this as any).os, this.channel.id); + this.connection.on('post', this.onPost); + }); + }, + disconnect() { + if (this.connection) { + this.connection.off('post', this.onPost); + this.connection.close(); + } + }, + onPost(post) { + this.posts.unshift(post); + this.scrollToBottom(); + }, + scrollToBottom() { + (this.$refs.posts as any).scrollTop = (this.$refs.posts as any).scrollHeight; + }, + reply(post) { + (this.$refs.form as any).text = `>>${ post.index } `; + } + } +}); +</script> + +<style lang="stylus" scoped> +.channel + + > p + margin 0 + padding 16px + text-align center + color #aaa + + > .posts + height calc(100% - 38px) + overflow auto + font-size 0.9em + + > .post + border-bottom solid 1px #eee + + &:last-child + border-bottom none + + > .form + position absolute + left 0 + bottom 0 + +</style> diff --git a/src/client/app/desktop/views/widgets/channel.vue b/src/client/app/desktop/views/widgets/channel.vue new file mode 100644 index 0000000000..c9b62dfeab --- /dev/null +++ b/src/client/app/desktop/views/widgets/channel.vue @@ -0,0 +1,107 @@ +<template> +<div class="mkw-channel"> + <template v-if="!props.compact"> + <p class="title">%fa:tv%{{ channel ? channel.title : '%i18n:desktop.tags.mk-channel-home-widget.title%' }}</p> + <button @click="settings" title="%i18n:desktop.tags.mk-channel-home-widget.settings%">%fa:cog%</button> + </template> + <p class="get-started" v-if="props.channel == null">%i18n:desktop.tags.mk-channel-home-widget.get-started%</p> + <x-channel class="channel" :channel="channel" v-if="channel != null"/> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import XChannel from './channel.channel.vue'; + +export default define({ + name: 'server', + props: () => ({ + channel: null, + compact: false + }) +}).extend({ + components: { + XChannel + }, + data() { + return { + fetching: true, + channel: null + }; + }, + mounted() { + if (this.props.channel) { + this.zap(); + } + }, + methods: { + func() { + this.props.compact = !this.props.compact; + }, + settings() { + const id = window.prompt('チャンネルID'); + if (!id) return; + this.props.channel = id; + this.zap(); + }, + zap() { + this.fetching = true; + + (this as any).api('channels/show', { + channelId: this.props.channel + }).then(channel => { + this.channel = channel; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-channel + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + overflow hidden + + > .title + z-index 2 + 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) + + > [data-fa] + 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 + + > .get-started + margin 0 + padding 16px + text-align center + color #aaa + + > .channel + height 200px + +</style> diff --git a/src/client/app/desktop/views/widgets/index.ts b/src/client/app/desktop/views/widgets/index.ts new file mode 100644 index 0000000000..77d771d6b3 --- /dev/null +++ b/src/client/app/desktop/views/widgets/index.ts @@ -0,0 +1,23 @@ +import Vue from 'vue'; + +import wNotifications from './notifications.vue'; +import wTimemachine from './timemachine.vue'; +import wActivity from './activity.vue'; +import wTrends from './trends.vue'; +import wUsers from './users.vue'; +import wPolls from './polls.vue'; +import wPostForm from './post-form.vue'; +import wMessaging from './messaging.vue'; +import wChannel from './channel.vue'; +import wProfile from './profile.vue'; + +Vue.component('mkw-notifications', wNotifications); +Vue.component('mkw-timemachine', wTimemachine); +Vue.component('mkw-activity', wActivity); +Vue.component('mkw-trends', wTrends); +Vue.component('mkw-users', wUsers); +Vue.component('mkw-polls', wPolls); +Vue.component('mkw-post-form', wPostForm); +Vue.component('mkw-messaging', wMessaging); +Vue.component('mkw-channel', wChannel); +Vue.component('mkw-profile', wProfile); diff --git a/src/client/app/desktop/views/widgets/messaging.vue b/src/client/app/desktop/views/widgets/messaging.vue new file mode 100644 index 0000000000..2c9f473bd1 --- /dev/null +++ b/src/client/app/desktop/views/widgets/messaging.vue @@ -0,0 +1,59 @@ +<template> +<div class="mkw-messaging"> + <p class="title" v-if="props.design == 0">%fa:comments%%i18n:desktop.tags.mk-messaging-home-widget.title%</p> + <mk-messaging ref="index" compact @navigate="navigate"/> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import MkMessagingRoomWindow from '../components/messaging-room-window.vue'; + +export default define({ + name: 'messaging', + props: () => ({ + design: 0 + }) +}).extend({ + methods: { + navigate(user) { + (this as any).os.new(MkMessagingRoomWindow, { + user: user + }); + }, + func() { + if (this.props.design == 1) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-messaging + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + z-index 2 + 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) + + > [data-fa] + margin-right 4px + + > .mk-messaging + max-height 250px + overflow auto + +</style> diff --git a/src/client/app/desktop/views/widgets/notifications.vue b/src/client/app/desktop/views/widgets/notifications.vue new file mode 100644 index 0000000000..1a2b3d3f89 --- /dev/null +++ b/src/client/app/desktop/views/widgets/notifications.vue @@ -0,0 +1,70 @@ +<template> +<div class="mkw-notifications"> + <template v-if="!props.compact"> + <p class="title">%fa:R bell%%i18n:desktop.tags.mk-notifications-home-widget.title%</p> + <button @click="settings" title="%i18n:desktop.tags.mk-notifications-home-widget.settings%">%fa:cog%</button> + </template> + <mk-notifications/> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'notifications', + props: () => ({ + compact: false + }) +}).extend({ + methods: { + settings() { + alert('not implemented yet'); + }, + func() { + this.props.compact = !this.props.compact; + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-notifications + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .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) + + > [data-fa] + 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 + +</style> diff --git a/src/client/app/desktop/views/widgets/polls.vue b/src/client/app/desktop/views/widgets/polls.vue new file mode 100644 index 0000000000..e5db34fc7a --- /dev/null +++ b/src/client/app/desktop/views/widgets/polls.vue @@ -0,0 +1,129 @@ +<template> +<div class="mkw-polls"> + <template v-if="!props.compact"> + <p class="title">%fa:chart-pie%%i18n:desktop.tags.mk-recommended-polls-home-widget.title%</p> + <button @click="fetch" title="%i18n:desktop.tags.mk-recommended-polls-home-widget.refresh%">%fa:sync%</button> + </template> + <div class="poll" v-if="!fetching && poll != null"> + <p v-if="poll.text"><router-link to="`/@${ acct }/${ poll.id }`">{{ poll.text }}</router-link></p> + <p v-if="!poll.text"><router-link to="`/@${ acct }/${ poll.id }`">%fa:link%</router-link></p> + <mk-poll :post="poll"/> + </div> + <p class="empty" v-if="!fetching && poll == null">%i18n:desktop.tags.mk-recommended-polls-home-widget.nothing%</p> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import getAcct from '../../../../../common/user/get-acct'; + +export default define({ + name: 'polls', + props: () => ({ + compact: false + }) +}).extend({ + computed: { + acct() { + return getAcct(this.poll.user); + }, + }, + data() { + return { + poll: null, + fetching: true, + offset: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + func() { + this.props.compact = !this.props.compact; + }, + fetch() { + this.fetching = true; + this.poll = null; + + (this as any).api('posts/polls/recommendation', { + limit: 1, + offset: this.offset + }).then(posts => { + const poll = posts ? posts[0] : null; + if (poll == null) { + this.offset = 0; + } else { + this.offset++; + } + this.poll = poll; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-polls + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + border-bottom solid 1px #eee + + > [data-fa] + 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 + + > .poll + padding 16px + font-size 12px + color #555 + + > p + margin 0 0 8px 0 + + > a + color inherit + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/widgets/post-form.vue b/src/client/app/desktop/views/widgets/post-form.vue new file mode 100644 index 0000000000..cf7fd1f2b2 --- /dev/null +++ b/src/client/app/desktop/views/widgets/post-form.vue @@ -0,0 +1,111 @@ +<template> +<div class="mkw-post-form"> + <template v-if="props.design == 0"> + <p class="title">%fa:pencil-alt%%i18n:desktop.tags.mk-post-form-home-widget.title%</p> + </template> + <textarea :disabled="posting" v-model="text" @keydown="onKeydown" placeholder="%i18n:desktop.tags.mk-post-form-home-widget.placeholder%"></textarea> + <button @click="post" :disabled="posting">%i18n:desktop.tags.mk-post-form-home-widget.post%</button> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'post-form', + props: () => ({ + design: 0 + }) +}).extend({ + data() { + return { + posting: false, + text: '' + }; + }, + methods: { + func() { + if (this.props.design == 1) { + this.props.design = 0; + } else { + this.props.design++; + } + }, + onKeydown(e) { + if ((e.which == 10 || e.which == 13) && (e.ctrlKey || e.metaKey)) this.post(); + }, + post() { + this.posting = true; + + (this as any).api('posts/create', { + text: this.text + }).then(data => { + this.clear(); + }).catch(err => { + alert('失敗した'); + }).then(() => { + this.posting = false; + }); + }, + clear() { + this.text = ''; + } + } +}); +</script> + +<style lang="stylus" scoped> +@import '~const.styl' + +.mkw-post-form + background #fff + overflow hidden + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .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) + + > [data-fa] + margin-right 4px + + > textarea + display block + width 100% + max-width 100% + min-width 100% + padding 16px + margin-bottom 28px + 16px + border none + border-bottom solid 1px #eee + + > button + display block + position absolute + bottom 8px + right 8px + margin 0 + padding 0 10px + height 28px + color $theme-color-foreground + background $theme-color !important + outline none + border none + border-radius 4px + transition background 0.1s ease + cursor pointer + + &:hover + background lighten($theme-color, 10%) !important + + &:active + background darken($theme-color, 10%) !important + transition background 0s ease + +</style> diff --git a/src/client/app/desktop/views/widgets/profile.vue b/src/client/app/desktop/views/widgets/profile.vue new file mode 100644 index 0000000000..83cd67b50c --- /dev/null +++ b/src/client/app/desktop/views/widgets/profile.vue @@ -0,0 +1,125 @@ +<template> +<div class="mkw-profile" + :data-compact="props.design == 1 || props.design == 2" + :data-melt="props.design == 2" +> + <div class="banner" + :style="os.i.bannerUrl ? `background-image: url(${os.i.bannerUrl}?thumbnail&size=256)` : ''" + title="クリックでバナー編集" + @click="os.apis.updateBanner" + ></div> + <img class="avatar" + :src="`${os.i.avatarUrl}?thumbnail&size=96`" + @click="os.apis.updateAvatar" + alt="avatar" + title="クリックでアバター編集" + v-user-preview="os.i.id" + /> + <router-link class="name" :to="`/@${os.i.username}`">{{ os.i.name }}</router-link> + <p class="username">@{{ os.i.username }}</p> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'profile', + props: () => ({ + design: 0 + }) +}).extend({ + methods: { + func() { + if (this.props.design == 2) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-profile + overflow hidden + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + &[data-compact] + > .banner:before + content "" + display block + width 100% + height 100% + background rgba(0, 0, 0, 0.5) + + > .avatar + top ((100px - 58px) / 2) + left ((100px - 58px) / 2) + border none + border-radius 100% + box-shadow 0 0 16px rgba(0, 0, 0, 0.5) + + > .name + position absolute + top 0 + left 92px + margin 0 + line-height 100px + color #fff + text-shadow 0 0 8px rgba(0, 0, 0, 0.5) + + > .username + display none + + &[data-melt] + background transparent !important + border none !important + + > .banner + visibility hidden + + > .avatar + box-shadow none + + > .name + color #666 + text-shadow none + + > .banner + height 100px + background-color #f5f5f5 + background-size cover + background-position center + cursor pointer + + > .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 + cursor pointer + + > .name + display block + margin 10px 0 0 84px + line-height 16px + font-weight bold + color #555 + + > .username + display block + margin 4px 0 8px 84px + line-height 16px + font-size 0.9em + color #999 + +</style> diff --git a/src/client/app/desktop/views/widgets/timemachine.vue b/src/client/app/desktop/views/widgets/timemachine.vue new file mode 100644 index 0000000000..6db3b14c62 --- /dev/null +++ b/src/client/app/desktop/views/widgets/timemachine.vue @@ -0,0 +1,28 @@ +<template> +<div class="mkw-timemachine"> + <mk-calendar :design="props.design" @chosen="chosen"/> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +export default define({ + name: 'timemachine', + props: () => ({ + design: 0 + }) +}).extend({ + methods: { + chosen(date) { + this.$emit('chosen', date); + }, + func() { + if (this.props.design == 5) { + this.props.design = 0; + } else { + this.props.design++; + } + } + } +}); +</script> diff --git a/src/client/app/desktop/views/widgets/trends.vue b/src/client/app/desktop/views/widgets/trends.vue new file mode 100644 index 0000000000..77779787ee --- /dev/null +++ b/src/client/app/desktop/views/widgets/trends.vue @@ -0,0 +1,135 @@ +<template> +<div class="mkw-trends"> + <template v-if="!props.compact"> + <p class="title">%fa:fire%%i18n:desktop.tags.mk-trends-home-widget.title%</p> + <button @click="fetch" title="%i18n:desktop.tags.mk-trends-home-widget.refresh%">%fa:sync%</button> + </template> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <div class="post" v-else-if="post != null"> + <p class="text"><router-link :to="`/@${ acct }/${ post.id }`">{{ post.text }}</router-link></p> + <p class="author">―<router-link :to="`/@${ acct }`">@{{ acct }}</router-link></p> + </div> + <p class="empty" v-else>%i18n:desktop.tags.mk-trends-home-widget.nothing%</p> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import getAcct from '../../../../../common/user/get-acct'; + +export default define({ + name: 'trends', + props: () => ({ + compact: false + }) +}).extend({ + computed: { + acct() { + return getAcct(this.post.user); + }, + }, + data() { + return { + post: null, + fetching: true, + offset: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + func() { + this.props.compact = !this.props.compact; + }, + fetch() { + this.fetching = true; + this.post = null; + + (this as any).api('posts/trend', { + limit: 1, + offset: this.offset, + repost: false, + reply: false, + media: false, + poll: false + }).then(posts => { + const post = posts ? posts[0] : null; + if (post == null) { + this.offset = 0; + } else { + this.offset++; + } + this.post = post; + this.fetching = false; + }); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-trends + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + border-bottom solid 1px #eee + + > [data-fa] + 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 + + > .post + padding 16px + font-size 12px + font-style oblique + color #555 + + > p + margin 0 + + > .text, + > .author + > a + color inherit + + > .empty + margin 0 + padding 16px + text-align center + color #aaa + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> diff --git a/src/client/app/desktop/views/widgets/users.vue b/src/client/app/desktop/views/widgets/users.vue new file mode 100644 index 0000000000..7b89441126 --- /dev/null +++ b/src/client/app/desktop/views/widgets/users.vue @@ -0,0 +1,172 @@ +<template> +<div class="mkw-users"> + <template v-if="!props.compact"> + <p class="title">%fa:users%%i18n:desktop.tags.mk-user-recommendation-home-widget.title%</p> + <button @click="refresh" title="%i18n:desktop.tags.mk-user-recommendation-home-widget.refresh%">%fa:sync%</button> + </template> + <p class="fetching" v-if="fetching">%fa:spinner .pulse .fw%%i18n:common.loading%<mk-ellipsis/></p> + <template v-else-if="users.length != 0"> + <div class="user" v-for="_user in users"> + <router-link class="avatar-anchor" :to="`/@${getAcct(_user)}`"> + <img class="avatar" :src="`${_user.avatarUrl}?thumbnail&size=42`" alt="" v-user-preview="_user.id"/> + </router-link> + <div class="body"> + <router-link class="name" :to="`/@${getAcct(_user)}`" v-user-preview="_user.id">{{ _user.name }}</router-link> + <p class="username">@{{ getAcct(_user) }}</p> + </div> + <mk-follow-button :user="_user"/> + </div> + </template> + <p class="empty" v-else>%i18n:desktop.tags.mk-user-recommendation-home-widget.no-one%</p> +</div> +</template> + +<script lang="ts"> +import define from '../../../common/define-widget'; +import getAcct from '../../../../../common/user/get-acct'; + +const limit = 3; + +export default define({ + name: 'users', + props: () => ({ + compact: false + }) +}).extend({ + data() { + return { + users: [], + fetching: true, + page: 0 + }; + }, + mounted() { + this.fetch(); + }, + methods: { + getAcct, + func() { + this.props.compact = !this.props.compact; + }, + fetch() { + this.fetching = true; + this.users = []; + + (this as any).api('users/recommendation', { + limit: limit, + offset: limit * this.page + }).then(users => { + this.users = users; + this.fetching = false; + }); + }, + refresh() { + if (this.users.length < limit) { + this.page = 0; + } else { + this.page++; + } + this.fetch(); + } + } +}); +</script> + +<style lang="stylus" scoped> +.mkw-users + background #fff + border solid 1px rgba(0, 0, 0, 0.075) + border-radius 6px + + > .title + margin 0 + padding 0 16px + line-height 42px + font-size 0.9em + font-weight bold + color #888 + border-bottom solid 1px #eee + + > [data-fa] + 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 + + > .fetching + margin 0 + padding 16px + text-align center + color #aaa + + > [data-fa] + margin-right 4px + +</style> |