diff options
| author | syuilo <Syuilotan@yahoo.co.jp> | 2022-07-07 21:23:03 +0900 |
|---|---|---|
| committer | syuilo <Syuilotan@yahoo.co.jp> | 2022-07-07 21:23:03 +0900 |
| commit | 84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b (patch) | |
| tree | a182502a5192992d873e7a7fcbf01662bb0dfca2 /packages/client | |
| parent | Merge pull request #8821 from misskey-dev/develop (diff) | |
| parent | 12.112.1 (diff) | |
| download | misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.tar.gz misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.tar.bz2 misskey-84d984bd31fa9863c3fe2e1aeb672ad0e2e8de4b.zip | |
Merge branch 'develop'
Diffstat (limited to 'packages/client')
317 files changed, 14081 insertions, 11797 deletions
diff --git a/packages/client/.eslintrc.js b/packages/client/.eslintrc.js index 10f0e5a9cb..a5a4fd0f43 100644 --- a/packages/client/.eslintrc.js +++ b/packages/client/.eslintrc.js @@ -22,10 +22,8 @@ module.exports = { }, ], // window の禁止理由: グローバルスコープと衝突し、予期せぬ結果を招くため - // data の禁止理由: 抽象的すぎるため // e の禁止理由: error や event など、複数のキーワードの頭文字であり分かりにくいため - 'id-denylist': ['error', 'window', 'data', 'e'], - 'eqeqeq': ['error', 'always', { 'null': 'ignore' }], + 'id-denylist': ['error', 'window', 'e'], 'no-shadow': ['warn'], 'vue/attributes-order': ['error', { 'alphabetical': false, @@ -70,6 +68,7 @@ module.exports = { // Vue '$$': false, '$ref': false, + '$shallowRef': false, '$computed': false, // Misskey diff --git a/packages/client/assets/tagcanvas.min.js b/packages/client/assets/tagcanvas.min.js new file mode 100644 index 0000000000..bcee46e682 --- /dev/null +++ b/packages/client/assets/tagcanvas.min.js @@ -0,0 +1,21 @@ +/** + * Copyright (C) 2010-2021 Graham Breach + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Lesser General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program. If not, see <http://www.gnu.org/licenses/>. + */ +/** + * TagCanvas 2.11 + * For more information, please contact <graham@goat1000.com> + */ + (function(){"use strict";var r,C,p=Math.abs,o=Math.sin,l=Math.cos,g=Math.max,h=Math.min,af=Math.ceil,E=Math.sqrt,w=Math.pow,I={},D={},R={0:"0,",1:"17,",2:"34,",3:"51,",4:"68,",5:"85,",6:"102,",7:"119,",8:"136,",9:"153,",a:"170,",A:"170,",b:"187,",B:"187,",c:"204,",C:"204,",d:"221,",D:"221,",e:"238,",E:"238,",f:"255,",F:"255,"},f,d,b,T,z,F,M,c=document,v,e,P,j={};for(r=0;r<256;++r)C=r.toString(16),r<16&&(C='0'+C),D[C]=D[C.toUpperCase()]=r.toString()+',';function n(a){return typeof a!='undefined'}function B(a){return typeof a=='object'&&a!=null}function G(a,c,b){return isNaN(a)?b:h(b,g(c,a))}function x(){return!1}function q(){return(new Date).valueOf()}function ak(c,d){var b=[],e=c.length,a;for(a=0;a<e;++a)b.push(c[a]);return b.sort(d),b}function ai(a){for(var b=a.length-1,d,c;b;)c=~~(Math.random()*b),d=a[b],a[b]=a[c],a[c]=d,--b}function ag(){var a=window.AudioContext||window.webkitAudioContext;if(e=new a,!e){e='off';return}return e}function $(u,a,t,s,r,q,p){var j=s,h=r,i=t*.01,n=80*i,m=100*i,o=40*i,l=30*i,c=l/2,b=j+n,f=b-o,k=h+m,d=k-l,g=h+l,e=h+m/2;if(a.setTransform(1,0,0,1,0,0),a.setLineDash([]),a.globalAlpha=1,a.strokeStyle=p,a.lineWidth=q,a.lineJoin='round',a.beginPath(),a.moveTo(f,g),a.lineTo(f,d),a.moveTo(b,k),a.lineTo(f,d),a.lineTo(j,d),a.lineTo(j,g),a.lineTo(f,g),a.lineTo(b,h),u){a.lineTo(b,g),a.moveTo(b,d),a.lineTo(b,k),a.moveTo(b-c,e-c),a.lineTo(b+c,e+c),a.moveTo(b+c,e-c),a.lineTo(b-c,e+c),a.stroke();return}a.closePath(),a.stroke()}function s(a,b,c){this.x=a,this.y=b,this.z=c}z=s.prototype,z.length=function(){return E(this.x*this.x+this.y*this.y+this.z*this.z)},z.dot=function(a){return this.x*a.x+this.y*a.y+this.z*a.z},z.cross=function(a){var b=this.y*a.z-this.z*a.y,c=this.z*a.x-this.x*a.z,d=this.x*a.y-this.y*a.x;return new s(b,c,d)},z.angle=function(b){var c=this.dot(b),a;return c==0?Math.PI/2:(a=c/(this.length()*b.length()),a>=1)?0:a<=-1?Math.PI:Math.acos(a)},z.unit=function(){var a=this.length();return new s(this.x/a,this.y/a,this.z/a)};function ay(b,a){a=a*Math.PI/180,b=b*Math.PI/180;var c=o(b)*l(a),d=-o(a),e=-l(b)*l(a);return new s(c,d,e)}function m(a){this[1]={1:a[0],2:a[1],3:a[2]},this[2]={1:a[3],2:a[4],3:a[5]},this[3]={1:a[6],2:a[7],3:a[8]}}T=m.prototype,m.Identity=function(){return new m([1,0,0,0,1,0,0,0,1])},m.Rotation=function(e,a){var c=o(e),d=l(e),b=1-d;return new m([d+w(a.x,2)*b,a.x*a.y*b-a.z*c,a.x*a.z*b+a.y*c,a.y*a.x*b+a.z*c,d+w(a.y,2)*b,a.y*a.z*b-a.x*c,a.z*a.x*b-a.y*c,a.z*a.y*b+a.x*c,d+w(a.z,2)*b])},T.mul=function(c){var d=[],a,b,e=c.xform?1:0;for(a=1;a<=3;++a)for(b=1;b<=3;++b)e?d.push(this[a][1]*c[1][b]+this[a][2]*c[2][b]+this[a][3]*c[3][b]):d.push(this[a][b]*c);return new m(d)},T.xform=function(b){var a={},c=b.x,d=b.y,e=b.z;return a.x=c*this[1][1]+d*this[2][1]+e*this[3][1],a.y=c*this[1][2]+d*this[2][2]+e*this[3][2],a.z=c*this[1][3]+d*this[2][3]+e*this[3][3],a};function aB(g,j,k,m,f){var a,b,c,d,e=[],h=2/g,i;i=Math.PI*(3-E(5)+(parseFloat(f)?parseFloat(f):0));for(a=0;a<g;++a)b=a*h-1+h/2,c=E(1-b*b),d=a*i,e.push([l(d)*c*j,b*k,o(d)*c*m]);return e}function U(n,p,m,k,h,g){var b,f=[],i=2/n,j,a,d,c,e;j=Math.PI*(3-E(5)+(parseFloat(g)?parseFloat(g):0));for(a=0;a<n;++a)d=a*i-1+i/2,b=a*j,c=l(b),e=o(b),f.push(p?[d*m,c*k,e*h]:[c*m,d*k,e*h]);return f}function aa(k,e,f,h,i,j){var b,g=[],m=Math.PI*2/e,a,c,d;for(a=0;a<e;++a)b=a*m,c=l(b),d=o(b),g.push(k?[j*f,c*h,d*i]:[c*f,j*h,d*i]);return g}function ax(a,b,c,d,e){return U(a,0,b,c,d,e)}function aH(a,b,c,d,e){return U(a,1,b,c,d,e)}function aG(b,c,d,e,a){return a=isNaN(a)?0:a*1,aa(0,b,c,d,e,a)}function aF(b,c,d,e,a){return a=isNaN(a)?0:a*1,aa(1,b,c,d,e,a)}function av(b){var a=new Image;a.onload=function(){var c=a.width/2,d=a.height/2;b.centreFunc=function(b,g,h,e,f){b.setTransform(1,0,0,1,0,0),b.globalAlpha=1,b.drawImage(a,e-c,f-d)}},a.src=b.centreImage}function aE(a,c){var b=a,d,e,f=(c*1).toPrecision(3)+')';return a[0]==='#'?(I[a]||(a.length===4?I[a]='rgba('+R[a[1]]+R[a[2]]+R[a[3]]:I[a]='rgba('+D[a.substr(1,2)]+D[a.substr(3,2)]+D[a.substr(5,2)]),b=I[a]+f):a.substr(0,4)==='rgb('||a.substr(0,4)==='hsl('?b=a.replace('(','a(').replace(')',','+f):(a.substr(0,5)==='rgba('||a.substr(0,5)==='hsla(')&&(d=a.lastIndexOf(',')+1,e=a.indexOf(')'),c*=parseFloat(a.substring(d,e)),b=a.substr(0,d)+c.toPrecision(3)+')'),b}function k(b,d){if(window.G_vmlCanvasManager)return null;var a=c.createElement('canvas');return a.width=b,a.height=d,a}function aD(){var b=k(3,3),a,c;return!!b&&(a=b.getContext('2d'),a.strokeStyle='#000',a.shadowColor='#fff',a.shadowBlur=3,a.globalAlpha=0,a.strokeRect(2,2,2,2),a.globalAlpha=1,c=a.getImageData(2,2,1,1),b=null,c.data[0]>0)}function aC(a,c,f,d){var e=a.createLinearGradient(0,0,c,0),b;for(b in d)e.addColorStop(1-b,d[b]);a.fillStyle=e,a.fillRect(0,f,c,1)}function L(a,m,j){var l=1024,d=1,e=a.weightGradient,i,f,b,c;if(a.gCanvas)f=a.gCanvas.getContext('2d'),d=a.gCanvas.height;else{if(B(e[0])?d=e.length:e=[e],a.gCanvas=i=k(l,d),!i)return null;f=i.getContext('2d');for(b=0;b<d;++b)aC(f,l,b,e[b])}return j=g(h(j||0,d-1),0),c=f.getImageData(~~((l-1)*m),j,1,1).data,'rgba('+c[0]+','+c[1]+','+c[2]+','+c[3]/255+')'}function Y(b,i,q,k,o,n,h,d,a,g,f,l){var m=o+(d||0)+(a.length&&a[0]<0?p(a[0]):0),j=n+(d||0)+(a.length&&a[1]<0?p(a[1]):0),c,e;b.font=i,b.textBaseline='top',b.fillStyle=q,h&&(b.shadowColor=h),d&&(b.shadowBlur=d),a.length&&(b.shadowOffsetX=a[0],b.shadowOffsetY=a[1]);for(c=0;c<k.length;++c)e=0,f&&('right'==l?e=g-f[c]:'centre'==l&&(e=(g-f[c])/2)),b.fillText(k[c],m+e,j),j+=parseInt(i)}function y(d,a,b,f,e,c,g){c?(d.beginPath(),d.moveTo(a,b+e-c),d.arcTo(a,b,a+c,b,c),d.arcTo(a+f,b,a+f,b+c,c),d.arcTo(a+f,b+e,a+f-c,b+e,c),d.arcTo(a,b+e,a,b+e-c,c),d.closePath(),d[g?'stroke':'fill']()):d[g?'strokeRect':'fillRect'](a,b,f,e)}function O(a,b,c,d,e,f,g,h,i){this.strings=a,this.font=b,this.width=c,this.height=d,this.maxWidth=e,this.stringWidths=f,this.align=g,this.valign=h,this.scale=i}M=O.prototype,M.SetImage=function(a,b,c,d,e,f,g,h){this.image=a,this.iwidth=b*this.scale,this.iheight=c*this.scale,this.ipos=d,this.ipad=e*this.scale,this.iscale=h,this.ialign=f,this.ivalign=g},M.Align=function(c,d,a){var b=0;return a=='right'||a=='bottom'?b=d-c:a!='left'&&a!='top'&&(b=(d-c)/2),b},M.Create=function(G,D,F,b,A,m,q,j,E){var o,e,f,a,l,s,i,u,v,r,w,n,c,d,x,B=p(q[0]),C=p(q[1]),t,z;return j=g(j,B+m,C+m),l=2*(j+b),i=2*(j+b),e=this.width+l,f=this.height+i,v=r=j+b,this.image&&(w=n=j+b,c=this.iwidth,d=this.iheight,this.ipos=='top'||this.ipos=='bottom'?(c<this.width?w+=this.Align(c,this.width,this.ialign):v+=this.Align(this.width,c,this.align),this.ipos=='top'?r+=d+this.ipad:n+=this.height+this.ipad,e=g(e,c+l),f+=d+this.ipad):(d<this.height?n+=this.Align(d,this.height,this.ivalign):r+=this.Align(this.height,d,this.valign),this.ipos=='right'?w+=this.width+this.ipad:v+=c+this.ipad,e+=c+this.ipad,f=g(f,d+i))),o=k(e,f),!o?null:(l=i=b/2,s=e-b,u=f-b,x=h(E,s/2,u/2),a=o.getContext('2d'),D&&(a.fillStyle=D,y(a,l,i,s,u,x)),b&&(a.strokeStyle=F,a.lineWidth=b,y(a,l,i,s,u,x,!0)),(m||B||C)&&(t=k(e,f),t&&(z=a,a=t.getContext('2d'))),Y(a,this.font,G,this.strings,v,r,0,0,[],this.maxWidth,this.stringWidths,this.align),this.image&&a.drawImage(this.image,w,n,c,d),z&&(a=z,A&&(a.shadowColor=A),m&&(a.shadowBlur=m),a.shadowOffsetX=q[0],a.shadowOffsetY=q[1],a.drawImage(t,0,0)),o)};function H(a,c,d){var b=k(c,d),e;return b?(e=b.getContext('2d'),e.drawImage(a,(c-a.width)/2,(d-a.height)/2),b):null}function S(e,b,c){var a=k(b,c),d;return a?(d=a.getContext('2d'),d.drawImage(e,0,0,b,c),a):null}function W(n,u,t,e,s,c,v,d,r,w){var g=u+(2*d+c)*e,f=t+(2*d+c)*e,l=k(g,f),b,i,q,m,j,o,a,p;return l?(c*=e,r*=e,i=q=c/2,m=g-c,j=f-c,d=d*e+i,b=l.getContext('2d'),p=h(r,m/2,j/2),s&&(b.fillStyle=s,y(b,i,q,m,j,p)),c&&(b.strokeStyle=v,b.lineWidth=c,y(b,i,q,m,j,p,!0)),w?(o=k(g,f),a=o.getContext('2d'),a.drawImage(n,d,d,u,t),a.globalCompositeOperation='source-in',a.fillStyle=v,a.fillRect(0,0,g,f),a.globalCompositeOperation='destination-over',a.drawImage(l,0,0),a.globalCompositeOperation='source-over',b.drawImage(o,0,0)):b.drawImage(n,d,d,n.width,n.height),{image:l,width:g/e,height:f/e}):null}function at(l,f,c,d,j){var e,a,b=parseFloat(f),i=g(c,d);return e=k(c,d),!e?null:(f.indexOf('%')>0?b=i*b/100:b=b*j,a=e.getContext('2d'),a.globalCompositeOperation='source-over',a.fillStyle='#fff',b>=i/2?(b=h(c,d)/2,a.beginPath(),a.moveTo(c/2,d/2),a.arc(c/2,d/2,b,0,2*Math.PI,!1),a.fill(),a.closePath()):(b=h(c/2,d/2,b),y(a,0,0,c,d,b,!0),a.fill()),a.globalCompositeOperation='source-in',a.drawImage(l,0,0,c,d),e)}function ao(q,m,i,b,h,a,c){var g=p(c[0]),f=p(c[1]),j=m+(g>a?g+a:a*2)*b,l=i+(f>a?f+a:a*2)*b,n=b*((a||0)+(c[0]<0?g:0)),o=b*((a||0)+(c[1]<0?f:0)),e,d;return e=k(j,l),!e?null:(d=e.getContext('2d'),h&&(d.shadowColor=h),a&&(d.shadowBlur=a*b),c&&(d.shadowOffsetX=c[0]*b,d.shadowOffsetY=c[1]*b),d.drawImage(q,n,o,m,i),{image:e,width:j/b,height:l/b})}function ae(m,o,l){var c=parseInt(m.toString().length*l),h=parseInt(l*2*m.length),j=k(c,h),g,i,e,f,b,d,n,a;if(!j)return null;g=j.getContext('2d'),g.fillStyle='#000',g.fillRect(0,0,c,h),Y(g,l+'px '+o,'#fff',m,0,0,0,0,[],'centre'),i=g.getImageData(0,0,c,h),e=i.width,f=i.height,a={min:{x:e,y:f},max:{x:-1,y:-1}};for(d=0;d<f;++d)for(b=0;b<e;++b)n=(d*e+b)*4,i.data[n+1]>0&&(b<a.min.x&&(a.min.x=b),b>a.max.x&&(a.max.x=b),d<a.min.y&&(a.min.y=d),d>a.max.y&&(a.max.y=d));return e!=c&&(a.min.x*=c/e,a.max.x*=c/e),f!=h&&(a.min.y*=c/f,a.max.y*=c/f),j=null,a}function Q(a){return"'"+a.replace(/(\'|\")/g,'').replace(/\s*,\s*/g,"', '")+"'"}function t(b,d,a){a=a||c,a.addEventListener?a.addEventListener(b,d,!1):a.attachEvent('on'+b,d)}function am(b,d,a){a=a||c,a.removeEventListener?a.removeEventListener(b,d):a.detachEvent('on'+b,d)}function A(g,e,j,a,b){var l=b.imageScale,h,c,k,m,f,d;if(!e.complete)return t('load',function(){A(g,e,j,a,b)},e);if(!g.complete)return t('load',function(){A(g,e,j,a,b)},g);if(j&&!j.complete)return t('load',function(){A(g,e,j,a,b)},j);e.width=e.width,e.height=e.height,l&&(g.width=e.width*l,g.height=e.height*l),a.iw=g.width,a.ih=g.height,b.txtOpt&&(c=g,h=b.zoomMax*b.txtScale,f=a.iw*h,d=a.ih*h,f<e.naturalWidth||d<e.naturalHeight?(c=S(g,f,d),c&&(a.fimage=c)):(f=a.iw,d=a.ih,h=1),parseFloat(b.imageRadius)&&(a.image=a.fimage=g=at(a.image,b.imageRadius,f,d,h)),a.HasText()||(b.shadow&&(c=ao(a.image,f,d,h,b.shadow,b.shadowBlur,b.shadowOffset),c&&(a.fimage=c.image,a.w=c.width,a.h=c.height)),(b.bgColour||b.bgOutlineThickness)&&(k=b.bgColour=='tag'?i(a.a,'background-color'):b.bgColour,m=b.bgOutline=='tag'?i(a.a,'color'):b.bgOutline||b.textColour,f=a.fimage.width,d=a.fimage.height,b.outlineMethod=='colour'&&(c=W(a.fimage,f,d,h,k,b.bgOutlineThickness,a.outline.colour,b.padding,b.bgRadius,1),c&&(a.oimage=c.image)),c=W(a.fimage,f,d,h,k,b.bgOutlineThickness,m,b.padding,b.bgRadius),c&&(a.fimage=c.image,a.w=c.width,a.h=c.height)),b.outlineMethod=='size'&&(b.outlineIncrease>0?(a.iw+=2*b.outlineIncrease,a.ih+=2*b.outlineIncrease,f=h*a.iw,d=h*a.ih,c=S(a.fimage,f,d),a.oimage=c,a.fimage=H(a.fimage,a.oimage.width,a.oimage.height)):(f=h*(a.iw+2*b.outlineIncrease),d=h*(a.ih+2*b.outlineIncrease),c=S(a.fimage,f,d),a.oimage=H(c,a.fimage.width,a.fimage.height))))),a.alt=j,a.Init()}function i(a,d){var b=c.defaultView,e=d.replace(/\-([a-z])/g,function(a){return a.charAt(1).toUpperCase()});return b&&b.getComputedStyle&&b.getComputedStyle(a,null).getPropertyValue(d)||a.currentStyle&&a.currentStyle[e]}function aj(c,d,e){var b=1,a;return d?b=1*(c.getAttribute(d)||e):(a=i(c,'font-size'))&&(b=a.indexOf('px')>-1&&a.replace('px','')*1||a.indexOf('pt')>-1&&a.replace('pt','')*1.25||a*3.3),b}function u(a){return a.target&&n(a.target.id)?a.target.id:a.srcElement.parentNode.id}function K(a,c){var b,d,e=parseInt(i(c,'width'))/c.width,f=parseInt(i(c,'height'))/c.height;return n(a.offsetX)?b={x:a.offsetX,y:a.offsetY}:(d=X(c.id),n(a.changedTouches)&&(a=a.changedTouches[0]),a.pageX&&(b={x:a.pageX-d.x,y:a.pageY-d.y})),b&&e&&f&&(b.x/=e,b.y/=f),b}function an(c){var d=c.target||c.fromElement.parentNode,b=a.tc[d.id];b&&(b.mx=b.my=-1,b.UnFreeze(),b.EndDrag())}function ad(e){var g,c=a,b,d,f=u(e);for(g in c.tc)b=c.tc[g],b.tttimer&&(clearTimeout(b.tttimer),b.tttimer=null);f&&c.tc[f]&&(b=c.tc[f],(d=K(e,b.canvas))&&(b.mx=d.x,b.my=d.y,b.Drag(e,d)),b.drawn=0)}function ap(b){var e=a,f=c.addEventListener?0:1,d=u(b);d&&b.button==f&&e.tc[d]&&e.tc[d].BeginDrag(b)}function aq(b){var f=a,g=c.addEventListener?0:1,e=u(b),d;e&&b.button==g&&f.tc[e]&&(d=f.tc[e],ad(b),!d.EndDrag()&&!d.touchState&&d.Clicked(b))}function ar(c){var e=u(c),b=e&&a.tc[e],d;b&&c.changedTouches&&(c.touches.length==1&&b.touchState==0?(b.touchState=1,b.BeginDrag(c),(d=K(c,b.canvas))&&(b.mx=d.x,b.my=d.y,b.drawn=0)):c.targetTouches.length==2&&b.pinchZoom?(b.touchState=3,b.EndDrag(),b.BeginPinch(c)):(b.EndDrag(),b.EndPinch(),b.touchState=0))}function ac(c){var d=u(c),b=d&&a.tc[d];if(b&&c.changedTouches){switch(b.touchState){case 1:b.Draw(),b.Clicked();break;break;case 2:b.EndDrag();break;case 3:b.EndPinch()}b.touchState=0}}function au(c){var f,e=a,b,d,g=u(c);for(f in e.tc)b=e.tc[f],b.tttimer&&(clearTimeout(b.tttimer),b.tttimer=null);if(b=g&&e.tc[g],b&&c.changedTouches&&b.touchState){switch(b.touchState){case 1:case 2:(d=K(c,b.canvas))&&(b.mx=d.x,b.my=d.y,b.Drag(c,d)&&(b.touchState=2));break;case 3:b.Pinch(c)}b.drawn=0}}function ab(b){var d=a,c=u(b);c&&d.tc[c]&&(b.cancelBubble=!0,b.returnValue=!1,b.preventDefault&&b.preventDefault(),d.tc[c].Wheel((b.wheelDelta||b.detail)>0))}function aw(d){var c,b=a;clearTimeout(b.scrollTimer);for(c in b.tc)b.tc[c].Pause();b.scrollTimer=setTimeout(function(){var b,c=a;for(b in c.tc)c.tc[b].Resume()},b.scrollPause)}function al(){Z(q())}function Z(b){var c=a.tc,d;a.NextFrame(a.interval),b=b||q();for(d in c)c[d].Draw(b)}function az(){requestAnimationFrame(Z)}function aA(a){setTimeout(al,a)}function X(f){var g=c.getElementById(f),b=g.getBoundingClientRect(),a=c.documentElement,d=c.body,e=window,h=e.pageXOffset||a.scrollLeft,i=e.pageYOffset||a.scrollTop,j=a.clientLeft||d.clientLeft,k=a.clientTop||d.clientTop;return{x:b.left+h-j,y:b.top+i-k}}function aI(a,b,d,e){var c=a.radius*a.z1/(a.z1+a.z2+b.z);return{x:b.x*c*d,y:b.y*c*e,z:b.z,w:(a.z1-b.z)/a.z2}}function V(a){this.e=a,this.br=0,this.line=[],this.text=[],this.original=a.innerText||a.textContent}F=V.prototype,F.Empty=function(){for(var a=0;a<this.text.length;++a)if(this.text[a].length)return!1;return!0},F.Lines=function(c){var e=c?1:0,b,d,a;c=c||this.e,b=c.childNodes,d=b.length;for(a=0;a<d;++a)b[a].nodeName=='BR'?(this.text.push(this.line.join(' ')),this.br=1):b[a].nodeType==3?this.br?(this.line=[b[a].nodeValue],this.br=0):this.line.push(b[a].nodeValue):this.Lines(b[a]);return e||this.br||this.text.push(this.line.join(' ')),this.text},F.SplitWidth=function(h,e,f,g){var c,b,a,d=[];e.font=g+'px '+f;for(c=0;c<this.text.length;++c){a=this.text[c].split(/\s+/),this.line=[a[0]];for(b=1;b<a.length;++b)e.measureText(this.line.join(' ')+' '+a[b]).width>h?(d.push(this.line.join(' ')),this.line=[a[b]]):this.line.push(a[b]);d.push(this.line.join(' '))}return this.text=d};function _(a,b){this.ts=null,this.tc=a,this.tag=b,this.x=this.y=this.w=this.h=this.sc=1,this.z=0,this.pulse=1,this.pulsate=a.pulsateTo<1,this.colour=a.outlineColour,this.adash=~~a.outlineDash,this.agap=~~a.outlineDashSpace||this.adash,this.aspeed=a.outlineDashSpeed*1,this.colour=='tag'?this.colour=i(b.a,'color'):this.colour=='tagbg'&&(this.colour=i(b.a,'background-color')),this.Draw=this.pulsate?this.DrawPulsate:this.DrawSimple,this.radius=a.outlineRadius|0,this.SetMethod(a.outlineMethod,a.altImage)}f=_.prototype,f.SetMethod=function(a,d){var b={block:['PreDraw','DrawBlock'],colour:['PreDraw','DrawColour'],outline:['PostDraw','DrawOutline'],classic:['LastDraw','DrawOutline'],size:['PreDraw','DrawSize'],none:['LastDraw']},c=b[a]||b.outline;a=='none'?this.Draw=function(){return 1}:this.drawFunc=this[c[1]],this[c[0]]=this.Draw,d&&(this.RealPreDraw=this.PreDraw,this.PreDraw=this.DrawAlt)},f.Update=function(d,e,i,j,a,f,g,h){var b=this.tc.outlineOffset,c=2*b;this.x=a*d+g-b,this.y=a*e+h-b,this.w=a*i+c,this.h=a*j+c,this.sc=a,this.z=f},f.Ants=function(k){if(!this.adash)return;var b=this.adash,c=this.agap,a=this.aspeed,j=b+c,h=0,g=b,f=c,i=0,d=0,e;a&&(d=p(a)*(q()-this.ts)/50,a<0&&(d=864e4-d),a=~~d%j),a?(b>=a?(h=b-a,g=a):(f=j-a,i=c-f),e=[h,f,g,i]):e=[b,c],k.setLineDash(e)},f.DrawOutline=function(a,d,e,b,c,f){var g=h(this.radius,c/2,b/2);a.strokeStyle=f,this.Ants(a),y(a,d,e,b,c,g,!0)},f.DrawSize=function(i,n,m,l,k,j,a,h,g){var f=a.w,e=a.h,c,b,d;return this.pulsate?(a.image?d=(a.image.height+this.tc.outlineIncrease)/a.image.height:d=a.oscale,b=a.fimage||a.image,c=1+(d-1)*(1-this.pulse),a.h*=c,a.w*=c):b=a.oimage,a.alpha=1,a.Draw(i,h,g,b),a.h=e,a.w=f,1},f.DrawColour=function(d,h,i,e,f,g,a,b,c){return a.oimage?(this.pulse<1?(a.alpha=1-w(this.pulse,2),a.Draw(d,b,c,a.fimage),a.alpha=this.pulse):a.alpha=1,a.Draw(d,b,c,a.oimage),1):this[a.image?'DrawColourImage':'DrawColourText'](d,h,i,e,f,g,a,b,c)},f.DrawColourText=function(f,h,i,j,g,e,a,b,c){var d=a.colour;return a.colour=e,a.alpha=1,a.Draw(f,b,c),a.colour=d,1},f.DrawColourImage=function(a,q,p,o,n,m,i,r,l){var f=a.canvas,e=~~g(q,0),d=~~g(p,0),c=h(f.width-e,o)+.5|0,b=h(f.height-d,n)+.5|0,j;return v?(v.width=c,v.height=b):v=k(c,b),!v?this.SetMethod('outline'):(j=v.getContext('2d'),j.drawImage(f,e,d,c,b,0,0,c,b),a.clearRect(e,d,c,b),this.pulsate?i.alpha=1-w(this.pulse,2):i.alpha=1,i.Draw(a,r,l),a.setTransform(1,0,0,1,0,0),a.save(),a.beginPath(),a.rect(e,d,c,b),a.clip(),a.globalCompositeOperation='source-in',a.fillStyle=m,a.fillRect(e,d,c,b),a.restore(),a.globalAlpha=1,a.globalCompositeOperation='destination-over',a.drawImage(v,0,0,c,b,e,d,c,b),a.globalCompositeOperation='source-over',1)},f.DrawAlt=function(b,a,c,d,f,g){var e=this.RealPreDraw(b,a,c,d,f,g);return a.alt&&(a.DrawImage(b,c,d,a.alt),e=1),e},f.DrawBlock=function(a,d,e,b,c,f){var g=h(this.radius,c/2,b/2);a.fillStyle=f,y(a,d,e,b,c,g)},f.DrawSimple=function(a,b,c,d,e,f){var g=this.tc;return a.setTransform(1,0,0,1,0,0),a.strokeStyle=this.colour,a.lineWidth=g.outlineThickness,a.shadowBlur=a.shadowOffsetX=a.shadowOffsetY=0,a.globalAlpha=f?e:1,this.drawFunc(a,this.x,this.y,this.w,this.h,this.colour,b,c,d)},f.DrawPulsate=function(h,d,e,f){var g=q()-this.ts,c=this.tc,b=c.pulsateTo+(1-c.pulsateTo)*(.5+l(2*Math.PI*g/(1e3*c.pulsateTime))/2);return this.pulse=b=a.Smooth(1,b),this.DrawSimple(h,d,e,f,b,1)},f.Active=function(d,a,b){var c=a>=this.x&&b>=this.y&&a<=this.x+this.w&&b<=this.y+this.h;return c?this.ts=this.ts||q():this.ts=null,c},f.PreDraw=f.PostDraw=f.LastDraw=x;function J(a,h,c,b,e,f,g,d,i,j,k,l,m,n){this.tc=a,this.image=null,this.text=h,this.text_original=n,this.line_widths=[],this.title=c.title||null,this.a=c,this.position=new s(b[0],b[1],b[2]),this.x=this.y=this.z=0,this.w=e,this.h=f,this.colour=g||a.textColour,this.bgColour=d||a.bgColour,this.bgRadius=i|0,this.bgOutline=j||this.colour,this.bgOutlineThickness=k|0,this.textFont=l||a.textFont,this.padding=m|0,this.sc=this.alpha=1,this.weighted=!a.weight,this.outline=new _(a,this),this.audio=null}d=J.prototype,d.Init=function(b){var a=this.tc;this.textHeight=a.textHeight,this.HasText()?this.Measure(a.ctxt,a):(this.w=this.iw,this.h=this.ih),this.SetShadowColour=a.shadowAlpha?this.SetShadowColourAlpha:this.SetShadowColourFixed,this.SetDraw(a)},d.Draw=x,d.HasText=function(){return this.text&&this.text[0].length>0},d.EqualTo=function(a){var b=a.getElementsByTagName('img');return this.a.href!=a.href?0:b.length?this.image.src==b[0].src:(a.innerText||a.textContent)==this.text_original},d.SetImage=function(a){this.image=this.fimage=a},d.SetAudio=function(a){this.audio=a,this.audio.load()},d.SetDraw=function(a){this.Draw=this.fimage?a.ie>7?this.DrawImageIE:this.DrawImage:this.DrawText,a.noSelect&&(this.CheckActive=x)},d.MeasureText=function(d){var a,e=this.text.length,b=0,c;for(a=0;a<e;++a)this.line_widths[a]=c=d.measureText(this.text[a]).width,b=g(b,c);return b},d.Measure=function(e,a){var f=ae(this.text,this.textFont,this.textHeight),b,k,h,i,g,l,j,c,d;j=f?f.max.y+f.min.y:this.textHeight,e.font=this.font=this.textHeight+'px '+this.textFont,l=this.MeasureText(e),a.txtOpt&&(b=a.txtScale,k=b*this.textHeight,h=k+'px '+this.textFont,i=[b*a.shadowOffset[0],b*a.shadowOffset[1]],e.font=h,g=this.MeasureText(e),d=new O(this.text,h,g+b,b*j+b,g,this.line_widths,a.textAlign,a.textVAlign,b),this.image&&d.SetImage(this.image,this.iw,this.ih,a.imagePosition,a.imagePadding,a.imageAlign,a.imageVAlign,a.imageScale),c=d.Create(this.colour,this.bgColour,this.bgOutline,b*this.bgOutlineThickness,a.shadow,b*a.shadowBlur,i,b*this.padding,b*this.bgRadius),a.outlineMethod=='colour'?this.oimage=d.Create(this.outline.colour,this.bgColour,this.outline.colour,b*this.bgOutlineThickness,a.shadow,b*a.shadowBlur,i,b*this.padding,b*this.bgRadius):a.outlineMethod=='size'&&(f=ae(this.text,this.textFont,this.textHeight+a.outlineIncrease),k=f.max.y+f.min.y,h=b*(this.textHeight+a.outlineIncrease)+'px '+this.textFont,e.font=h,g=this.MeasureText(e),d=new O(this.text,h,g+b,b*k+b,g,this.line_widths,a.textAlign,a.textVAlign,b),this.image&&d.SetImage(this.image,this.iw+a.outlineIncrease,this.ih+a.outlineIncrease,a.imagePosition,a.imagePadding,a.imageAlign,a.imageVAlign,a.imageScale),this.oimage=d.Create(this.colour,this.bgColour,this.bgOutline,b*this.bgOutlineThickness,a.shadow,b*a.shadowBlur,i,b*this.padding,b*this.bgRadius),this.oscale=this.oimage.width/c.width,a.outlineIncrease>0?c=H(c,this.oimage.width,this.oimage.height):this.oimage=H(this.oimage,c.width,c.height)),c&&(this.fimage=c,l=this.fimage.width/b,j=this.fimage.height/b),this.SetDraw(a),a.txtOpt=!!this.fimage),this.h=j,this.w=l},d.SetFont=function(a,b,c,d){this.textFont=a,this.colour=b,this.bgColour=c,this.bgOutline=d,this.Measure(this.tc.ctxt,this.tc)},d.SetWeight=function(c){var b=this.tc,e=b.weightMode.split(/[, ]/),d,a,f=c.length;if(!this.HasText())return;this.weighted=!0;for(a=0;a<f;++a)d=e[a]||'size','both'==d?(this.Weight(c[a],b.ctxt,b,'size',b.min_weight[a],b.max_weight[a],a),this.Weight(c[a],b.ctxt,b,'colour',b.min_weight[a],b.max_weight[a],a)):this.Weight(c[a],b.ctxt,b,d,b.min_weight[a],b.max_weight[a],a);this.Measure(b.ctxt,b)},d.Weight=function(b,i,a,d,f,h,e){b=isNaN(b)?1:b;var c=(b-f)/(h-f);'colour'==d?this.colour=L(a,c,e):'bgcolour'==d?this.bgColour=L(a,c,e):'bgoutline'==d?this.bgOutline=L(a,c,e):'outline'==d?this.outline.colour=L(a,c,e):'size'==d&&(a.weightSizeMin>0&&a.weightSizeMax>a.weightSizeMin?this.textHeight=a.weightSize*(a.weightSizeMin+(a.weightSizeMax-a.weightSizeMin)*c):this.textHeight=g(1,b*a.weightSize))},d.SetShadowColourFixed=function(a,b,c){a.shadowColor=b},d.SetShadowColourAlpha=function(a,b,c){a.shadowColor=aE(b,c)},d.DrawText=function(a,h,i){var e=this.tc,g=this.x,f=this.y,c=this.sc,b,d;a.globalAlpha=this.alpha,a.fillStyle=this.colour,e.shadow&&this.SetShadowColour(a,e.shadow,this.alpha),a.font=this.font,g+=h/c,f+=i/c-this.h/2;for(b=0;b<this.text.length;++b)d=g,'right'==e.textAlign?d+=this.w/2-this.line_widths[b]:'centre'==e.textAlign?d-=this.line_widths[b]/2:d-=this.w/2,a.setTransform(c,0,0,c,c*d,c*f),a.fillText(this.text[b],0,0),f+=this.textHeight},d.DrawImage=function(b,i,k,l){var e=this.x,f=this.y,a=this.sc,j=l||this.fimage,c=this.w,d=this.h,g=this.alpha,h=this.shadow;b.globalAlpha=g,h&&this.SetShadowColour(b,h,g),e+=i/a-c/2,f+=k/a-d/2,b.setTransform(a,0,0,a,a*e,a*f),b.drawImage(j,0,0,c,d)},d.DrawImageIE=function(b,d,e){var c=this.fimage,a=this.sc,f=c.width=this.w*a,g=c.height=this.h*a,h=this.x*a+d-f/2,i=this.y*a+e-g/2;b.setTransform(1,0,0,1,0,0),b.globalAlpha=this.alpha,b.drawImage(c,h,i)},d.Calc=function(g,e){var a,b=this.tc,d=b.minBrightness,f=b.maxBrightness,c=b.max_radius;return a=g.xform(this.position),this.xformed=a,a=aI(b,a,b.stretchX,b.stretchY),this.x=a.x,this.y=a.y,this.z=a.z,this.sc=a.w,this.alpha=e*G(d+(f-d)*(c-this.z)/(2*c),0,1),this.xformed},d.UpdateActive=function(h,e,f){var a=this.outline,b=this.w,c=this.h,d=this.x-b/2,g=this.y-c/2;return a.Update(d,g,b,c,this.sc,this.z,e,f),a},d.CheckActive=function(a,d,e){var b=this.tc,c=this.UpdateActive(a,d,e);return c.Active(a,b.mx,b.my)?c:null},d.Clicked=function(f){var b=this.a,a=b.target,d=b.href,e;if(a!=''&&a!='_self'){if(self.frames[a])self.frames[a].document.location=d;else{try{if(top.frames[a]){top.frames[a].document.location=d;return}}catch(a){}window.open(d,a)}return}if(c.createEvent){if(e=c.createEvent('MouseEvents'),e.initMouseEvent('click',1,1,window,0,0,0,0,0,0,0,0,0,0,null),!b.dispatchEvent(e))return}else if(b.fireEvent)if(!b.fireEvent('onclick'))return;c.location=d},d.StopAudio=function(){this.audio&&this.playing&&this.audio.pause(),this.stopped=1,this.playing=0},d.PlayAudio=function(){if(e==='off'||this.tc.audioOff)return;if(!e&&!ag())return;var a=this.tc.audio,c=this.tc.gain,d='suspended',b;if(this.audio)if(this.track||(this.track=e.createMediaElementSource(this.audio),this.gain=e.createGain(),this.track.connect(this.gain),this.gain.connect(e.destination)),a=this.audio,c=this.gain,!a.paused)return 1;if(a){if(e.state==d&&e.resume(),e.state==d)return;return c.gain.value=h(2,g(0,this.tc.audioVolume*1)),a.currentTime=0,this.stopped=0,b=a.play(),b!==void 0&&b.then(a=>{this.stopped?this.audio.pause():this.playing=1}),1}};function a(f,o,k){var d,i,b=c.getElementById(f),l=['id','class','innerHTML'];if(!b)throw 0;if(n(window.G_vmlCanvasManager)&&(b=window.G_vmlCanvasManager.initElement(b),this.ie=parseFloat(navigator.appVersion.split('MSIE')[1])),b&&(!b.getContext||!b.getContext('2d').fillText)){i=c.createElement('DIV');for(d=0;d<l.length;++d)i[l[d]]=b[l[d]];throw b.parentNode.insertBefore(i,b),b.parentNode.removeChild(b),0}for(d in a.options)this[d]=k&&n(k[d])?k[d]:n(a[d])?a[d]:a.options[d];if(this.canvas=b,this.ctxt=b.getContext('2d'),this.z1=250/g(this.depth,.001),this.z2=this.z1/this.zoom,this.radius=h(b.height,b.width)*.0075,this.max_radius=100,this.max_weight=[],this.min_weight=[],this.textFont=this.textFont&&Q(this.textFont),this.textHeight*=1,this.imageRadius=this.imageRadius.toString(),this.pulsateTo=G(this.pulsateTo,0,1),this.minBrightness=G(this.minBrightness,0,1),this.maxBrightness=G(this.maxBrightness,this.minBrightness,1),this.ctxt.textBaseline='top',this.lx=(this.lock+'').indexOf('x')+1,this.ly=(this.lock+'').indexOf('y')+1,this.frozen=this.dx=this.dy=this.fixedAnim=this.touchState=0,this.fixedAlpha=1,this.source=o||f,this.repeatTags=h(64,~~this.repeatTags),this.minTags=h(200,~~this.minTags),~~this.scrollPause>0?a.scrollPause=~~this.scrollPause:this.scrollPause=0,this.minTags>0&&this.repeatTags<1&&(d=this.GetTags().length)&&(this.repeatTags=af(this.minTags/d)-1),this.transform=m.Identity(),this.startTime=this.time=q(),this.mx=this.my=-1,this.centreImage&&av(this),this.Animate=this.dragControl?this.AnimateDrag:this.AnimatePosition,this.animTiming=typeof a[this.animTiming]=='function'?a[this.animTiming]:a.Smooth,this.shadowBlur||this.shadowOffset[0]||this.shadowOffset[1]?(this.ctxt.shadowColor=this.shadow,this.shadow=this.ctxt.shadowColor,this.shadowAlpha=aD()):delete this.shadow,this.activeAudio===!1?e='off':this.activeAudio&&this.LoadAudio(),this.Load(),o&&this.hideTags&&function(b){a.loaded?b.HideTags():t('load',function(){b.HideTags()},window)}(this),this.yaw=this.initial?this.initial[0]*this.maxSpeed:0,this.pitch=this.initial?this.initial[1]*this.maxSpeed:0,this.tooltip?(this.ctitle=b.title,b.title='',this.tooltip=='native'?this.Tooltip=this.TooltipNative:(this.Tooltip=this.TooltipDiv,this.ttdiv||(this.ttdiv=c.createElement('div'),this.ttdiv.className=this.tooltipClass,this.ttdiv.style.position='absolute',this.ttdiv.style.zIndex=b.style.zIndex+1,t('mouseover',function(a){a.target.style.display='none'},this.ttdiv),c.body.appendChild(this.ttdiv)))):this.Tooltip=this.TooltipNone,!this.noMouse&&!j[f]){j[f]=[['mousemove',ad],['mouseout',an],['mouseup',aq],['touchstart',ar],['touchend',ac],['touchcancel',ac],['touchmove',au]],this.dragControl&&(j[f].push(['mousedown',ap]),j[f].push(['selectstart',x])),this.wheelZoom&&(j[f].push(['mousewheel',ab]),j[f].push(['DOMMouseScroll',ab])),this.scrollPause&&j[f].push(['scroll',aw,window]);for(d=0;d<j[f].length;++d)i=j[f][d],t(i[0],i[1],i[2]?i[2]:b)}a.started||(a.NextFrame=window.requestAnimationFrame?az:aA,a.interval=this.interval,a.NextFrame(this.interval),a.started=1)}b=a.prototype,b.SourceElements=function(){return c.querySelectorAll?c.querySelectorAll('#'+this.source):[c.getElementById(this.source)]},b.HideTags=function(){var b=this.SourceElements(),a;for(a=0;a<b.length;++a)b[a].style.display='none'},b.GetTags=function(){var e=this.SourceElements(),c,f=[],a,b,d;for(d=0;d<=this.repeatTags;++d)for(a=0;a<e.length;++a){c=e[a].getElementsByTagName('a');for(b=0;b<c.length;++b)f.push(c[b])}return f},b.Message=function(j){var g=[],a,f,b=j.split(''),d,e,h,i;for(a=0;a<b.length;++a)b[a]!=' '&&(f=a-b.length/2,d=c.createElement('A'),d.href='#',d.innerText=b[a],h=100*o(f/9),i=-100*l(f/9),e=new J(this,b[a],d,[h,0,i],2,18,'#000','#fff',0,0,0,'monospace',2,b[a]),e.Init(),g.push(e));return g},b.AddAudio=function(b,c){if(e==='off')return;var a=b.getElementsByTagName('audio');a.length&&(c.SetAudio(a[0]),this.hasAudio=1)},b.CreateTag=function(b){var e,c,a,f,d,g,h,j,k=[0,0,0],l;if('text'!=this.imageMode)if(e=b.getElementsByTagName('img'),e.length)if(c=new Image,c.src=e[0].src,!this.imageMode)return a=new J(this,"",b,k,0,0),a.SetImage(c),A(c,e[0],e[1],a,this),this.AddAudio(b,a),a;if('image'!=this.imageMode&&(d=new V(b),f=d.Lines(),d.Empty()?d=null:(g=this.textFont||Q(i(b,'font-family')),this.splitWidth&&(f=d.SplitWidth(this.splitWidth,this.ctxt,g,this.textHeight)),h=this.bgColour=='tag'?i(b,'background-color'):this.bgColour,j=this.bgOutline=='tag'?i(b,'color'):this.bgOutline)),d||c)return a=new J(this,f,b,k,2,this.textHeight+2,this.textColour||i(b,'color'),h,this.bgRadius,j,this.bgOutlineThickness,g,this.padding,d&&d.original),c?(a.SetImage(c),A(c,e[0],e[1],a,this)):a.Init(),this.AddAudio(b,a),a},b.UpdateTag=function(a,b){var c=this.textColour||i(b,'color'),d=this.textFont||Q(i(b,'font-family')),e=this.bgColour=='tag'?i(b,'background-color'):this.bgColour,f=this.bgOutline=='tag'?i(b,'color'):this.bgOutline;a.a=b,a.title=b.title,(a.colour!=c||a.textFont!=d||a.bgColour!=e||a.bgOutline!=f)&&a.SetFont(d,c,e,f)},b.Weight=function(d){var f=d.length,c,b,a,e=[],g,h=this.weightFrom?this.weightFrom.split(/[, ]/):[null],i=h.length;for(b=0;b<f;++b){e[b]=[];for(a=0;a<i;++a)c=aj(d[b].a,h[a],this.textHeight),(!this.max_weight[a]||c>this.max_weight[a])&&(this.max_weight[a]=c),(!this.min_weight[a]||c<this.min_weight[a])&&(this.min_weight[a]=c),e[b][a]=c}for(a=0;a<i;++a)this.max_weight[a]>this.min_weight[a]&&(g=1);if(g)for(b=0;b<f;++b)d[b].SetWeight(e[b])},b.Load=function(){var c=this.GetTags(),b=[],d,k,l,h,i,j,f,a,e=[],m={sphere:aB,vcylinder:ax,hcylinder:aH,vring:aG,hring:aF};if(c.length){e.length=c.length;for(a=0;a<c.length;++a)e[a]=a;this.shuffleTags&&ai(e),h=100*this.radiusX,i=100*this.radiusY,j=100*this.radiusZ,this.max_radius=g(h,g(i,j));for(a=0;a<c.length;++a)k=this.CreateTag(c[e[a]]),k&&b.push(k);this.weight&&this.Weight(b,!0),this.shapeArgs?this.shapeArgs[0]=b.length:(l=this.shape.toString().split(/[(),]/),d=l.shift(),typeof window[d]=='function'?this.shape=window[d]:this.shape=m[d]||m.sphere,this.shapeArgs=[b.length,h,i,j].concat(l)),f=this.shape.apply(this,this.shapeArgs),this.listLength=b.length;for(a=0;a<b.length;++a)b[a].position=new s(f[a][0],f[a][1],f[a][2])}this.noTagsMessage&&!b.length&&(a=this.imageMode&&this.imageMode!='both'?this.imageMode+' ':'',b=this.Message('No '+a+'tags')),this.taglist=b},b.Update=function(){var e=this.GetTags(),d=[],j=this.taglist,k,f=[],c=[],h,i,g,a,b;if(!this.shapeArgs)return this.Load();if(e.length){g=this.listLength=e.length,i=j.length;for(a=0;a<i;++a)d.push(j[a]),c.push(a);for(a=0;a<g;++a){for(b=0,k=0;b<i;++b)j[b].EqualTo(e[a])&&(this.UpdateTag(d[b],e[a]),k=c[b]=-1);k||f.push(a)}for(a=0,b=0;a<i;++a)c[b]==-1?c.splice(b,1):++b;if(c.length){for(ai(c);c.length&&f.length;)a=c.shift(),b=f.shift(),d[a]=this.CreateTag(e[b]);for(c.sort(function(a,b){return a-b});c.length;)d.splice(c.pop(),1)}for(b=d.length/(f.length+1),a=0;f.length;)d.splice(af(++a*b),0,this.CreateTag(e[f.shift()]));this.shapeArgs[0]=g=d.length,h=this.shape.apply(this,this.shapeArgs);for(a=0;a<g;++a)d[a].position=new s(h[a][0],h[a][1],h[a][2]);this.weight&&this.Weight(d)}this.taglist=d},b.SetShadow=function(a){a.shadowBlur=this.shadowBlur,a.shadowOffsetX=this.shadowOffset[0],a.shadowOffsetY=this.shadowOffset[1]},b.LoadAudio=function(){if(!e&&!ag())return;this.audio=c.createElement('audio'),this.audio.src=this.activeAudio,this.track=e.createMediaElementSource(this.audio),this.gain=e.createGain(),this.track.connect(this.gain),this.gain.connect(e.destination),this.hasAudio=1,P=function(a){e.resume(),c.removeEventListener('click',P)},c.addEventListener('click',P)},b.ShowAudioIcon=function(){var a=this.audioIconSize,c=this.canvas,d=this.ctxt,k=c.width-a-3,f=c.height-a-3,g=this.audioIconThickness,h='#000',i='#fff',j=this.audioIconDark,b=this.audioOff,l='suspended';if(!e)return;b||(b=e.state===l),this.audioIcon&&this.hasAudio&&($(b,d,a,k,f,g+1,j?i:h),$(b,d,a,k,f,g,j?h:i))},b.CheckAudioIcon=function(){var a=this.audioIconSize,b=this.canvas,c=this.audioIconThickness/2,d=b.width-a-3-c,e=b.height-a-3-c;if(this.audioIcon&&this.mx>=d&&this.my>=e)return!0},b.ToggleAudio=function(){var a=this.audioOff||e&&e.state==='suspended';a||this.currentAudio&&this.currentAudio.StopAudio(),this.audioOff=!a},b.Draw=function(s){if(this.paused)return;var l=this.canvas,i=l.width,j=l.height,q=0,p=(s-this.time)*a.interval/1e3,h=i/2+this.offsetX,g=j/2+this.offsetY,d=this.ctxt,b,f,c,o=-1,e=this.taglist,k=e.length,t=this.active&&this.active.tag,m='',u=this.frontSelect,r=this.centreFunc==x,n;if(this.time=s,this.frozen&&this.drawn)return this.Animate(i,j,p);n=this.AnimateFixed(),d.setTransform(1,0,0,1,0,0);for(c=0;c<k;++c)e[c].Calc(this.transform,this.fixedAlpha);if(e=ak(e,function(a,b){return b.z-a.z}),n&&this.fixedAnim.active)b=this.fixedAnim.tag.UpdateActive(d,h,g);else if(this.active=null,this.CheckAudioIcon())m='pointer';else{for(c=0;c<k;++c)f=this.mx>=0&&this.my>=0&&this.taglist[c].CheckActive(d,h,g),f&&f.sc>q&&(!u||f.z<=0)&&(b=f,o=c,b.tag=this.taglist[c],q=f.sc);this.active=b}this.txtOpt||this.shadow&&this.SetShadow(d),d.clearRect(0,0,i,j);for(c=0;c<k;++c){if(!r&&e[c].z<=0){try{this.centreFunc(d,i,j,h,g)}catch(a){alert(a),this.centreFunc=x}r=!0}b&&b.tag==e[c]&&b.PreDraw(d,e[c],h,g)||e[c].Draw(d,h,g),b&&b.tag==e[c]&&b.PostDraw(d)}this.freezeActive&&b?this.Freeze():(this.UnFreeze(),this.drawn=k==this.listLength),this.fixedCallback&&(this.fixedCallback(this,this.fixedCallbackTag),this.fixedCallback=null),n||this.Animate(i,j,p),b&&(b.LastDraw(d),b.tag!=t&&(this.currentAudio&&this.currentAudio!=b.tag&&this.currentAudio.StopAudio(),b.tag.PlayAudio()&&(this.currentAudio=b.tag)),m=this.activeCursor),l.style.cursor=m,this.Tooltip(b,this.taglist[o]),this.audioIcon&&this.ShowAudioIcon()},b.TooltipNone=function(){},b.TooltipNative=function(b,a){b?this.canvas.title=a&&a.title?a.title:'':this.canvas.title=this.ctitle},b.SetTTDiv=function(c,d){var a=this,b=a.ttdiv.style;c!=a.ttdiv.innerHTML&&(b.display='none'),a.ttdiv.innerHTML=c,d&&(d.title=a.ttdiv.innerHTML),b.display=='none'&&!a.tttimer&&(a.tttimer=setTimeout(function(){var c=X(a.canvas.id);b.display='block',b.left=c.x+a.mx+'px',b.top=c.y+a.my+24+'px',a.tttimer=null},a.tooltipDelay))},b.TooltipDiv=function(b,a){b&&a&&a.title?this.SetTTDiv(a.title,a):!b&&this.mx!=-1&&this.my!=-1&&this.ctitle.length?this.SetTTDiv(this.ctitle):this.ttdiv.style.display='none'},b.Transform=function(c,a,b){if(a||b){var d=o(a),e=l(a),f=o(b),g=l(b),h=new m([g,0,f,0,1,0,-f,0,g]),i=new m([1,0,0,0,e,-d,0,d,e]);c.transform=c.transform.mul(h.mul(i))}},b.AnimateFixed=function(){var a,b,c,d,e;return!!(this.fadeIn&&(b=q()-this.startTime,b>=this.fadeIn?(this.fadeIn=0,this.fixedAlpha=1):this.fixedAlpha=b/this.fadeIn),this.fixedAnim)&&(this.fixedAnim.transform||(this.fixedAnim.transform=this.transform),a=this.fixedAnim,b=q()-a.t0,c=a.angle,d,e=this.animTiming(a.t,b),this.transform=a.transform,b>=a.t?(this.fixedCallbackTag=a.tag,this.fixedCallback=a.cb,this.fixedAnim=this.yaw=this.pitch=0):c*=e,d=m.Rotation(c,a.axis),this.transform=this.transform.mul(d),this.fixedAnim!=0)},b.AnimatePosition=function(g,h,f){var a=this,d=a.mx,e=a.my,b,c;!a.frozen&&d>=0&&e>=0&&d<g&&e<h?(b=a.maxSpeed,c=a.reverse?-1:1,a.lx||(a.yaw=(d*2*b/g-b)*c*f),a.ly||(a.pitch=(e*2*b/h-b)*-c*f),a.initial=null):a.initial||(a.frozen&&!a.freezeDecel?a.yaw=a.pitch=0:a.Decel(a)),this.Transform(a,a.pitch,a.yaw)},b.AnimateDrag=function(d,e,c){var a=this,b=100*c*a.maxSpeed/a.max_radius/a.zoom;a.dx||a.dy?(a.lx||(a.yaw=a.dx*b/a.stretchX),a.ly||(a.pitch=a.dy*-b/a.stretchY),a.dx=a.dy=0,a.initial=null):a.initial||a.Decel(a),this.Transform(a,a.pitch,a.yaw)},b.Freeze=function(){this.frozen||(this.preFreeze=[this.yaw,this.pitch],this.frozen=1,this.drawn=0)},b.UnFreeze=function(){this.frozen&&(this.yaw=this.preFreeze[0],this.pitch=this.preFreeze[1],this.frozen=0)},b.Decel=function(a){var b=a.minSpeed,c=p(a.yaw),d=p(a.pitch);!a.lx&&c>b&&(a.yaw=c>a.z0?a.yaw*a.decel:0),!a.ly&&d>b&&(a.pitch=d>a.z0?a.pitch*a.decel:0)},b.Zoom=function(a){this.z2=this.z1*(1/a),this.drawn=0},b.Clicked=function(b){if(this.CheckAudioIcon()){this.ToggleAudio();return}var a=this.active;try{a&&a.tag&&(this.clickToFront===!1||this.clickToFront===null?a.tag.Clicked(b):this.TagToFront(a.tag,this.clickToFront,function(){a.tag.Clicked(b)},!0))}catch(a){}},b.Wheel=function(a){var b=this.zoom+this.zoomStep*(a?1:-1);this.zoom=h(this.zoomMax,g(this.zoomMin,b)),this.Zoom(this.zoom)},b.BeginDrag=function(a){this.down=K(a,this.canvas),a.cancelBubble=!0,a.returnValue=!1,a.preventDefault&&a.preventDefault()},b.Drag=function(e,a){if(this.dragControl&&this.down){var d=this.dragThreshold*this.dragThreshold,b=a.x-this.down.x,c=a.y-this.down.y;(this.dragging||b*b+c*c>d)&&(this.dx=b,this.dy=c,this.dragging=1,this.down=a)}return this.dragging},b.EndDrag=function(){var a=this.dragging;return this.dragging=this.down=null,a};function ah(a){var b=a.targetTouches[0],c=a.targetTouches[1];return E(w(c.pageX-b.pageX,2)+w(c.pageY-b.pageY,2))}b.BeginPinch=function(a){this.pinched=[ah(a),this.zoom],a.preventDefault&&a.preventDefault()},b.Pinch=function(d){var b,c,a=this.pinched;if(!a)return;c=ah(d),b=a[1]*c/a[0],this.zoom=h(this.zoomMax,g(this.zoomMin,b)),this.Zoom(this.zoom)},b.EndPinch=function(a){this.pinched=null},b.Pause=function(){this.paused=!0},b.Resume=function(){this.paused=!1},b.SetSpeed=function(a){this.initial=a,this.yaw=a[0]*this.maxSpeed,this.pitch=a[1]*this.maxSpeed},b.FindTag=function(a){if(!n(a))return null;if(n(a.index)&&(a=a.index),!B(a))return this.taglist[a];var c,d,b;n(a.id)?(c='id',d=a.id):n(a.text)&&(c='innerText',d=a.text);for(b=0;b<this.taglist.length;++b)if(this.taglist[b].a[c]==d)return this.taglist[b]},b.RotateTag=function(a,h,i,j,f,g){var b=a.Calc(this.transform,1),c=new s(b.x,b.y,b.z),d=ay(i,h),e=c.angle(d),k=c.cross(d).unit();e==0?(this.fixedCallbackTag=a,this.fixedCallback=f):this.fixedAnim={angle:-e,axis:k,t:j,t0:q(),cb:f,tag:a,active:g}},b.TagToFront=function(a,b,c,d){this.RotateTag(a,0,0,b,c,d)},b.Volume=function(a){this.audioVolume=a*1},a.Start=function(b,c,d){a.Delete(b),a.tc[b]=new a(b,c,d)};function N(c,b){a.tc[b]&&a.tc[b][c]()}a.Linear=function(a,b){return b/a},a.Smooth=function(a,b){return.5-l(b*Math.PI/a)/2},a.Pause=function(a){N('Pause',a)},a.Resume=function(a){N('Resume',a)},a.Reload=function(a){N('Load',a)},a.Update=function(a){N('Update',a)},a.SetSpeed=function(c,b){return!!(B(b)&&a.tc[c]&&!isNaN(b[0])&&!isNaN(b[1]))&&(a.tc[c].SetSpeed(b),!0)},a.TagToFront=function(c,b){return!!B(b)&&(b.lat=b.lng=0,a.RotateTag(c,b))},a.RotateTag=function(c,b){if(B(b)&&a.tc[c]){isNaN(b.time)&&(b.time=500);var d=a.tc[c].FindTag(b);if(d)return a.tc[c].RotateTag(d,b.lat,b.lng,b.time,b.callback,b.active),!0}return!1},a.Delete=function(b){var d,e;if(j[b])if(e=c.getElementById(b),e)for(d=0;d<j[b].length;++d)am(j[b][d][0],j[b][d][1],e);delete j[b],delete a.tc[b]},a.tc={},a.options={z1:2e4,z2:2e4,z0:2e-4,freezeActive:!1,freezeDecel:!1,activeCursor:'pointer',pulsateTo:1,pulsateTime:3,reverse:!1,depth:.5,maxSpeed:.05,minSpeed:0,decel:.95,interval:20,minBrightness:.1,maxBrightness:1,outlineColour:'#ffff99',outlineThickness:2,outlineOffset:5,outlineMethod:'outline',outlineRadius:0,textColour:'#ff99ff',textHeight:15,textFont:'Helvetica, Arial, sans-serif',shadow:'#000',shadowBlur:0,shadowOffset:[0,0],initial:null,hideTags:!0,zoom:1,weight:!1,weightMode:'size',weightFrom:null,weightSize:1,weightSizeMin:null,weightSizeMax:null,weightGradient:{0:'#f00',0.33:'#ff0',0.66:'#0f0',1:'#00f'},txtOpt:!0,txtScale:2,frontSelect:!1,wheelZoom:!0,zoomMin:.3,zoomMax:3,zoomStep:.05,shape:'sphere',lock:null,tooltip:null,tooltipDelay:300,tooltipClass:'tctooltip',radiusX:1,radiusY:1,radiusZ:1,stretchX:1,stretchY:1,offsetX:0,offsetY:0,shuffleTags:!1,noSelect:!1,noMouse:!1,imageScale:1,paused:!1,dragControl:!1,dragThreshold:4,centreFunc:x,splitWidth:0,animTiming:'Smooth',clickToFront:!1,fadeIn:0,padding:0,bgColour:null,bgRadius:0,bgOutline:null,bgOutlineThickness:0,outlineIncrease:4,textAlign:'centre',textVAlign:'middle',imageMode:null,imagePosition:null,imagePadding:2,imageAlign:'centre',imageVAlign:'middle',noTagsMessage:!0,centreImage:null,pinchZoom:!1,repeatTags:0,minTags:0,imageRadius:0,scrollPause:!1,outlineDash:0,outlineDashSpace:0,outlineDashSpeed:1,activeAudio:'',audioVolume:1,audioIcon:1,audioIconSize:20,audioIconThickness:2,audioIconDark:0,altImage:0};for(r in a.options)a[r]=a.options[r];window.TagCanvas=a,t('load',function(){a.loaded=1},window)})() diff --git a/packages/client/package.json b/packages/client/package.json index 83c8086e23..f65e70870e 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -15,7 +15,7 @@ "@rollup/plugin-alias": "3.1.9", "@rollup/plugin-json": "4.1.0", "@syuilo/aiscript": "0.11.1", - "@vitejs/plugin-vue": "2.3.3", + "@vitejs/plugin-vue": "3.0.0-beta.1", "@vue/compiler-sfc": "3.2.37", "abort-controller": "3.0.0", "autobind-decorator": "2.4.0", @@ -35,7 +35,7 @@ "escape-regexp": "0.0.1", "eventemitter3": "4.0.7", "feed": "4.2.2", - "idb-keyval": "6.1.0", + "idb-keyval": "6.2.0", "insert-text-at-cursor": "0.3.0", "json5": "2.2.1", "katex": "0.15.6", @@ -45,7 +45,7 @@ "mocha": "10.0.0", "ms": "2.1.3", "nested-property": "4.0.0", - "photoswipe": "5.2.7", + "photoswipe": "5.2.8", "prismjs": "1.28.0", "private-ip": "2.3.3", "promise-limit": "2.7.0", @@ -56,28 +56,27 @@ "random-seed": "0.3.0", "reflect-metadata": "0.1.13", "rndstr": "1.0.0", - "rollup": "2.75.6", + "rollup": "2.75.7", "s-age": "1.1.2", - "sass": "1.52.3", + "sass": "1.53.0", "seedrandom": "3.0.5", "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "syuilo-password-strength": "0.0.1", "textarea-caret": "3.1.0", - "three": "0.141.0", + "three": "0.142.0", "throttle-debounce": "5.0.0", "tinycolor2": "1.4.2", - "tsc-alias": "1.6.9", + "tsc-alias": "1.6.11", "tsconfig-paths": "4.0.0", "twemoji-parser": "14.0.0", - "typescript": "4.7.3", + "typescript": "4.7.4", "uuid": "8.3.2", "v-debounce": "0.1.2", "vanilla-tilt": "1.7.2", - "vite": "2.9.10", + "vite": "3.0.0-beta.7", "vue": "3.2.37", "vue-prism-editor": "2.0.0-alpha.2", - "vue-router": "4.0.16", "vuedraggable": "4.0.1", "websocket": "1.0.34", "ws": "8.8.0" @@ -101,13 +100,13 @@ "@types/uuid": "8.3.4", "@types/websocket": "1.0.5", "@types/ws": "8.5.3", - "@typescript-eslint/eslint-plugin": "5.27.1", - "@typescript-eslint/parser": "5.27.1", + "@typescript-eslint/eslint-plugin": "5.30.0", + "@typescript-eslint/parser": "5.30.0", "cross-env": "7.0.3", - "cypress": "10.0.3", - "eslint": "8.17.0", + "cypress": "10.3.0", + "eslint": "8.18.0", "eslint-plugin-import": "2.26.0", - "eslint-plugin-vue": "9.1.0", + "eslint-plugin-vue": "9.1.1", "start-server-and-test": "1.14.0" } } diff --git a/packages/client/src/account.ts b/packages/client/src/account.ts index ce4af61f18..38f2ee4b36 100644 --- a/packages/client/src/account.ts +++ b/packages/client/src/account.ts @@ -1,11 +1,11 @@ -import { del, get, set } from '@/scripts/idb-proxy'; import { defineAsyncComponent, reactive } from 'vue'; import * as misskey from 'misskey-js'; +import { showSuspendedDialog } from './scripts/show-suspended-dialog'; +import { i18n } from './i18n'; +import { del, get, set } from '@/scripts/idb-proxy'; import { apiUrl } from '@/config'; import { waiting, api, popup, popupMenu, success, alert } from '@/os'; import { unisonReload, reloadChannel } from '@/scripts/unison-reload'; -import { showSuspendedDialog } from './scripts/show-suspended-dialog'; -import { i18n } from './i18n'; // TODO: 他のタブと永続化されたstateを同期 @@ -17,18 +17,15 @@ const accountData = localStorage.getItem('account'); export const $i = accountData ? reactive(JSON.parse(accountData) as Account) : null; export const iAmModerator = $i != null && ($i.isAdmin || $i.isModerator); +export const iAmAdmin = $i != null && $i.isAdmin; export async function signout() { waiting(); localStorage.removeItem('account'); - //#region Remove account - const accounts = await getAccounts(); - accounts.splice(accounts.findIndex(x => x.id === $i.id), 1); + await removeAccount($i.id); - if (accounts.length > 0) await set('accounts', accounts); - else await del('accounts'); - //#endregion + const accounts = await getAccounts(); //#region Remove service worker registration try { @@ -55,7 +52,7 @@ export async function signout() { } catch (err) {} //#endregion - document.cookie = `igi=; path=/`; + document.cookie = 'igi=; path=/'; if (accounts.length > 0) login(accounts[0].token); else unisonReload('/'); @@ -72,14 +69,22 @@ export async function addAccount(id: Account['id'], token: Account['token']) { } } +export async function removeAccount(id: Account['id']) { + const accounts = await getAccounts(); + accounts.splice(accounts.findIndex(x => x.id === id), 1); + + if (accounts.length > 0) await set('accounts', accounts); + else await del('accounts'); +} + function fetchAccount(token: string): Promise<Account> { return new Promise((done, fail) => { // Fetch user fetch(`${apiUrl}/i`, { method: 'POST', body: JSON.stringify({ - i: token - }) + i: token, + }), }) .then(res => res.json()) .then(res => { @@ -216,13 +221,13 @@ export async function openAccountMenu(opts: { type: 'link', icon: 'fas fa-users', text: i18n.ts.manageAccounts, - to: `/settings/accounts`, + to: '/settings/accounts', }]], ev.currentTarget ?? ev.target, { - align: 'left' + align: 'left', }); } else { popupMenu([...(opts.includeCurrentAccount ? [createItem($i)] : []), ...accountItemPromises], ev.currentTarget ?? ev.target, { - align: 'left' + align: 'left', }); } } diff --git a/packages/client/src/components/abuse-report-window.vue b/packages/client/src/components/abuse-report-window.vue index 5114349620..6b8e36c4da 100644 --- a/packages/client/src/components/abuse-report-window.vue +++ b/packages/client/src/components/abuse-report-window.vue @@ -1,5 +1,5 @@ <template> -<XWindow ref="window" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')"> +<XWindow ref="uiWindow" :initial-width="400" :initial-height="500" :can-resize="true" @closed="emit('closed')"> <template #header> <i class="fas fa-exclamation-circle" style="margin-right: 0.5em;"></i> <I18n :src="i18n.ts.reportAbuseOf" tag="span"> @@ -40,7 +40,7 @@ const emit = defineEmits<{ (ev: 'closed'): void; }>(); -const window = ref<InstanceType<typeof XWindow>>(); +const uiWindow = ref<InstanceType<typeof XWindow>>(); const comment = ref(props.initialComment || ''); function send() { @@ -52,7 +52,7 @@ function send() { type: 'success', text: i18n.ts.abuseReported }); - window.value?.close(); + uiWindow.value?.close(); emit('closed'); }); } diff --git a/packages/client/src/components/abuse-report.vue b/packages/client/src/components/abuse-report.vue index a947406f88..2b89eef85a 100644 --- a/packages/client/src/components/abuse-report.vue +++ b/packages/client/src/components/abuse-report.vue @@ -1,13 +1,19 @@ <template> -<div class="bcekxzvu _card _gap"> - <div class="_content target"> - <MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true"/> - <MkA v-user-preview="report.targetUserId" class="info" :to="userPage(report.targetUser)"> - <MkUserName class="name" :user="report.targetUser"/> - <MkAcct class="acct" :user="report.targetUser" style="display: block;"/> +<div class="bcekxzvu _gap _panel"> + <div class="target"> + <MkA v-user-preview="report.targetUserId" class="info" :to="`/user-info/${report.targetUserId}`"> + <MkAvatar class="avatar" :user="report.targetUser" :show-indicator="true" :disable-link="true"/> + <div class="names"> + <MkUserName class="name" :user="report.targetUser"/> + <MkAcct class="acct" :user="report.targetUser" style="display: block;"/> + </div> </MkA> + <MkKeyValue class="_formBlock"> + <template #key>{{ $ts.registeredDate }}</template> + <template #value>{{ new Date(report.targetUser.createdAt).toLocaleString() }} (<MkTime :time="report.targetUser.createdAt"/>)</template> + </MkKeyValue> </div> - <div class="_content"> + <div class="detail"> <div> <Mfm :text="report.comment"/> </div> @@ -18,85 +24,85 @@ <MkAcct :user="report.assignee"/> </div> <div><MkTime :time="report.createdAt"/></div> - </div> - <div class="_footer"> - <MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved"> - {{ $ts.forwardReport }} - <template #caption>{{ $ts.forwardReportIsAnonymous }}</template> - </MkSwitch> - <MkButton v-if="!report.resolved" primary @click="resolve">{{ $ts.abuseMarkAsResolved }}</MkButton> + <div class="action"> + <MkSwitch v-model="forward" :disabled="report.targetUser.host == null || report.resolved"> + {{ $ts.forwardReport }} + <template #caption>{{ $ts.forwardReportIsAnonymous }}</template> + </MkSwitch> + <MkButton v-if="!report.resolved" primary @click="resolve">{{ $ts.abuseMarkAsResolved }}</MkButton> + </div> </div> </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; - +<script lang="ts" setup> import MkButton from '@/components/ui/button.vue'; import MkSwitch from '@/components/form/switch.vue'; +import MkKeyValue from '@/components/key-value.vue'; import { acct, userPage } from '@/filters/user'; import * as os from '@/os'; -export default defineComponent({ - components: { - MkButton, - MkSwitch, - }, - - props: { - report: { - type: Object, - required: true, - } - }, +const props = defineProps<{ + report: any; +}>(); - emits: ['resolved'], +const emit = defineEmits<{ + (ev: 'resolved', reportId: string): void; +}>(); - data() { - return { - forward: this.report.forwarded, - }; - }, +let forward = $ref(props.report.forwarded); - methods: { - acct, - userPage, - - resolve() { - os.apiWithDialog('admin/resolve-abuse-user-report', { - forward: this.forward, - reportId: this.report.id, - }).then(() => { - this.$emit('resolved', this.report.id); - }); - } - } -}); +function resolve() { + os.apiWithDialog('admin/resolve-abuse-user-report', { + forward: forward, + reportId: props.report.id, + }).then(() => { + emit('resolved', props.report.id); + }); +} </script> <style lang="scss" scoped> .bcekxzvu { + display: flex; + > .target { - display: flex; - width: 100%; + width: 35%; box-sizing: border-box; text-align: left; - align-items: center; - - > .avatar { - width: 42px; - height: 42px; - } + padding: 24px; + border-right: solid 1px var(--divider); > .info { - margin-left: 0.3em; - padding: 0 8px; - flex: 1; + display: flex; + box-sizing: border-box; + align-items: center; + padding: 14px; + border-radius: 8px; + --c: rgb(255 196 0 / 15%); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + + > .avatar { + width: 42px; + height: 42px; + } + + > .names { + margin-left: 0.3em; + padding: 0 8px; + flex: 1; - > .name { - font-weight: bold; + > .name { + font-weight: bold; + } } } } + + > .detail { + flex: 1; + padding: 24px; + } } </style> diff --git a/packages/client/src/components/autocomplete.vue b/packages/client/src/components/autocomplete.vue index 1e4a4506f7..144281e3c3 100644 --- a/packages/client/src/components/autocomplete.vue +++ b/packages/client/src/components/autocomplete.vue @@ -20,6 +20,7 @@ <span v-if="emoji.isCustomEmoji" class="emoji"><img :src="defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url" :alt="emoji.emoji"/></span> <span v-else-if="!defaultStore.state.useOsNativeEmojis" class="emoji"><img :src="emoji.url" :alt="emoji.emoji"/></span> <span v-else class="emoji">{{ emoji.emoji }}</span> + <!-- eslint-disable-next-line vue/no-v-html --> <span class="name" v-html="emoji.name.replace(q, `<b>${q}</b>`)"></span> <span v-if="emoji.aliasOf" class="alias">({{ emoji.aliasOf }})</span> </li> @@ -35,6 +36,7 @@ <script lang="ts"> import { markRaw, ref, onUpdated, onMounted, onBeforeUnmount, nextTick, watch } from 'vue'; import contains from '@/scripts/contains'; +import { char2filePath } from '@/scripts/twemoji-base'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import { acct } from '@/filters/user'; import * as os from '@/os'; @@ -42,7 +44,6 @@ import { MFM_TAGS } from '@/scripts/mfm-tags'; import { defaultStore } from '@/store'; import { emojilist } from '@/scripts/emojilist'; import { instance } from '@/instance'; -import { twemojiSvgBase } from '@/scripts/twemoji-base'; import { i18n } from '@/i18n'; type EmojiDef = { @@ -55,16 +56,10 @@ type EmojiDef = { const lib = emojilist.filter(x => x.category !== 'flags'); -const char2file = (char: string) => { - let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16)); - if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f'); - return codes.filter(x => x && x.length).join('-'); -}; - const emjdb: EmojiDef[] = lib.map(x => ({ emoji: x.char, name: x.name, - url: `${twemojiSvgBase}/${char2file(x.char)}.svg` + url: char2filePath(x.char), })); for (const x of lib) { @@ -74,7 +69,7 @@ for (const x of lib) { emoji: x.char, name: k, aliasOf: x.name, - url: `${twemojiSvgBase}/${char2file(x.char)}.svg` + url: char2filePath(x.char), }); } } diff --git a/packages/client/src/components/captcha.vue b/packages/client/src/components/captcha.vue index 183658471b..7360734914 100644 --- a/packages/client/src/components/captcha.vue +++ b/packages/client/src/components/captcha.vue @@ -51,7 +51,7 @@ const variable = computed(() => { } }); -const loaded = computed(() => !!window[variable.value]); +const loaded = !!window[variable.value]; const src = computed(() => { switch (props.provider) { @@ -62,7 +62,7 @@ const src = computed(() => { const captcha = computed<Captcha>(() => window[variable.value] || {} as unknown as Captcha); -if (loaded.value) { +if (loaded) { available.value = true; } else { (document.getElementById(props.provider) || document.head.appendChild(Object.assign(document.createElement('script'), { @@ -74,7 +74,7 @@ if (loaded.value) { } function reset() { - if (captcha.value?.reset) captcha.value.reset(); + if (captcha.value.reset) captcha.value.reset(); } function requestRender() { diff --git a/packages/client/src/components/chart-tooltip.vue b/packages/client/src/components/chart-tooltip.vue index 20e094a5a7..9b57a1b3d5 100644 --- a/packages/client/src/components/chart-tooltip.vue +++ b/packages/client/src/components/chart-tooltip.vue @@ -1,25 +1,27 @@ <template> -<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'left'" :inner-margin="16" @closed="emit('closed')"> - <div v-if="title" class="qpcyisrl"> - <div class="title">{{ title }}</div> - <div v-for="x in series" class="series"> - <span class="color" :style="{ background: x.backgroundColor, borderColor: x.borderColor }"></span> - <span>{{ x.text }}</span> - </div> +<MkTooltip ref="tooltip" :showing="showing" :x="x" :y="y" :max-width="340" :direction="'top'" :inner-margin="16" @closed="emit('closed')"> + <div v-if="title || series" class="qpcyisrl"> + <div v-if="title" class="title">{{ title }}</div> + <template v-if="series"> + <div v-for="x in series" class="series"> + <span class="color" :style="{ background: x.backgroundColor, borderColor: x.borderColor }"></span> + <span>{{ x.text }}</span> + </div> + </template> </div> </MkTooltip> </template> <script lang="ts" setup> -import { } from 'vue'; +import { } from 'vue'; import MkTooltip from './ui/tooltip.vue'; const props = defineProps<{ showing: boolean; x: number; y: number; - title: string; - series: { + title?: string; + series?: { backgroundColor: string; borderColor: string; text: string; diff --git a/packages/client/src/components/chart.vue b/packages/client/src/components/chart.vue index 4e9c4e587a..fc7c4ff950 100644 --- a/packages/client/src/components/chart.vue +++ b/packages/client/src/components/chart.vue @@ -13,7 +13,7 @@ id-denylist violation when setting it. This is causing about 60+ lint issues. As this is part of Chart.js's API it makes sense to disable the check here. */ -import { defineProps, onMounted, ref, watch, PropType, onUnmounted } from 'vue'; +import { onMounted, ref, watch, PropType, onUnmounted } from 'vue'; import { Chart, ArcElement, @@ -39,7 +39,7 @@ import zoomPlugin from 'chartjs-plugin-zoom'; //import gradient from 'chartjs-plugin-gradient'; import * as os from '@/os'; import { defaultStore } from '@/store'; -import MkChartTooltip from '@/components/chart-tooltip.vue'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; const props = defineProps({ src: { @@ -53,7 +53,7 @@ const props = defineProps({ limit: { type: Number, required: false, - default: 90 + default: 90, }, span: { type: String as PropType<'hour' | 'day'>, @@ -62,22 +62,22 @@ const props = defineProps({ detailed: { type: Boolean, required: false, - default: false + default: false, }, stacked: { type: Boolean, required: false, - default: false + default: false, }, bar: { type: Boolean, required: false, - default: false + default: false, }, aspectRatio: { type: Number, required: false, - default: null + default: null, }, }); @@ -156,46 +156,11 @@ const getDate = (ago: number) => { const format = (arr) => { return arr.map((v, i) => ({ x: getDate(i).getTime(), - y: v + y: v, })); }; -const tooltipShowing = ref(false); -const tooltipX = ref(0); -const tooltipY = ref(0); -const tooltipTitle = ref(null); -const tooltipSeries = ref(null); -let disposeTooltipComponent; - -os.popup(MkChartTooltip, { - showing: tooltipShowing, - x: tooltipX, - y: tooltipY, - title: tooltipTitle, - series: tooltipSeries, -}, {}).then(({ dispose }) => { - disposeTooltipComponent = dispose; -}); - -function externalTooltipHandler(context) { - if (context.tooltip.opacity === 0) { - tooltipShowing.value = false; - return; - } - - tooltipTitle.value = context.tooltip.title[0]; - tooltipSeries.value = context.tooltip.body.map((b, i) => ({ - backgroundColor: context.tooltip.labelColors[i].backgroundColor, - borderColor: context.tooltip.labelColors[i].borderColor, - text: b.lines[0], - })); - - const rect = context.chart.canvas.getBoundingClientRect(); - - tooltipShowing.value = true; - tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX; - tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY; -} +const { handler: externalTooltipHandler } = useChartTooltip(); const render = () => { if (chartInstance) { @@ -343,7 +308,7 @@ const render = () => { min: 'original', max: 'original', }, - } + }, } : undefined, //gradient, }, @@ -367,8 +332,8 @@ const render = () => { ctx.stroke(); ctx.restore(); } - } - }] + }, + }], }); }; @@ -377,7 +342,7 @@ const exportData = () => { }; const fetchFederationChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/federation', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/federation', { limit: props.limit, span: props.span }); return { series: [{ name: 'Received', @@ -427,36 +392,36 @@ const fetchFederationChart = async (): Promise<typeof chartData> => { }; const fetchApRequestChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/ap-request', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/ap-request', { limit: props.limit, span: props.span }); return { series: [{ name: 'In', type: 'area', color: '#008FFB', - data: format(raw.inboxReceived) + data: format(raw.inboxReceived), }, { name: 'Out (succ)', type: 'area', color: '#00E396', - data: format(raw.deliverSucceeded) + data: format(raw.deliverSucceeded), }, { name: 'Out (fail)', type: 'area', color: '#FEB019', - data: format(raw.deliverFailed) - }] + data: format(raw.deliverFailed), + }], }; }; const fetchNotesChart = async (type: string): Promise<typeof chartData> => { - const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span }); return { series: [{ name: 'All', type: 'line', data: format(type === 'combined' ? sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) - : sum(raw[type].inc, negate(raw[type].dec)) + : sum(raw[type].inc, negate(raw[type].dec)), ), color: '#888888', }, { @@ -464,7 +429,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => { type: 'area', data: format(type === 'combined' ? sum(raw.local.diffs.renote, raw.remote.diffs.renote) - : raw[type].diffs.renote + : raw[type].diffs.renote, ), color: colors.green, }, { @@ -472,7 +437,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => { type: 'area', data: format(type === 'combined' ? sum(raw.local.diffs.reply, raw.remote.diffs.reply) - : raw[type].diffs.reply + : raw[type].diffs.reply, ), color: colors.yellow, }, { @@ -480,7 +445,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => { type: 'area', data: format(type === 'combined' ? sum(raw.local.diffs.normal, raw.remote.diffs.normal) - : raw[type].diffs.normal + : raw[type].diffs.normal, ), color: colors.blue, }, { @@ -488,7 +453,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => { type: 'area', data: format(type === 'combined' ? sum(raw.local.diffs.withFile, raw.remote.diffs.withFile) - : raw[type].diffs.withFile + : raw[type].diffs.withFile, ), color: colors.purple, }], @@ -496,7 +461,7 @@ const fetchNotesChart = async (type: string): Promise<typeof chartData> => { }; const fetchNotesTotalChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/notes', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/notes', { limit: props.limit, span: props.span }); return { series: [{ name: 'Combined', @@ -515,35 +480,35 @@ const fetchNotesTotalChart = async (): Promise<typeof chartData> => { }; const fetchUsersChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/users', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/users', { limit: props.limit, span: props.span }); return { series: [{ name: 'Combined', type: 'line', data: format(total ? sum(raw.local.total, raw.remote.total) - : sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)) + : sum(raw.local.inc, negate(raw.local.dec), raw.remote.inc, negate(raw.remote.dec)), ), }, { name: 'Local', type: 'area', data: format(total ? raw.local.total - : sum(raw.local.inc, negate(raw.local.dec)) + : sum(raw.local.inc, negate(raw.local.dec)), ), }, { name: 'Remote', type: 'area', data: format(total ? raw.remote.total - : sum(raw.remote.inc, negate(raw.remote.dec)) + : sum(raw.remote.inc, negate(raw.remote.dec)), ), }], }; }; const fetchActiveUsersChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/active-users', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/active-users', { limit: props.limit, span: props.span }); return { series: [{ name: 'Read & Write', @@ -595,7 +560,7 @@ const fetchActiveUsersChart = async (): Promise<typeof chartData> => { }; const fetchDriveChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span }); return { bytes: true, series: [{ @@ -607,8 +572,8 @@ const fetchDriveChart = async (): Promise<typeof chartData> => { raw.local.incSize, negate(raw.local.decSize), raw.remote.incSize, - negate(raw.remote.decSize) - ) + negate(raw.remote.decSize), + ), ), }, { name: 'Local +', @@ -631,7 +596,7 @@ const fetchDriveChart = async (): Promise<typeof chartData> => { }; const fetchDriveFilesChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/drive', { limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/drive', { limit: props.limit, span: props.span }); return { series: [{ name: 'All', @@ -642,8 +607,8 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => { raw.local.incCount, negate(raw.local.decCount), raw.remote.incCount, - negate(raw.remote.decCount) - ) + negate(raw.remote.decCount), + ), ), }, { name: 'Local +', @@ -666,29 +631,29 @@ const fetchDriveFilesChart = async (): Promise<typeof chartData> => { }; const fetchInstanceRequestsChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'In', type: 'area', color: '#008FFB', - data: format(raw.requests.received) + data: format(raw.requests.received), }, { name: 'Out (succ)', type: 'area', color: '#00E396', - data: format(raw.requests.succeeded) + data: format(raw.requests.succeeded), }, { name: 'Out (fail)', type: 'area', color: '#FEB019', - data: format(raw.requests.failed) - }] + data: format(raw.requests.failed), + }], }; }; const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Users', @@ -696,14 +661,14 @@ const fetchInstanceUsersChart = async (total: boolean): Promise<typeof chartData color: '#008FFB', data: format(total ? raw.users.total - : sum(raw.users.inc, negate(raw.users.dec)) - ) - }] + : sum(raw.users.inc, negate(raw.users.dec)), + ), + }], }; }; const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Notes', @@ -711,14 +676,14 @@ const fetchInstanceNotesChart = async (total: boolean): Promise<typeof chartData color: '#008FFB', data: format(total ? raw.notes.total - : sum(raw.notes.inc, negate(raw.notes.dec)) - ) - }] + : sum(raw.notes.inc, negate(raw.notes.dec)), + ), + }], }; }; const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Following', @@ -726,22 +691,22 @@ const fetchInstanceFfChart = async (total: boolean): Promise<typeof chartData> = color: '#008FFB', data: format(total ? raw.following.total - : sum(raw.following.inc, negate(raw.following.dec)) - ) + : sum(raw.following.inc, negate(raw.following.dec)), + ), }, { name: 'Followers', type: 'area', color: '#00E396', data: format(total ? raw.followers.total - : sum(raw.followers.inc, negate(raw.followers.dec)) - ) - }] + : sum(raw.followers.inc, negate(raw.followers.dec)), + ), + }], }; }; const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { bytes: true, series: [{ @@ -750,14 +715,14 @@ const fetchInstanceDriveUsageChart = async (total: boolean): Promise<typeof char color: '#008FFB', data: format(total ? raw.drive.totalUsage - : sum(raw.drive.incUsage, negate(raw.drive.decUsage)) - ) - }] + : sum(raw.drive.incUsage, negate(raw.drive.decUsage)), + ), + }], }; }; const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof chartData> => { - const raw = await os.api('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/instance', { host: props.args.host, limit: props.limit, span: props.span }); return { series: [{ name: 'Drive files', @@ -765,14 +730,14 @@ const fetchInstanceDriveFilesChart = async (total: boolean): Promise<typeof char color: '#008FFB', data: format(total ? raw.drive.totalFiles - : sum(raw.drive.incFiles, negate(raw.drive.decFiles)) - ) - }] + : sum(raw.drive.incFiles, negate(raw.drive.decFiles)), + ), + }], }; }; const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/user/notes', { userId: props.args.user.id, limit: props.limit, span: props.span }); return { series: [...(props.args.withoutAll ? [] : [{ name: 'All', @@ -804,7 +769,7 @@ const fetchPerUserNotesChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -819,7 +784,7 @@ const fetchPerUserFollowingChart = async (): Promise<typeof chartData> => { }; const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/user/following', { userId: props.args.user.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Local', @@ -834,7 +799,7 @@ const fetchPerUserFollowersChart = async (): Promise<typeof chartData> => { }; const fetchPerUserDriveChart = async (): Promise<typeof chartData> => { - const raw = await os.api('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span }); + const raw = await os.apiGet('charts/user/drive', { userId: props.args.user.id, limit: props.limit, span: props.span }); return { series: [{ name: 'Inc', @@ -891,10 +856,6 @@ watch(() => [props.src, props.span], fetchAndRender); onMounted(() => { fetchAndRender(); }); - -onUnmounted(() => { - if (disposeTooltipComponent) disposeTooltipComponent(); -}); /* eslint-enable id-denylist */ </script> diff --git a/packages/client/src/components/code-core.vue b/packages/client/src/components/code-core.vue index 45a38afe04..b074028821 100644 --- a/packages/client/src/components/code-core.vue +++ b/packages/client/src/components/code-core.vue @@ -1,3 +1,4 @@ +<!-- eslint-disable vue/no-v-html --> <template> <code v-if="inline" :class="`language-${prismLang}`" v-html="html"></code> <pre v-else :class="`language-${prismLang}`"><code :class="`language-${prismLang}`" v-html="html"></code></pre> @@ -5,7 +6,7 @@ <script lang="ts" setup> import { computed } from 'vue'; -import 'prismjs'; +import Prism from 'prismjs'; import 'prismjs/themes/prism-okaidia.css'; const props = defineProps<{ diff --git a/packages/client/src/components/drive-file-thumbnail.vue b/packages/client/src/components/drive-file-thumbnail.vue index dd24440e82..16c77f7267 100644 --- a/packages/client/src/components/drive-file-thumbnail.vue +++ b/packages/client/src/components/drive-file-thumbnail.vue @@ -1,6 +1,6 @@ <template> <div ref="thumbnail" class="zdjebgpv"> - <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :style="`object-fit: ${ fit }`"/> + <ImgWithBlurhash v-if="isThumbnailAvailable" :hash="file.blurhash" :src="file.thumbnailUrl" :alt="file.name" :title="file.name" :cover="fit !== 'contain'"/> <i v-else-if="is === 'image'" class="fas fa-file-image icon"></i> <i v-else-if="is === 'video'" class="fas fa-file-video icon"></i> <i v-else-if="is === 'audio' || is === 'midi'" class="fas fa-music icon"></i> @@ -33,16 +33,16 @@ const is = computed(() => { if (props.file.type.endsWith('/pdf')) return 'pdf'; if (props.file.type.startsWith('text/')) return 'textfile'; if ([ - "application/zip", - "application/x-cpio", - "application/x-bzip", - "application/x-bzip2", - "application/java-archive", - "application/x-rar-compressed", - "application/x-tar", - "application/gzip", - "application/x-7z-compressed" - ].some(archiveType => archiveType === props.file.type)) return 'archive'; + 'application/zip', + 'application/x-cpio', + 'application/x-bzip', + 'application/x-bzip2', + 'application/java-archive', + 'application/x-rar-compressed', + 'application/x-tar', + 'application/gzip', + 'application/x-7z-compressed', + ].some(archiveType => archiveType === props.file.type)) return 'archive'; return 'unknown'; }); @@ -57,9 +57,9 @@ const isThumbnailAvailable = computed(() => { .zdjebgpv { position: relative; display: flex; - background: #e1e1e1; + background: var(--panel); border-radius: 8px; - overflow: clip; + overflow: hidden; overflow: clip; > .icon-sub { position: absolute; diff --git a/packages/client/src/components/emoji-picker.section.vue b/packages/client/src/components/emoji-picker.section.vue index 52f7047487..e2a80d5466 100644 --- a/packages/client/src/components/emoji-picker.section.vue +++ b/packages/client/src/components/emoji-picker.section.vue @@ -1,15 +1,17 @@ <template> +<!-- このコンポーネントの要素のclassは親から利用されるのでむやみに弄らないこと --> <section> <header class="_acrylic" @click="shown = !shown"> <i class="toggle fa-fw" :class="shown ? 'fas fa-chevron-down' : 'fas fa-chevron-up'"></i> <slot></slot> ({{ emojis.length }}) </header> - <div v-if="shown"> - <button v-for="emoji in emojis" + <div v-if="shown" class="body"> + <button + v-for="emoji in emojis" :key="emoji" - class="_button" + class="_button item" @click="emit('chosen', emoji, $event)" > - <MkEmoji :emoji="emoji" :normal="true"/> + <MkEmoji class="emoji" :emoji="emoji" :normal="true"/> </button> </div> </section> diff --git a/packages/client/src/components/emoji-picker.vue b/packages/client/src/components/emoji-picker.vue index 64732e7033..4a46e0ecfb 100644 --- a/packages/client/src/components/emoji-picker.vue +++ b/packages/client/src/components/emoji-picker.vue @@ -3,63 +3,67 @@ <input ref="search" v-model.trim="q" class="search" data-prevent-emoji-insert :class="{ filled: q != null && q != '' }" :placeholder="i18n.ts.search" type="search" @paste.stop="paste" @keyup.enter="done()"> <div ref="emojis" class="emojis"> <section class="result"> - <div v-if="searchResultCustom.length > 0"> - <button v-for="emoji in searchResultCustom" + <div v-if="searchResultCustom.length > 0" class="body"> + <button + v-for="emoji in searchResultCustom" :key="emoji.id" - class="_button" + class="_button item" :title="emoji.name" tabindex="0" @click="chosen(emoji, $event)" > <!--<MkEmoji v-if="emoji.char != null" :emoji="emoji.char"/>--> - <img :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> + <img class="emoji" :src="disableShowingAnimatedImages ? getStaticImageUrl(emoji.url) : emoji.url"/> </button> </div> - <div v-if="searchResultUnicode.length > 0"> - <button v-for="emoji in searchResultUnicode" + <div v-if="searchResultUnicode.length > 0" class="body"> + <button + v-for="emoji in searchResultUnicode" :key="emoji.name" - class="_button" + class="_button item" :title="emoji.name" tabindex="0" @click="chosen(emoji, $event)" > - <MkEmoji :emoji="emoji.char"/> + <MkEmoji class="emoji" :emoji="emoji.char"/> </button> </div> </section> - <div v-if="tab === 'index'" class="index"> + <div v-if="tab === 'index'" class="group index"> <section v-if="showPinned"> - <div> - <button v-for="emoji in pinned" + <div class="body"> + <button + v-for="emoji in pinned" :key="emoji" - class="_button" + class="_button item" tabindex="0" @click="chosen(emoji, $event)" > - <MkEmoji :emoji="emoji" :normal="true"/> + <MkEmoji class="emoji" :emoji="emoji" :normal="true"/> </button> </div> </section> <section> <header class="_acrylic"><i class="far fa-clock fa-fw"></i> {{ i18n.ts.recentUsed }}</header> - <div> - <button v-for="emoji in recentlyUsedEmojis" + <div class="body"> + <button + v-for="emoji in recentlyUsedEmojis" :key="emoji" - class="_button" + class="_button item" @click="chosen(emoji, $event)" > - <MkEmoji :emoji="emoji" :normal="true"/> + <MkEmoji class="emoji" :emoji="emoji" :normal="true"/> </button> </div> </section> </div> - <div> + <div v-once class="group"> <header class="_acrylic">{{ i18n.ts.customEmojis }}</header> <XSection v-for="category in customEmojiCategories" :key="'custom:' + category" :initial-shown="false" :emojis="customEmojis.filter(e => e.category === category).map(e => ':' + e.name + ':')" @chosen="chosen">{{ category || i18n.ts.other }}</XSection> </div> - <div> + <div v-once class="group"> <header class="_acrylic">{{ i18n.ts.emoji }}</header> <XSection v-for="category in categories" :key="category" :emojis="emojilist.filter(e => e.category === category).map(e => e.char)" @chosen="chosen">{{ category }}</XSection> </div> @@ -76,6 +80,7 @@ <script lang="ts" setup> import { ref, computed, watch, onMounted } from 'vue'; import * as Misskey from 'misskey-js'; +import XSection from './emoji-picker.section.vue'; import { emojilist, UnicodeEmojiDef, unicodeEmojiCategories as categories } from '@/scripts/emojilist'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; import Ripple from '@/components/ripple.vue'; @@ -83,7 +88,6 @@ import * as os from '@/os'; import { isTouchUsing } from '@/scripts/touch'; import { deviceKind } from '@/scripts/device-kind'; import { emojiCategories, instance } from '@/instance'; -import XSection from './emoji-picker.section.vue'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; @@ -266,7 +270,7 @@ watch(q, () => { function focus() { if (!['smartphone', 'tablet'].includes(deviceKind) && !isTouchUsing) { search.value?.focus({ - preventScroll: true + preventScroll: true, }); } } @@ -415,19 +419,16 @@ defineExpose({ font-size: 15px; } - > div { + > .body { display: grid; grid-template-columns: var(--columns); + font-size: 30px; - > button { + > .item { aspect-ratio: 1 / 1; width: auto; height: auto; min-width: 0; - - > * { - font-size: 30px; - } } } } @@ -478,7 +479,7 @@ defineExpose({ display: none; } - > div { + > .group { &:not(.index) { padding: 4px 0 8px 0; border-top: solid 0.5px var(--divider); @@ -513,16 +514,18 @@ defineExpose({ } } - > div { + > .body { position: relative; padding: $pad; - > button { + > .item { position: relative; padding: 0; width: var(--eachSize); height: var(--eachSize); + contain: strict; border-radius: 4px; + font-size: 24px; &:focus-visible { outline: solid 2px var(--focus); @@ -538,8 +541,7 @@ defineExpose({ box-shadow: inset 0 0.15em 0.3em rgba(27, 31, 35, 0.15); } - > * { - font-size: 24px; + > .emoji { height: 1.25em; vertical-align: -.25em; pointer-events: none; diff --git a/packages/client/src/components/file-list-for-admin.vue b/packages/client/src/components/file-list-for-admin.vue new file mode 100644 index 0000000000..489c017a93 --- /dev/null +++ b/packages/client/src/components/file-list-for-admin.vue @@ -0,0 +1,118 @@ +<template> +<div> + <MkPagination v-slot="{items}" :pagination="pagination" class="urempief" :class="{ grid: viewMode === 'grid' }"> + <MkA + v-for="file in items" + :key="file.id" + v-tooltip.mfm="`${file.type}\n${bytes(file.size)}\n${new Date(file.createdAt).toLocaleString()}\nby ${file.user ? '@' + Acct.toString(file.user) : 'system'}`" + :to="`/admin/file/${file.id}`" + class="file _button" + > + <div v-if="file.isSensitive" class="sensitive-label">{{ i18n.ts.sensitive }}</div> + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + <div v-if="viewMode === 'list'" class="body"> + <div> + <small style="opacity: 0.7;">{{ file.name }}</small> + </div> + <div> + <MkAcct v-if="file.user" :user="file.user"/> + <div v-else>{{ i18n.ts.system }}</div> + </div> + <div> + <span style="margin-right: 1em;">{{ file.type }}</span> + <span>{{ bytes(file.size) }}</span> + </div> + <div> + <span>{{ i18n.ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span> + </div> + </div> + </MkA> + </MkPagination> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import MkSwitch from '@/components/ui/switch.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + pagination: any; + viewMode: 'grid' | 'list'; +}>(); +</script> + +<style lang="scss" scoped> +@keyframes sensitive-blink { + 0% { opacity: 1; } + 50% { opacity: 0; } +} + +.urempief { + margin-top: var(--margin); + + &.list { + > .file { + display: flex; + width: 100%; + box-sizing: border-box; + text-align: left; + align-items: center; + + &:hover { + color: var(--accent); + } + + > .thumbnail { + width: 128px; + height: 128px; + } + + > .body { + margin-left: 0.3em; + padding: 8px; + flex: 1; + + @media (max-width: 500px) { + font-size: 14px; + } + } + } + } + + &.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + grid-gap: 12px; + margin: var(--margin) 0; + + > .file { + position: relative; + aspect-ratio: 1; + + > .thumbnail { + width: 100%; + height: 100%; + } + + > .sensitive-label { + position: absolute; + z-index: 10; + top: 8px; + left: 8px; + padding: 2px 4px; + background: #ff0000bf; + color: #fff; + border-radius: 4px; + font-size: 85%; + animation: sensitive-blink 1s infinite; + } + } + } +} +</style> diff --git a/packages/client/src/components/forgot-password.vue b/packages/client/src/components/forgot-password.vue index 19c1f23c85..6ed89d45d7 100644 --- a/packages/client/src/components/forgot-password.vue +++ b/packages/client/src/components/forgot-password.vue @@ -9,12 +9,12 @@ <form v-if="instance.enableEmail" class="bafeceda" @submit.prevent="onSubmit"> <div class="main _formRoot"> - <MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required> + <MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required> <template #label>{{ i18n.ts.username }}</template> <template #prefix>@</template> </MkInput> - <MkInput v-model="email" class="_formBlock" type="email" spellcheck="false" required> + <MkInput v-model="email" class="_formBlock" type="email" :spellcheck="false" required> <template #label>{{ i18n.ts.emailAddress }}</template> <template #caption>{{ i18n.ts._forgotPassword.enterEmail }}</template> </MkInput> diff --git a/packages/client/src/components/form-dialog.vue b/packages/client/src/components/form-dialog.vue index 11459f5937..f05dde16f8 100644 --- a/packages/client/src/components/form-dialog.vue +++ b/packages/client/src/components/form-dialog.vue @@ -1,5 +1,6 @@ <template> -<XModalWindow ref="dialog" +<XModalWindow + ref="dialog" :width="450" :can-close="false" :with-ok-button="true" @@ -37,10 +38,10 @@ <option v-for="item in form[item].enum" :key="item.value" :value="item.value">{{ item.label }}</option> </FormSelect> <FormRadios v-else-if="form[item].type === 'radio'" v-model="values[item]" class="_formBlock"> - <template #caption><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> + <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> <option v-for="item in form[item].options" :key="item.value" :value="item.value">{{ item.label }}</option> </FormRadios> - <FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].mim" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter" class="_formBlock"> + <FormRange v-else-if="form[item].type === 'range'" v-model="values[item]" :min="form[item].min" :max="form[item].max" :step="form[item].step" :text-converter="form[item].textConverter" class="_formBlock"> <template #label><span v-text="form[item].label || item"></span><span v-if="form[item].required === false"> ({{ $ts.optional }})</span></template> <template v-if="form[item].description" #caption>{{ form[item].description }}</template> </FormRange> @@ -55,7 +56,6 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import XModalWindow from '@/components/ui/modal-window.vue'; import FormInput from './form/input.vue'; import FormTextarea from './form/textarea.vue'; import FormSwitch from './form/switch.vue'; @@ -63,6 +63,7 @@ import FormSelect from './form/select.vue'; import FormRange from './form/range.vue'; import MkButton from './ui/button.vue'; import FormRadios from './form/radios.vue'; +import XModalWindow from '@/components/ui/modal-window.vue'; export default defineComponent({ components: { @@ -91,31 +92,31 @@ export default defineComponent({ data() { return { - values: {} + values: {}, }; }, created() { for (const item in this.form) { - this.values[item] = this.form[item].hasOwnProperty('default') ? this.form[item].default : null; + this.values[item] = this.form[item].default ?? null; } }, methods: { ok() { this.$emit('done', { - result: this.values + result: this.values, }); this.$refs.dialog.close(); }, cancel() { this.$emit('done', { - canceled: true + canceled: true, }); this.$refs.dialog.close(); - } - } + }, + }, }); </script> diff --git a/packages/client/src/components/form/checkbox.vue b/packages/client/src/components/form/checkbox.vue new file mode 100644 index 0000000000..fadb770aee --- /dev/null +++ b/packages/client/src/components/form/checkbox.vue @@ -0,0 +1,143 @@ +<template> +<div + class="ziffeoms" + :class="{ disabled, checked }" +> + <input + ref="input" + type="checkbox" + :disabled="disabled" + @keydown.enter="toggle" + > + <span ref="button" v-adaptive-border v-tooltip="checked ? $ts.itsOn : $ts.itsOff" class="button" @click.prevent="toggle"> + <i class="check fas fa-check"></i> + </span> + <span class="label"> + <!-- TODO: 無名slotの方は廃止 --> + <span @click="toggle"><slot name="label"></slot><slot></slot></span> + <p class="caption"><slot name="caption"></slot></p> + </span> +</div> +</template> + +<script lang="ts" setup> +import { toRefs, Ref } from 'vue'; +import * as os from '@/os'; +import Ripple from '@/components/ripple.vue'; + +const props = defineProps<{ + modelValue: boolean | Ref<boolean>; + disabled?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'update:modelValue', v: boolean): void; +}>(); + +let button = $ref<HTMLElement>(); +const checked = toRefs(props).modelValue; +const toggle = () => { + if (props.disabled) return; + emit('update:modelValue', !checked.value); + + if (!checked.value) { + const rect = button.getBoundingClientRect(); + const x = rect.left + (button.offsetWidth / 2); + const y = rect.top + (button.offsetHeight / 2); + os.popup(Ripple, { x, y, particle: false }, {}, 'end'); + } +}; +</script> + +<style lang="scss" scoped> +.ziffeoms { + position: relative; + display: flex; + transition: all 0.2s ease; + + > * { + user-select: none; + } + + > input { + position: absolute; + width: 0; + height: 0; + opacity: 0; + margin: 0; + } + + > .button { + position: relative; + display: inline-flex; + flex-shrink: 0; + margin: 0; + box-sizing: border-box; + width: 23px; + height: 23px; + outline: none; + background: var(--panel); + border: solid 1px var(--panel); + border-radius: 4px; + cursor: pointer; + transition: inherit; + + > .check { + margin: auto; + opacity: 0; + color: var(--fgOnAccent); + font-size: 13px; + transform: scale(0.5); + transition: all 0.2s ease; + } + } + + &:hover { + > .button { + border-color: var(--inputBorderHover) !important; + } + } + + > .label { + margin-left: 12px; + margin-top: 2px; + display: block; + transition: inherit; + color: var(--fg); + + > span { + display: block; + line-height: 20px; + cursor: pointer; + transition: inherit; + } + + > .caption { + margin: 8px 0 0 0; + color: var(--fgTransparentWeak); + font-size: 0.85em; + + &:empty { + display: none; + } + } + } + + &.disabled { + opacity: 0.6; + cursor: not-allowed; + } + + &.checked { + > .button { + background-color: var(--accent) !important; + border-color: var(--accent) !important; + + > .check { + opacity: 1; + transform: scale(1); + } + } + } +} +</style> diff --git a/packages/client/src/components/form/folder.vue b/packages/client/src/components/form/folder.vue index 1b960657d7..a9d8bd97b8 100644 --- a/packages/client/src/components/form/folder.vue +++ b/packages/client/src/components/form/folder.vue @@ -9,13 +9,13 @@ <i v-else class="fas fa-angle-down icon"></i> </span> </div> - <keep-alive> + <KeepAlive> <div v-if="openedAtLeastOnce" v-show="opened" class="body"> <MkSpacer :margin-min="14" :margin-max="22"> <slot></slot> </MkSpacer> </div> - </keep-alive> + </KeepAlive> </div> </template> diff --git a/packages/client/src/components/form/group.vue b/packages/client/src/components/form/group.vue deleted file mode 100644 index 1e8376ca44..0000000000 --- a/packages/client/src/components/form/group.vue +++ /dev/null @@ -1,36 +0,0 @@ -<template> -<div v-sticky-container class="adfeebaf _formBlock"> - <div class="label"><slot name="label"></slot></div> - <div class="main _formRoot"> - <slot></slot> - </div> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; - -export default defineComponent({ -}); -</script> - -<style lang="scss" scoped> -.adfeebaf { - padding: 24px 24px; - border: solid 1px var(--divider); - border-radius: var(--radius); - - > .label { - font-weight: bold; - padding: 0 0 16px 0; - - &:empty { - display: none; - } - } - - > .main { - - } -} -</style> diff --git a/packages/client/src/components/form/input.vue b/packages/client/src/components/form/input.vue index 7165671af3..ec1ad20de3 100644 --- a/packages/client/src/components/form/input.vue +++ b/packages/client/src/components/form/input.vue @@ -3,7 +3,8 @@ <div class="label" @click="focus"><slot name="label"></slot></div> <div class="input" :class="{ inline, disabled, focused }"> <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div> - <input ref="inputEl" + <input + ref="inputEl" v-model="v" v-adaptive-border :type="type" @@ -32,176 +33,118 @@ </div> </template> -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; -import MkButton from '@/components/ui/button.vue'; +<script lang="ts" setup> +import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs } from 'vue'; import { debounce } from 'throttle-debounce'; +import MkButton from '@/components/ui/button.vue'; +import { useInterval } from '@/scripts/use-interval'; -export default defineComponent({ - components: { - MkButton, - }, - - props: { - modelValue: { - required: true - }, - type: { - type: String, - required: false - }, - required: { - type: Boolean, - required: false - }, - readonly: { - type: Boolean, - required: false - }, - disabled: { - type: Boolean, - required: false - }, - pattern: { - type: String, - required: false - }, - placeholder: { - type: String, - required: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - autocomplete: { - required: false - }, - spellcheck: { - required: false - }, - step: { - required: false - }, - datalist: { - type: Array, - required: false, - }, - inline: { - type: Boolean, - required: false, - default: false - }, - debounce: { - type: Boolean, - required: false, - default: false - }, - manualSave: { - type: Boolean, - required: false, - default: false - }, - }, - - emits: ['change', 'keydown', 'enter', 'update:modelValue'], - - setup(props, context) { - const { modelValue, type, autofocus } = toRefs(props); - const v = ref(modelValue.value); - const id = Math.random().toString(); // TODO: uuid? - const focused = ref(false); - const changed = ref(false); - const invalid = ref(false); - const filled = computed(() => v.value !== '' && v.value != null); - const inputEl = ref<HTMLElement>(); - const prefixEl = ref<HTMLElement>(); - const suffixEl = ref<HTMLElement>(); +const props = defineProps<{ + modelValue: string | number; + type?: 'text' | 'number' | 'password' | 'email' | 'url' | 'date' | 'time'; + required?: boolean; + readonly?: boolean; + disabled?: boolean; + pattern?: string; + placeholder?: string; + autofocus?: boolean; + autocomplete?: boolean; + spellcheck?: boolean; + step?: any; + datalist?: string[]; + inline?: boolean; + debounce?: boolean; + manualSave?: boolean; + small?: boolean; + large?: boolean; +}>(); - const focus = () => inputEl.value.focus(); - const onInput = (ev) => { - changed.value = true; - context.emit('change', ev); - }; - const onKeydown = (ev: KeyboardEvent) => { - context.emit('keydown', ev); +const emit = defineEmits<{ + (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'keydown', _ev: KeyboardEvent): void; + (ev: 'enter'): void; + (ev: 'update:modelValue', value: string | number): void; +}>(); - if (ev.code === 'Enter') { - context.emit('enter'); - } - }; +const { modelValue, type, autofocus } = toRefs(props); +const v = ref(modelValue.value); +const id = Math.random().toString(); // TODO: uuid? +const focused = ref(false); +const changed = ref(false); +const invalid = ref(false); +const filled = computed(() => v.value !== '' && v.value != null); +const inputEl = ref<HTMLElement>(); +const prefixEl = ref<HTMLElement>(); +const suffixEl = ref<HTMLElement>(); +const height = + props.small ? 38 : + props.large ? 42 : + 40; - const updated = () => { - changed.value = false; - if (type?.value === 'number') { - context.emit('update:modelValue', parseFloat(v.value)); - } else { - context.emit('update:modelValue', v.value); - } - }; +const focus = () => inputEl.value.focus(); +const onInput = (ev: KeyboardEvent) => { + changed.value = true; + emit('change', ev); +}; +const onKeydown = (ev: KeyboardEvent) => { + emit('keydown', ev); - const debouncedUpdated = debounce(1000, updated); + if (ev.code === 'Enter') { + emit('enter'); + } +}; - watch(modelValue, newValue => { - v.value = newValue; - }); +const updated = () => { + changed.value = false; + if (type.value === 'number') { + emit('update:modelValue', parseFloat(v.value)); + } else { + emit('update:modelValue', v.value); + } +}; - watch(v, newValue => { - if (!props.manualSave) { - if (props.debounce) { - debouncedUpdated(); - } else { - updated(); - } - } +const debouncedUpdated = debounce(1000, updated); - invalid.value = inputEl.value.validity.badInput; - }); +watch(modelValue, newValue => { + v.value = newValue; +}); - onMounted(() => { - nextTick(() => { - if (autofocus.value) { - focus(); - } +watch(v, newValue => { + if (!props.manualSave) { + if (props.debounce) { + debouncedUpdated(); + } else { + updated(); + } + } - // このコンポーネントが作成された時、非表示状態である場合がある - // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する - const clock = window.setInterval(() => { - if (prefixEl.value) { - if (prefixEl.value.offsetWidth) { - inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; - } - } - if (suffixEl.value) { - if (suffixEl.value.offsetWidth) { - inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; - } - } - }, 100); + invalid.value = inputEl.value.validity.badInput; +}); - onUnmounted(() => { - window.clearInterval(clock); - }); - }); - }); +// このコンポーネントが作成された時、非表示状態である場合がある +// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する +useInterval(() => { + if (prefixEl.value) { + if (prefixEl.value.offsetWidth) { + inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; + } + } + if (suffixEl.value) { + if (suffixEl.value.offsetWidth) { + inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; + } + } +}, 100, { + immediate: true, + afterMounted: true, +}); - return { - id, - v, - focused, - invalid, - changed, - filled, - inputEl, - prefixEl, - suffixEl, - focus, - onInput, - onKeydown, - updated, - }; - }, +onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + }); }); </script> @@ -228,14 +171,13 @@ export default defineComponent({ } > .input { - $height: 42px; position: relative; > input { appearance: none; -webkit-appearance: none; display: block; - height: $height; + height: v-bind("height + 'px'"); width: 100%; margin: 0; padding: 0 12px; @@ -265,7 +207,7 @@ export default defineComponent({ top: 0; padding: 0 12px; font-size: 1em; - height: $height; + height: v-bind("height + 'px'"); pointer-events: none; &:empty { diff --git a/packages/client/src/components/form/radio.vue b/packages/client/src/components/form/radio.vue index 2becbec6f3..b4d39507e3 100644 --- a/packages/client/src/components/form/radio.vue +++ b/packages/client/src/components/form/radio.vue @@ -7,7 +7,8 @@ :aria-disabled="disabled" @click="toggle" > - <input type="radio" + <input + type="radio" :disabled="disabled" > <span class="button"> @@ -23,27 +24,27 @@ import { defineComponent } from 'vue'; export default defineComponent({ props: { modelValue: { - required: false + required: false, }, value: { - required: false + required: false, }, disabled: { type: Boolean, - default: false - } + default: false, + }, }, computed: { checked(): boolean { return this.modelValue === this.value; - } + }, }, methods: { toggle() { if (this.disabled) return; this.$emit('update:modelValue', this.value); - } - } + }, + }, }); </script> @@ -53,7 +54,8 @@ export default defineComponent({ display: inline-block; text-align: left; cursor: pointer; - padding: 10px 12px; + padding: 9px 12px; + min-width: 60px; background-color: var(--panel); background-clip: padding-box !important; border: solid 1px var(--panel); diff --git a/packages/client/src/components/form/radios.vue b/packages/client/src/components/form/radios.vue index a52acae9e1..bde4a8fb00 100644 --- a/packages/client/src/components/form/radios.vue +++ b/packages/client/src/components/form/radios.vue @@ -4,11 +4,11 @@ import MkRadio from './radio.vue'; export default defineComponent({ components: { - MkRadio + MkRadio, }, props: { modelValue: { - required: false + required: false, }, }, data() { @@ -19,7 +19,7 @@ export default defineComponent({ watch: { value() { this.$emit('update:modelValue', this.value); - } + }, }, render() { let options = this.$slots.default(); @@ -30,25 +30,25 @@ export default defineComponent({ if (options.length === 1 && options[0].props == null) options = options[0].children; return h('div', { - class: 'novjtcto' + class: 'novjtcto', }, [ ...(label ? [h('div', { - class: 'label' + class: 'label', }, [label])] : []), h('div', { - class: 'body' + class: 'body', }, options.map(option => h(MkRadio, { - key: option.key, - value: option.props.value, - modelValue: this.value, - 'onUpdate:modelValue': value => this.value = value, - }, option.children)), + key: option.key, + value: option.props.value, + modelValue: this.value, + 'onUpdate:modelValue': value => this.value = value, + }, option.children)), ), ...(caption ? [h('div', { - class: 'caption' + class: 'caption', }, [caption])] : []), ]); - } + }, }); </script> @@ -65,9 +65,9 @@ export default defineComponent({ } > .body { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); - grid-gap: 12px; + display: flex; + gap: 12px; + flex-wrap: wrap; } > .caption { diff --git a/packages/client/src/components/form/range.vue b/packages/client/src/components/form/range.vue index 07f2c23124..ebec482d84 100644 --- a/packages/client/src/components/form/range.vue +++ b/packages/client/src/components/form/range.vue @@ -1,164 +1,142 @@ <template> <div class="timctyfi" :class="{ disabled }"> <div class="label"><slot name="label"></slot></div> - <div v-panel class="body"> + <div v-adaptive-border class="body"> <div ref="containerEl" class="container"> <div class="track"> - <div class="highlight" :style="{ width: (steppedValue * 100) + '%' }"></div> + <div class="highlight" :style="{ width: (steppedRawValue * 100) + '%' }"></div> </div> - <div v-if="steps" class="ticks"> + <div v-if="steps && showTicks" class="ticks"> <div v-for="i in (steps + 1)" class="tick" :style="{ left: (((i - 1) / steps) * 100) + '%' }"></div> </div> <div ref="thumbEl" v-tooltip="textConverter(finalValue)" class="thumb" :style="{ left: thumbPosition + 'px' }" @mousedown="onMousedown" @touchstart="onMousedown"></div> </div> </div> + <div class="caption"><slot name="caption"></slot></div> </div> </template> -<script lang="ts"> -import { computed, defineAsyncComponent, defineComponent, onMounted, onUnmounted, ref, watch } from 'vue'; +<script lang="ts" setup> +import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue'; import * as os from '@/os'; -export default defineComponent({ - props: { - modelValue: { - type: Number, - required: false, - default: 0 - }, - disabled: { - type: Boolean, - required: false, - default: false - }, - min: { - type: Number, - required: false, - default: 0 - }, - max: { - type: Number, - required: false, - default: 100 - }, - step: { - type: Number, - required: false, - default: 1 - }, - autofocus: { - type: Boolean, - required: false - }, - textConverter: { - type: Function, - required: false, - default: (v) => v.toString(), - }, - }, +const props = withDefaults(defineProps<{ + modelValue: number; + disabled?: boolean; + min: number; + max: number; + step?: number; + textConverter?: (value: number) => string, + showTicks?: boolean; +}>(), { + step: 1, + textConverter: (v) => v.toString(), +}); - setup(props, context) { - const containerEl = ref<HTMLElement>(); - const thumbEl = ref<HTMLElement>(); +const emit = defineEmits<{ + (ev: 'update:modelValue', value: number): void; +}>(); - const rawValue = ref((props.modelValue - props.min) / (props.max - props.min)); - const steppedValue = computed(() => { - if (props.step) { - const step = props.step / (props.max - props.min); - return (step * Math.round(rawValue.value / step)); - } else { - return rawValue.value; - } - }); - const finalValue = computed(() => { - return (steppedValue.value * (props.max - props.min)) + props.min; - }); - watch(finalValue, () => { - context.emit('update:modelValue', finalValue.value); - }); +const containerEl = ref<HTMLElement>(); +const thumbEl = ref<HTMLElement>(); - const thumbWidth = computed(() => { - if (thumbEl.value == null) return 0; - return thumbEl.value!.offsetWidth; - }); - const thumbPosition = ref(0); - const calcThumbPosition = () => { - if (containerEl.value == null) { - thumbPosition.value = 0; - } else { - thumbPosition.value = (containerEl.value.offsetWidth - thumbWidth.value) * steppedValue.value; - } - }; - watch([steppedValue, containerEl], calcThumbPosition); - onMounted(() => { - const ro = new ResizeObserver((entries, observer) => { - calcThumbPosition(); - }); - ro.observe(containerEl.value); - onUnmounted(() => { - ro.disconnect(); - }); - }); +const rawValue = ref((props.modelValue - props.min) / (props.max - props.min)); +const steppedRawValue = computed(() => { + if (props.step) { + const step = props.step / (props.max - props.min); + return (step * Math.round(rawValue.value / step)); + } else { + return rawValue.value; + } +}); +const finalValue = computed(() => { + if (Number.isInteger(props.step)) { + return Math.round((steppedRawValue.value * (props.max - props.min)) + props.min); + } else { + return (steppedRawValue.value * (props.max - props.min)) + props.min; + } +}); - const steps = computed(() => { - if (props.step) { - return (props.max - props.min) / props.step; - } else { - return 0; - } - }); +const thumbWidth = computed(() => { + if (thumbEl.value == null) return 0; + return thumbEl.value!.offsetWidth; +}); +const thumbPosition = ref(0); +const calcThumbPosition = () => { + if (containerEl.value == null) { + thumbPosition.value = 0; + } else { + thumbPosition.value = (containerEl.value.offsetWidth - thumbWidth.value) * steppedRawValue.value; + } +}; +watch([steppedRawValue, containerEl], calcThumbPosition); - const onMousedown = (ev: MouseEvent | TouchEvent) => { - ev.preventDefault(); +let ro: ResizeObserver | undefined; - const tooltipShowing = ref(true); - os.popup(defineAsyncComponent(() => import('@/components/ui/tooltip.vue')), { - showing: tooltipShowing, - text: computed(() => { - return props.textConverter(finalValue.value); - }), - targetElement: thumbEl, - }, {}, 'closed'); +onMounted(() => { + ro = new ResizeObserver((entries, observer) => { + calcThumbPosition(); + }); + ro.observe(containerEl.value); +}); - const style = document.createElement('style'); - style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }')); - document.head.appendChild(style); +onUnmounted(() => { + if (ro) ro.disconnect(); +}); - const onDrag = (ev: MouseEvent | TouchEvent) => { - ev.preventDefault(); - const containerRect = containerEl.value!.getBoundingClientRect(); - const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX; - const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth.value / 2)); - rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth.value))); - }; +const steps = computed(() => { + if (props.step) { + return (props.max - props.min) / props.step; + } else { + return 0; + } +}); - const onMouseup = () => { - document.head.removeChild(style); - tooltipShowing.value = false; - window.removeEventListener('mousemove', onDrag); - window.removeEventListener('touchmove', onDrag); - window.removeEventListener('mouseup', onMouseup); - window.removeEventListener('touchend', onMouseup); - }; +const onMousedown = (ev: MouseEvent | TouchEvent) => { + ev.preventDefault(); - window.addEventListener('mousemove', onDrag); - window.addEventListener('touchmove', onDrag); - window.addEventListener('mouseup', onMouseup, { once: true }); - window.addEventListener('touchend', onMouseup, { once: true }); - }; + const tooltipShowing = ref(true); + os.popup(defineAsyncComponent(() => import('@/components/ui/tooltip.vue')), { + showing: tooltipShowing, + text: computed(() => { + return props.textConverter(finalValue.value); + }), + targetElement: thumbEl, + }, {}, 'closed'); - return { - rawValue, - finalValue, - steppedValue, - onMousedown, - containerEl, - thumbEl, - thumbPosition, - steps, - }; - }, -}); + const style = document.createElement('style'); + style.appendChild(document.createTextNode('* { cursor: grabbing !important; } body * { pointer-events: none !important; }')); + document.head.appendChild(style); + + const onDrag = (ev: MouseEvent | TouchEvent) => { + ev.preventDefault(); + const containerRect = containerEl.value!.getBoundingClientRect(); + const pointerX = ev.touches && ev.touches.length > 0 ? ev.touches[0].clientX : ev.clientX; + const pointerPositionOnContainer = pointerX - (containerRect.left + (thumbWidth.value / 2)); + rawValue.value = Math.min(1, Math.max(0, pointerPositionOnContainer / (containerEl.value!.offsetWidth - thumbWidth.value))); + }; + + let beforeValue = finalValue.value; + + const onMouseup = () => { + document.head.removeChild(style); + tooltipShowing.value = false; + window.removeEventListener('mousemove', onDrag); + window.removeEventListener('touchmove', onDrag); + window.removeEventListener('mouseup', onMouseup); + window.removeEventListener('touchend', onMouseup); + + // 値が変わってたら通知 + if (beforeValue !== finalValue.value) { + emit('update:modelValue', finalValue.value); + } + }; + + window.addEventListener('mousemove', onDrag); + window.addEventListener('touchmove', onDrag); + window.addEventListener('mouseup', onMouseup, { once: true }); + window.addEventListener('touchend', onMouseup, { once: true }); +}; </script> <style lang="scss" scoped> @@ -191,7 +169,9 @@ export default defineComponent({ $thumbWidth: 20px; > .body { - padding: 12px; + padding: 10px 12px; + background: var(--panel); + border: solid 1px var(--panel); border-radius: 6px; > .container { @@ -209,7 +189,7 @@ export default defineComponent({ height: 3px; background: rgba(0, 0, 0, 0.1); border-radius: 999px; - overflow: clip; + overflow: hidden; overflow: clip; > .highlight { position: absolute; @@ -218,7 +198,7 @@ export default defineComponent({ height: 100%; background: var(--accent); opacity: 0.5; - transition: width 0.2s cubic-bezier(0,0,0,1); + //transition: width 0.2s cubic-bezier(0,0,0,1); } } @@ -251,7 +231,7 @@ export default defineComponent({ cursor: grab; background: var(--accent); border-radius: 999px; - transition: left 0.2s cubic-bezier(0,0,0,1); + //transition: left 0.2s cubic-bezier(0,0,0,1); &:hover { background: var(--accentLighten); diff --git a/packages/client/src/components/form/select.vue b/packages/client/src/components/form/select.vue index 87196027a8..fe8c08cd6c 100644 --- a/packages/client/src/components/form/select.vue +++ b/packages/client/src/components/form/select.vue @@ -3,7 +3,8 @@ <div class="label" @click="focus"><slot name="label"></slot></div> <div ref="container" class="input" :class="{ inline, disabled, focused }" @click.prevent="onClick"> <div ref="prefixEl" class="prefix"><slot name="prefix"></slot></div> - <select ref="inputEl" + <select + ref="inputEl" v-model="v" v-adaptive-border class="select" @@ -25,178 +26,139 @@ </div> </template> -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode } from 'vue'; +<script lang="ts" setup> +import { onMounted, onUnmounted, nextTick, ref, watch, computed, toRefs, VNode, useSlots } from 'vue'; import MkButton from '@/components/ui/button.vue'; import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; -export default defineComponent({ - components: { - MkButton, - }, +const props = defineProps<{ + modelValue: string; + required?: boolean; + readonly?: boolean; + disabled?: boolean; + placeholder?: string; + autofocus?: boolean; + inline?: boolean; + manualSave?: boolean; + small?: boolean; + large?: boolean; +}>(); - props: { - modelValue: { - required: true - }, - required: { - type: Boolean, - required: false - }, - readonly: { - type: Boolean, - required: false - }, - disabled: { - type: Boolean, - required: false - }, - placeholder: { - type: String, - required: false - }, - autofocus: { - type: Boolean, - required: false, - default: false - }, - inline: { - type: Boolean, - required: false, - default: false - }, - manualSave: { - type: Boolean, - required: false, - default: false - }, - }, +const emit = defineEmits<{ + (ev: 'change', _ev: KeyboardEvent): void; + (ev: 'update:modelValue', value: string): void; +}>(); - emits: ['change', 'update:modelValue'], +const slots = useSlots(); - setup(props, context) { - const { modelValue, autofocus } = toRefs(props); - const v = ref(modelValue.value); - const focused = ref(false); - const changed = ref(false); - const invalid = ref(false); - const filled = computed(() => v.value !== '' && v.value != null); - const inputEl = ref(null); - const prefixEl = ref(null); - const suffixEl = ref(null); - const container = ref(null); +const { modelValue, autofocus } = toRefs(props); +const v = ref(modelValue.value); +const focused = ref(false); +const changed = ref(false); +const invalid = ref(false); +const filled = computed(() => v.value !== '' && v.value != null); +const inputEl = ref(null); +const prefixEl = ref(null); +const suffixEl = ref(null); +const container = ref(null); +const height = + props.small ? 38 : + props.large ? 42 : + 40; - const focus = () => inputEl.value.focus(); - const onInput = (ev) => { - changed.value = true; - context.emit('change', ev); - }; +const focus = () => inputEl.value.focus(); +const onInput = (ev) => { + changed.value = true; + emit('change', ev); +}; - const updated = () => { - changed.value = false; - context.emit('update:modelValue', v.value); - }; +const updated = () => { + changed.value = false; + emit('update:modelValue', v.value); +}; - watch(modelValue, newValue => { - v.value = newValue; - }); +watch(modelValue, newValue => { + v.value = newValue; +}); - watch(v, newValue => { - if (!props.manualSave) { - updated(); - } +watch(v, newValue => { + if (!props.manualSave) { + updated(); + } - invalid.value = inputEl.value.validity.badInput; - }); + invalid.value = inputEl.value.validity.badInput; +}); - onMounted(() => { - nextTick(() => { - if (autofocus.value) { - focus(); - } +// このコンポーネントが作成された時、非表示状態である場合がある +// 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する +useInterval(() => { + if (prefixEl.value) { + if (prefixEl.value.offsetWidth) { + inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; + } + } + if (suffixEl.value) { + if (suffixEl.value.offsetWidth) { + inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; + } + } +}, 100, { + immediate: true, + afterMounted: true, +}); - // このコンポーネントが作成された時、非表示状態である場合がある - // 非表示状態だと要素の幅などは0になってしまうので、定期的に計算する - const clock = window.setInterval(() => { - if (prefixEl.value) { - if (prefixEl.value.offsetWidth) { - inputEl.value.style.paddingLeft = prefixEl.value.offsetWidth + 'px'; - } - } - if (suffixEl.value) { - if (suffixEl.value.offsetWidth) { - inputEl.value.style.paddingRight = suffixEl.value.offsetWidth + 'px'; - } - } - }, 100); +onMounted(() => { + nextTick(() => { + if (autofocus.value) { + focus(); + } + }); +}); - onUnmounted(() => { - window.clearInterval(clock); - }); - }); - }); +const onClick = (ev: MouseEvent) => { + focused.value = true; - const onClick = (ev: MouseEvent) => { - focused.value = true; + const menu = []; + let options = slots.default!(); - const menu = []; - let options = context.slots.default(); + const pushOption = (option: VNode) => { + menu.push({ + text: option.children, + active: v.value === option.props.value, + action: () => { + v.value = option.props.value; + }, + }); + }; - const pushOption = (option: VNode) => { + const scanOptions = (options: VNode[]) => { + for (const vnode of options) { + if (vnode.type === 'optgroup') { + const optgroup = vnode; menu.push({ - text: option.children, - active: v.value === option.props.value, - action: () => { - v.value = option.props.value; - }, + type: 'label', + text: optgroup.props.label, }); - }; - - const scanOptions = (options: VNode[]) => { - for (const vnode of options) { - if (vnode.type === 'optgroup') { - const optgroup = vnode; - menu.push({ - type: 'label', - text: optgroup.props.label, - }); - scanOptions(optgroup.children); - } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある - const fragment = vnode; - scanOptions(fragment.children); - } else { - const option = vnode; - pushOption(option); - } - } - }; - - scanOptions(options); + scanOptions(optgroup.children); + } else if (Array.isArray(vnode.children)) { // 何故かフラグメントになってくることがある + const fragment = vnode; + scanOptions(fragment.children); + } else { + const option = vnode; + pushOption(option); + } + } + }; - os.popupMenu(menu, container.value, { - width: container.value.offsetWidth, - }).then(() => { - focused.value = false; - }); - }; + scanOptions(options); - return { - v, - focused, - invalid, - changed, - filled, - inputEl, - prefixEl, - suffixEl, - container, - focus, - onInput, - onClick, - updated, - }; - }, -}); + os.popupMenu(menu, container.value, { + width: container.value.offsetWidth, + }).then(() => { + focused.value = false; + }); +}; </script> <style lang="scss" scoped> @@ -222,7 +184,6 @@ export default defineComponent({ } > .input { - $height: 42px; position: relative; cursor: pointer; @@ -236,7 +197,7 @@ export default defineComponent({ appearance: none; -webkit-appearance: none; display: block; - height: $height; + height: v-bind("height + 'px'"); width: 100%; margin: 0; padding: 0 12px; @@ -253,6 +214,7 @@ export default defineComponent({ cursor: pointer; transition: border-color 0.1s ease-out; pointer-events: none; + user-select: none; } > .prefix, @@ -264,7 +226,7 @@ export default defineComponent({ top: 0; padding: 0 12px; font-size: 1em; - height: $height; + height: v-bind("height + 'px'"); pointer-events: none; &:empty { diff --git a/packages/client/src/components/form/split.vue b/packages/client/src/components/form/split.vue index 676b293967..301a8a84e5 100644 --- a/packages/client/src/components/form/split.vue +++ b/packages/client/src/components/form/split.vue @@ -6,9 +6,9 @@ <script lang="ts" setup> const props = withDefaults(defineProps<{ - minWidth: number; + minWidth?: number; }>(), { - minWidth: 210, + minWidth: 210, }); const minWidth = props.minWidth + 'px'; diff --git a/packages/client/src/components/form/switch.vue b/packages/client/src/components/form/switch.vue index fadb770aee..fead163552 100644 --- a/packages/client/src/components/form/switch.vue +++ b/packages/client/src/components/form/switch.vue @@ -1,6 +1,6 @@ <template> <div - class="ziffeoms" + class="ziffeomt" :class="{ disabled, checked }" > <input @@ -9,8 +9,8 @@ :disabled="disabled" @keydown.enter="toggle" > - <span ref="button" v-adaptive-border v-tooltip="checked ? $ts.itsOn : $ts.itsOff" class="button" @click.prevent="toggle"> - <i class="check fas fa-check"></i> + <span ref="button" v-tooltip="checked ? $ts.itsOn : $ts.itsOff" class="button" @click.prevent="toggle"> + <div class="knob"></div> </span> <span class="label"> <!-- TODO: 無名slotの方は廃止 --> @@ -23,7 +23,6 @@ <script lang="ts" setup> import { toRefs, Ref } from 'vue'; import * as os from '@/os'; -import Ripple from '@/components/ripple.vue'; const props = defineProps<{ modelValue: boolean | Ref<boolean>; @@ -41,16 +40,13 @@ const toggle = () => { emit('update:modelValue', !checked.value); if (!checked.value) { - const rect = button.getBoundingClientRect(); - const x = rect.left + (button.offsetWidth / 2); - const y = rect.top + (button.offsetHeight / 2); - os.popup(Ripple, { x, y, particle: false }, {}, 'end'); + } }; </script> <style lang="scss" scoped> -.ziffeoms { +.ziffeomt { position: relative; display: flex; transition: all 0.2s ease; @@ -73,21 +69,25 @@ const toggle = () => { flex-shrink: 0; margin: 0; box-sizing: border-box; - width: 23px; + width: 32px; height: 23px; outline: none; - background: var(--panel); - border: solid 1px var(--panel); - border-radius: 4px; + background: var(--swutchOffBg); + background-clip: content-box; + border: solid 1px var(--swutchOffBg); + border-radius: 999px; cursor: pointer; transition: inherit; + user-select: none; - > .check { - margin: auto; - opacity: 0; - color: var(--fgOnAccent); - font-size: 13px; - transform: scale(0.5); + > .knob { + position: absolute; + top: 3px; + left: 3px; + width: 15px; + height: 15px; + background: var(--swutchOffFg); + border-radius: 999px; transition: all 0.2s ease; } } @@ -130,12 +130,12 @@ const toggle = () => { &.checked { > .button { - background-color: var(--accent) !important; - border-color: var(--accent) !important; + background-color: var(--swutchOnBg) !important; + border-color: var(--swutchOnBg) !important; - > .check { - opacity: 1; - transform: scale(1); + > .knob { + left: 12px; + background: var(--swutchOnFg); } } } diff --git a/packages/client/src/components/formula-core.vue b/packages/client/src/components/formula-core.vue index 49a61ab80e..8db8932fcd 100644 --- a/packages/client/src/components/formula-core.vue +++ b/packages/client/src/components/formula-core.vue @@ -1,4 +1,4 @@ - +<!-- eslint-disable vue/no-v-html --> <template> <div v-if="block" v-html="compiledFormula"></div> <span v-else v-html="compiledFormula"></span> diff --git a/packages/client/src/components/formula.vue b/packages/client/src/components/formula.vue index fbb40bace7..431b4e6c3e 100644 --- a/packages/client/src/components/formula.vue +++ b/packages/client/src/components/formula.vue @@ -3,7 +3,8 @@ </template> <script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue';import * as os from '@/os'; +import { defineComponent, defineAsyncComponent } from 'vue'; +import * as os from '@/os'; export default defineComponent({ components: { diff --git a/packages/client/src/components/global/a.vue b/packages/client/src/components/global/a.vue index 5287d59b3e..c7cf12e8c8 100644 --- a/packages/client/src/components/global/a.vue +++ b/packages/client/src/components/global/a.vue @@ -5,13 +5,13 @@ </template> <script lang="ts" setup> +import { inject } from 'vue'; import * as os from '@/os'; import copyToClipboard from '@/scripts/copy-to-clipboard'; -import { router } from '@/router'; import { url } from '@/config'; import { popout as popout_ } from '@/scripts/popout'; import { i18n } from '@/i18n'; -import { MisskeyNavigator } from '@/scripts/navigate'; +import { useRouter } from '@/router'; const props = withDefaults(defineProps<{ to: string; @@ -22,15 +22,16 @@ const props = withDefaults(defineProps<{ behavior: null, }); -const mkNav = new MisskeyNavigator(); +const router = useRouter(); const active = $computed(() => { if (props.activeClass == null) return false; const resolved = router.resolve(props.to); - if (resolved.path === router.currentRoute.value.path) return true; - if (resolved.name == null) return false; + if (resolved == null) return false; + if (resolved.route.path === router.currentRoute.value.path) return true; + if (resolved.route.name == null) return false; if (router.currentRoute.value.name == null) return false; - return resolved.name === router.currentRoute.value.name; + return resolved.route.name === router.currentRoute.value.name; }); function onContextmenu(ev) { @@ -44,31 +45,25 @@ function onContextmenu(ev) { text: i18n.ts.openInWindow, action: () => { os.pageWindow(props.to); - } - }, mkNav.sideViewHook ? { - icon: 'fas fa-columns', - text: i18n.ts.openInSideView, - action: () => { - if (mkNav.sideViewHook) mkNav.sideViewHook(props.to); - } - } : undefined, { + }, + }, { icon: 'fas fa-expand-alt', text: i18n.ts.showInPage, action: () => { router.push(props.to); - } + }, }, null, { icon: 'fas fa-external-link-alt', text: i18n.ts.openInNewTab, action: () => { window.open(props.to, '_blank'); - } + }, }, { icon: 'fas fa-link', text: i18n.ts.copyLink, action: () => { copyToClipboard(`${url}${props.to}`); - } + }, }], ev); } @@ -98,6 +93,6 @@ function nav() { } } - mkNav.push(props.to); + router.push(props.to); } </script> diff --git a/packages/client/src/components/global/emoji.vue b/packages/client/src/components/global/emoji.vue index 0075e0867d..23cb649f7a 100644 --- a/packages/client/src/components/global/emoji.vue +++ b/packages/client/src/components/global/emoji.vue @@ -1,4 +1,4 @@ -<template> +char2filePath<template> <img v-if="customEmoji" class="mk-emoji custom" :class="{ normal, noStyle }" :src="url" :alt="alt" :title="alt" decoding="async"/> <img v-else-if="char && !useOsNativeEmojis" class="mk-emoji" :src="url" :alt="alt" :title="alt" decoding="async"/> <span v-else-if="char && useOsNativeEmojis">{{ char }}</span> @@ -8,7 +8,7 @@ <script lang="ts"> import { computed, defineComponent, ref, watch } from 'vue'; import { getStaticImageUrl } from '@/scripts/get-static-image-url'; -import { twemojiSvgBase } from '@/scripts/twemoji-base'; +import { char2filePath } from '@/scripts/twemoji-base'; import { defaultStore } from '@/store'; import { instance } from '@/instance'; @@ -45,10 +45,7 @@ export default defineComponent({ const customEmoji = computed(() => isCustom.value ? ce.value.find(x => x.name === props.emoji.substr(1, props.emoji.length - 2)) : null); const url = computed(() => { if (char.value) { - let codes = Array.from(char.value).map(x => x.codePointAt(0).toString(16)); - if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f'); - codes = codes.filter(x => x && x.length); - return `${twemojiSvgBase}/${codes.join('-')}.svg`; + return char2filePath(char.value); } else { return defaultStore.state.disableShowingAnimatedImages ? getStaticImageUrl(customEmoji.value.url) diff --git a/packages/client/src/components/global/header.vue b/packages/client/src/components/global/header.vue deleted file mode 100644 index 63db19a520..0000000000 --- a/packages/client/src/components/global/header.vue +++ /dev/null @@ -1,361 +0,0 @@ -<template> -<div ref="el" class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick"> - <template v-if="info"> - <div v-if="!hideTitle" class="titleContainer" @click="showTabsPopup"> - <MkAvatar v-if="info.avatar" class="avatar" :user="info.avatar" :disable-preview="true" :show-indicator="true"/> - <i v-else-if="info.icon" class="icon" :class="info.icon"></i> - - <div class="title"> - <MkUserName v-if="info.userName" :user="info.userName" :nowrap="true" class="title"/> - <div v-else-if="info.title" class="title">{{ info.title }}</div> - <div v-if="!narrow && info.subtitle" class="subtitle"> - {{ info.subtitle }} - </div> - <div v-if="narrow && hasTabs" class="subtitle activeTab"> - {{ info.tabs.find(tab => tab.active)?.title }} - <i class="chevron fas fa-chevron-down"></i> - </div> - </div> - </div> - <div v-if="!narrow || hideTitle" class="tabs"> - <button v-for="tab in info.tabs" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.active }" @click="tab.onClick"> - <i v-if="tab.icon" class="icon" :class="tab.icon"></i> - <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> - </button> - </div> - </template> - <div class="buttons right"> - <template v-if="info && info.actions && !narrow"> - <template v-for="action in info.actions"> - <MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton> - <button v-else v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> - </template> - </template> - <button v-if="shouldShowMenu" v-tooltip="$ts.menu" class="_button button" @click.stop="showMenu" @touchstart="preventDrag"><i class="fas fa-ellipsis-h"></i></button> - </div> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent, onMounted, onUnmounted, PropType, ref, inject } from 'vue'; -import tinycolor from 'tinycolor2'; -import { popupMenu } from '@/os'; -import { url } from '@/config'; -import { scrollToTop } from '@/scripts/scroll'; -import MkButton from '@/components/ui/button.vue'; -import { i18n } from '@/i18n'; -import { globalEvents } from '@/events'; - -export default defineComponent({ - components: { - MkButton - }, - - props: { - info: { - type: Object as PropType<{ - actions?: {}[]; - tabs?: {}[]; - }>, - required: true - }, - menu: { - required: false - }, - thin: { - required: false, - default: false - }, - }, - - setup(props) { - const el = ref<HTMLElement>(null); - const bg = ref(null); - const narrow = ref(false); - const height = ref(0); - const hasTabs = computed(() => { - return props.info.tabs && props.info.tabs.length > 0; - }); - const shouldShowMenu = computed(() => { - if (props.info == null) return false; - if (props.info.actions != null && narrow.value) return true; - if (props.info.menu != null) return true; - if (props.info.share != null) return true; - if (props.menu != null) return true; - return false; - }); - - const share = () => { - navigator.share({ - url: url + props.info.path, - ...props.info.share, - }); - }; - - const showMenu = (ev: MouseEvent) => { - let menu = props.info.menu ? props.info.menu() : []; - if (narrow.value && props.info.actions) { - menu = [...props.info.actions.map(x => ({ - text: x.text, - icon: x.icon, - action: x.handler - })), menu.length > 0 ? null : undefined, ...menu]; - } - if (props.info.share) { - if (menu.length > 0) menu.push(null); - menu.push({ - text: i18n.ts.share, - icon: 'fas fa-share-alt', - action: share - }); - } - if (props.menu) { - if (menu.length > 0) menu.push(null); - menu = menu.concat(props.menu); - } - popupMenu(menu, ev.currentTarget ?? ev.target); - }; - - const showTabsPopup = (ev: MouseEvent) => { - if (!hasTabs.value) return; - if (!narrow.value) return; - ev.preventDefault(); - ev.stopPropagation(); - const menu = props.info.tabs.map(tab => ({ - text: tab.title, - icon: tab.icon, - action: tab.onClick, - })); - popupMenu(menu, ev.currentTarget ?? ev.target); - }; - - const preventDrag = (ev: TouchEvent) => { - ev.stopPropagation(); - }; - - const onClick = () => { - scrollToTop(el.value, { behavior: 'smooth' }); - }; - - const calcBg = () => { - const rawBg = props.info?.bg || 'var(--bg)'; - const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); - tinyBg.setAlpha(0.85); - bg.value = tinyBg.toRgbString(); - }; - - onMounted(() => { - calcBg(); - globalEvents.on('themeChanged', calcBg); - onUnmounted(() => { - globalEvents.off('themeChanged', calcBg); - }); - - if (el.value.parentElement) { - narrow.value = el.value.parentElement.offsetWidth < 500; - const ro = new ResizeObserver((entries, observer) => { - if (el.value) { - narrow.value = el.value.parentElement.offsetWidth < 500; - } - }); - ro.observe(el.value.parentElement); - onUnmounted(() => { - ro.disconnect(); - }); - } - }); - - return { - el, - bg, - narrow, - height, - hasTabs, - shouldShowMenu, - share, - showMenu, - showTabsPopup, - preventDrag, - onClick, - hideTitle: inject('shouldOmitHeaderTitle', false), - thin_: props.thin || inject('shouldHeaderThin', false) - }; - }, -}); -</script> - -<style lang="scss" scoped> -.fdidabkb { - --height: 60px; - display: flex; - position: sticky; - top: var(--stickyTop, 0); - z-index: 1000; - width: 100%; - -webkit-backdrop-filter: var(--blur, blur(15px)); - backdrop-filter: var(--blur, blur(15px)); - border-bottom: solid 0.5px var(--divider); - - &.thin { - --height: 50px; - - > .buttons { - > .button { - font-size: 0.9em; - } - } - } - - &.slim { - text-align: center; - - > .titleContainer { - flex: 1; - margin: 0 auto; - margin-left: var(--height); - - > *:first-child { - margin-left: auto; - } - - > *:last-child { - margin-right: auto; - } - } - } - - > .buttons { - --margin: 8px; - display: flex; - align-items: center; - height: var(--height); - margin: 0 var(--margin); - - &.right { - margin-left: auto; - } - - &:empty { - width: var(--height); - } - - > .button { - display: flex; - align-items: center; - justify-content: center; - height: calc(var(--height) - (var(--margin) * 2)); - width: calc(var(--height) - (var(--margin) * 2)); - box-sizing: border-box; - position: relative; - border-radius: 5px; - - &:hover { - background: rgba(0, 0, 0, 0.05); - } - - &.highlighted { - color: var(--accent); - } - } - - > .fullButton { - & + .fullButton { - margin-left: 12px; - } - } - } - - > .titleContainer { - display: flex; - align-items: center; - max-width: 400px; - overflow: auto; - white-space: nowrap; - text-align: left; - font-weight: bold; - flex-shrink: 0; - margin-left: 24px; - - > .avatar { - $size: 32px; - display: inline-block; - width: $size; - height: $size; - vertical-align: bottom; - margin: 0 8px; - pointer-events: none; - } - - > .icon { - margin-right: 8px; - } - - > .title { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - line-height: 1.1; - - > .subtitle { - opacity: 0.6; - font-size: 0.8em; - font-weight: normal; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - &.activeTab { - text-align: center; - - > .chevron { - display: inline-block; - margin-left: 6px; - } - } - } - } - } - - > .tabs { - margin-left: 16px; - font-size: 0.8em; - overflow: auto; - white-space: nowrap; - - > .tab { - display: inline-block; - position: relative; - padding: 0 10px; - height: 100%; - font-weight: normal; - opacity: 0.7; - - &:hover { - opacity: 1; - } - - &.active { - opacity: 1; - - &:after { - content: ""; - display: block; - position: absolute; - bottom: 0; - left: 0; - right: 0; - margin: 0 auto; - width: 100%; - height: 3px; - background: var(--accent); - } - } - - > .icon + .title { - margin-left: 8px; - } - } - } -} -</style> diff --git a/packages/client/src/components/global/page-header.vue b/packages/client/src/components/global/page-header.vue new file mode 100644 index 0000000000..766f9b6b6a --- /dev/null +++ b/packages/client/src/components/global/page-header.vue @@ -0,0 +1,346 @@ +<template> +<div v-if="show" ref="el" class="fdidabkb" :class="{ slim: narrow, thin: thin_ }" :style="{ background: bg }" @click="onClick"> + <template v-if="metadata"> + <div v-if="!hideTitle" class="titleContainer" @click="showTabsPopup"> + <MkAvatar v-if="metadata.avatar" class="avatar" :user="metadata.avatar" :disable-preview="true" :show-indicator="true"/> + <i v-else-if="metadata.icon" class="icon" :class="metadata.icon"></i> + + <div class="title"> + <MkUserName v-if="metadata.userName" :user="metadata.userName" :nowrap="true" class="title"/> + <div v-else-if="metadata.title" class="title">{{ metadata.title }}</div> + <div v-if="!narrow && metadata.subtitle" class="subtitle"> + {{ metadata.subtitle }} + </div> + <div v-if="narrow && hasTabs" class="subtitle activeTab"> + {{ tabs.find(tab => tab.key === props.tab)?.title }} + <i class="chevron fas fa-chevron-down"></i> + </div> + </div> + </div> + <div v-if="!narrow || hideTitle" class="tabs"> + <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)"> + <i v-if="tab.icon" class="icon" :class="tab.icon"></i> + <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> + </button> + <div ref="tabHighlightEl" class="highlight"></div> + </div> + </template> + <div class="buttons right"> + <template v-for="action in actions"> + <button v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> + </template> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, onUnmounted, ref, inject, watch, shallowReactive, nextTick, reactive } from 'vue'; +import tinycolor from 'tinycolor2'; +import { popupMenu } from '@/os'; +import { scrollToTop } from '@/scripts/scroll'; +import { i18n } from '@/i18n'; +import { globalEvents } from '@/events'; +import { injectPageMetadata } from '@/scripts/page-metadata'; + +type Tab = { + key?: string | null; + title: string; + icon?: string; + iconOnly?: boolean; + onClick?: (ev: MouseEvent) => void; +}; + +const props = defineProps<{ + tabs?: Tab[]; + tab?: string; + actions?: { + text: string; + icon: string; + handler: (ev: MouseEvent) => void; + }[]; + thin?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'update:tab', key: string); +}>(); + +const metadata = injectPageMetadata(); + +const hideTitle = inject('shouldOmitHeaderTitle', false); +const thin_ = props.thin || inject('shouldHeaderThin', false); + +const el = $ref<HTMLElement | null>(null); +const tabRefs = {}; +const tabHighlightEl = $ref<HTMLElement | null>(null); +const bg = ref(null); +let narrow = $ref(false); +const height = ref(0); +const hasTabs = $computed(() => props.tabs && props.tabs.length > 0); +const hasActions = $computed(() => props.actions && props.actions.length > 0); +const show = $computed(() => { + return !hideTitle || hasTabs || hasActions; +}); + +const showTabsPopup = (ev: MouseEvent) => { + if (!hasTabs) return; + if (!narrow) return; + ev.preventDefault(); + ev.stopPropagation(); + const menu = props.tabs.map(tab => ({ + text: tab.title, + icon: tab.icon, + active: tab.key != null && tab.key === props.tab, + action: (ev) => { + onTabClick(tab, ev); + }, + })); + popupMenu(menu, ev.currentTarget ?? ev.target); +}; + +const preventDrag = (ev: TouchEvent) => { + ev.stopPropagation(); +}; + +const onClick = () => { + scrollToTop(el, { behavior: 'smooth' }); +}; + +function onTabMousedown(tab: Tab, ev: MouseEvent): void { + // ユーザビリティの観点からmousedown時にはonClickは呼ばない + if (tab.key) { + emit('update:tab', tab.key); + } +} + +function onTabClick(tab: Tab, ev: MouseEvent): void { + if (tab.onClick) { + ev.preventDefault(); + ev.stopPropagation(); + tab.onClick(ev); + } + if (tab.key) { + emit('update:tab', tab.key); + } +} + +const calcBg = () => { + const rawBg = metadata?.bg || 'var(--bg)'; + const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + tinyBg.setAlpha(0.85); + bg.value = tinyBg.toRgbString(); +}; + +let ro: ResizeObserver | null; + +onMounted(() => { + calcBg(); + globalEvents.on('themeChanged', calcBg); + + watch(() => [props.tab, props.tabs], () => { + nextTick(() => { + const tabEl = tabRefs[props.tab]; + if (tabEl && tabHighlightEl) { + // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある + // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 + const parentRect = tabEl.parentElement.getBoundingClientRect(); + const rect = tabEl.getBoundingClientRect(); + tabHighlightEl.style.width = rect.width + 'px'; + tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px'; + } + }); + }, { + immediate: true, + }); + + if (el && el.parentElement) { + narrow = el.parentElement.offsetWidth < 500; + ro = new ResizeObserver((entries, observer) => { + if (el.parentElement && document.body.contains(el)) { + narrow = el.parentElement.offsetWidth < 500; + } + }); + ro.observe(el.parentElement); + } +}); + +onUnmounted(() => { + globalEvents.off('themeChanged', calcBg); + if (ro) ro.disconnect(); +}); +</script> + +<style lang="scss" scoped> +.fdidabkb { + --height: 55px; + display: flex; + width: 100%; + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + border-bottom: solid 0.5px var(--divider); + contain: strict; + height: var(--height); + + &.thin { + --height: 45px; + + > .buttons { + > .button { + font-size: 0.9em; + } + } + } + + &.slim { + text-align: center; + + > .titleContainer { + flex: 1; + margin: 0 auto; + margin-left: var(--height); + + > *:first-child { + margin-left: auto; + } + + > *:last-child { + margin-right: auto; + } + } + } + + > .buttons { + --margin: 8px; + display: flex; + align-items: center; + height: var(--height); + margin: 0 var(--margin); + + &.right { + margin-left: auto; + } + + &:empty { + width: var(--height); + } + + > .button { + display: flex; + align-items: center; + justify-content: center; + height: calc(var(--height) - (var(--margin) * 2)); + width: calc(var(--height) - (var(--margin) * 2)); + box-sizing: border-box; + position: relative; + border-radius: 5px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.highlighted { + color: var(--accent); + } + } + + > .fullButton { + & + .fullButton { + margin-left: 12px; + } + } + } + + > .titleContainer { + display: flex; + align-items: center; + max-width: 400px; + overflow: auto; + white-space: nowrap; + text-align: left; + font-weight: bold; + flex-shrink: 0; + margin-left: 24px; + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: bottom; + margin: 0 8px; + pointer-events: none; + } + + > .icon { + margin-right: 8px; + width: 16px; + text-align: center; + } + + > .title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.1; + + > .subtitle { + opacity: 0.6; + font-size: 0.8em; + font-weight: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.activeTab { + text-align: center; + + > .chevron { + display: inline-block; + margin-left: 6px; + } + } + } + } + } + + > .tabs { + position: relative; + margin-left: 16px; + font-size: 0.8em; + overflow: auto; + white-space: nowrap; + + > .tab { + display: inline-block; + position: relative; + padding: 0 10px; + height: 100%; + font-weight: normal; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + } + + > .icon + .title { + margin-left: 8px; + } + } + + > .highlight { + position: absolute; + bottom: 0; + height: 3px; + background: var(--accent); + border-radius: 999px; + transition: all 0.2s ease; + pointer-events: none; + } + } +} +</style> diff --git a/packages/client/src/components/global/router-view.vue b/packages/client/src/components/global/router-view.vue new file mode 100644 index 0000000000..fca2371f0d --- /dev/null +++ b/packages/client/src/components/global/router-view.vue @@ -0,0 +1,37 @@ +<template> +<KeepAlive :max="defaultStore.state.numberOfPageCache"> + <component :is="currentPageComponent" :key="key" v-bind="Object.fromEntries(currentPageProps)"/> +</KeepAlive> +</template> + +<script lang="ts" setup> +import { inject, nextTick, onMounted, onUnmounted, watch } from 'vue'; +import { Router } from '@/nirax'; +import { defaultStore } from '@/store'; + +const props = defineProps<{ + router?: Router; +}>(); + +const router = props.router ?? inject('router'); + +if (router == null) { + throw new Error('no router provided'); +} + +let currentPageComponent = $shallowRef(router.getCurrentComponent()); +let currentPageProps = $ref(router.getCurrentProps()); +let key = $ref(router.getCurrentKey()); + +function onChange({ route, props: newProps, key: newKey }) { + currentPageComponent = route.component; + currentPageProps = newProps; + key = newKey; +} + +router.addListener('change', onChange); + +onUnmounted(() => { + router.removeListener('change', onChange); +}); +</script> diff --git a/packages/client/src/components/global/spacer.vue b/packages/client/src/components/global/spacer.vue index f2eda1907b..53adf07771 100644 --- a/packages/client/src/components/global/spacer.vue +++ b/packages/client/src/components/global/spacer.vue @@ -6,78 +6,61 @@ </div> </template> -<script lang="ts"> +<script lang="ts" setup> +import { inject, onMounted, onUnmounted, ref } from 'vue'; import { deviceKind } from '@/scripts/device-kind'; -import { defineComponent, inject, onMounted, onUnmounted, ref } from 'vue'; -export default defineComponent({ - props: { - contentMax: { - type: Number, - required: false, - default: null, - }, - marginMin: { - type: Number, - required: false, - default: 12, - }, - marginMax: { - type: Number, - required: false, - default: 24, - }, - }, +const props = withDefaults(defineProps<{ + contentMax?: number | null; + marginMin?: number; + marginMax?: number; +}>(), { + contentMax: null, + marginMin: 12, + marginMax: 24, +}); - setup(props, context) { - let ro: ResizeObserver; - const root = ref<HTMLElement>(); - const content = ref<HTMLElement>(); - const margin = ref(0); - const shouldSpacerMin = inject('shouldSpacerMin', false); - const adjust = (rect: { width: number; height: number; }) => { - if (shouldSpacerMin || deviceKind === 'smartphone') { - margin.value = props.marginMin; - return; - } +let ro: ResizeObserver; +let root = $ref<HTMLElement>(); +let content = $ref<HTMLElement>(); +let margin = $ref(0); +const shouldSpacerMin = inject('shouldSpacerMin', false); - if (rect.width > props.contentMax || (rect.width > 360 && window.innerWidth > 400)) { - margin.value = props.marginMax; - } else { - margin.value = props.marginMin; - } - }; +const adjust = (rect: { width: number; height: number; }) => { + if (shouldSpacerMin || deviceKind === 'smartphone') { + margin = props.marginMin; + return; + } - onMounted(() => { - ro = new ResizeObserver((entries) => { - /* iOSが対応していない - adjust({ - width: entries[0].borderBoxSize[0].inlineSize, - height: entries[0].borderBoxSize[0].blockSize, - }); - */ - adjust({ - width: root.value!.offsetWidth, - height: root.value!.offsetHeight, - }); - }); - ro.observe(root.value!); + if (rect.width > (props.contentMax ?? 0) || (rect.width > 360 && window.innerWidth > 400)) { + margin = props.marginMax; + } else { + margin = props.marginMin; + } +}; - if (props.contentMax) { - content.value!.style.maxWidth = `${props.contentMax}px`; - } +onMounted(() => { + ro = new ResizeObserver((entries) => { + /* iOSが対応していない + adjust({ + width: entries[0].borderBoxSize[0].inlineSize, + height: entries[0].borderBoxSize[0].blockSize, }); - - onUnmounted(() => { - ro.disconnect(); + */ + adjust({ + width: root!.offsetWidth, + height: root!.offsetHeight, }); + }); + ro.observe(root!); + + if (props.contentMax) { + content!.style.maxWidth = `${props.contentMax}px`; + } +}); - return { - root, - content, - margin, - }; - }, +onUnmounted(() => { + ro.disconnect(); }); </script> diff --git a/packages/client/src/components/global/sticky-container.vue b/packages/client/src/components/global/sticky-container.vue index 89d397f082..44f4f065a6 100644 --- a/packages/client/src/components/global/sticky-container.vue +++ b/packages/client/src/components/global/sticky-container.vue @@ -1,71 +1,63 @@ <template> <div ref="rootEl"> - <slot name="header"></slot> - <div ref="bodyEl"> + <div ref="headerEl"> + <slot name="header"></slot> + </div> + <div ref="bodyEl" :data-sticky-container-header-height="headerHeight"> <slot></slot> </div> </div> </template> <script lang="ts"> -import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; +// なんか動かない +//const CURRENT_STICKY_TOP = Symbol('CURRENT_STICKY_TOP'); +const CURRENT_STICKY_TOP = 'CURRENT_STICKY_TOP'; +</script> -export default defineComponent({ - props: { - autoSticky: { - type: Boolean, - required: false, - default: false, - }, - }, +<script lang="ts" setup> +import { onMounted, onUnmounted, provide, inject, Ref, ref, watch } from 'vue'; - setup(props, context) { - const rootEl = ref<HTMLElement>(null); - const bodyEl = ref<HTMLElement>(null); +const rootEl = $ref<HTMLElement>(); +const headerEl = $ref<HTMLElement>(); +const bodyEl = $ref<HTMLElement>(); - const calc = () => { - const currentStickyTop = getComputedStyle(rootEl.value).getPropertyValue('--stickyTop') || '0px'; +let headerHeight = $ref<string | undefined>(); +let childStickyTop = $ref(0); +const parentStickyTop = inject<Ref<number>>(CURRENT_STICKY_TOP, ref(0)); +provide(CURRENT_STICKY_TOP, $$(childStickyTop)); - const header = rootEl.value.children[0]; - if (header === bodyEl.value) { - bodyEl.value.style.setProperty('--stickyTop', currentStickyTop); - } else { - bodyEl.value.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); +const calc = () => { + childStickyTop = parentStickyTop.value + headerEl.offsetHeight; + headerHeight = headerEl.offsetHeight.toString(); +}; - if (props.autoSticky) { - header.style.setProperty('--stickyTop', currentStickyTop); - header.style.position = 'sticky'; - header.style.top = 'var(--stickyTop)'; - header.style.zIndex = '1'; - } - } - }; +const observer = new ResizeObserver(() => { + window.setTimeout(() => { + calc(); + }, 100); +}); - onMounted(() => { - calc(); +onMounted(() => { + calc(); - const observer = new MutationObserver(() => { - window.setTimeout(() => { - calc(); - }, 100); - }); + watch(parentStickyTop, calc); - observer.observe(rootEl.value, { - attributes: false, - childList: true, - subtree: false, - }); + watch($$(childStickyTop), () => { + bodyEl.style.setProperty('--stickyTop', `${childStickyTop}px`); + }, { + immediate: true, + }); - onUnmounted(() => { - observer.disconnect(); - }); - }); + headerEl.style.position = 'sticky'; + headerEl.style.top = 'var(--stickyTop, 0)'; + headerEl.style.zIndex = '1000'; + + observer.observe(headerEl); +}); - return { - rootEl, - bodyEl, - }; - }, +onUnmounted(() => { + observer.disconnect(); }); </script> diff --git a/packages/client/src/components/global/time.vue b/packages/client/src/components/global/time.vue index a7f142f961..801490225b 100644 --- a/packages/client/src/components/global/time.vue +++ b/packages/client/src/components/global/time.vue @@ -24,14 +24,14 @@ let now = $ref(new Date()); const relative = $computed(() => { const ago = (now.getTime() - _time.getTime()) / 1000/*ms*/; return ( - ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) : - ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) : - ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago / 604800).toString() }) : - ago >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago / 86400).toString() }) : - ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago / 3600).toString() }) : - ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) : - ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) : - ago >= -1 ? i18n.ts._ago.justNow : + ago >= 31536000 ? i18n.t('_ago.yearsAgo', { n: Math.round(ago / 31536000).toString() }) : + ago >= 2592000 ? i18n.t('_ago.monthsAgo', { n: Math.round(ago / 2592000).toString() }) : + ago >= 604800 ? i18n.t('_ago.weeksAgo', { n: Math.round(ago / 604800).toString() }) : + ago >= 86400 ? i18n.t('_ago.daysAgo', { n: Math.round(ago / 86400).toString() }) : + ago >= 3600 ? i18n.t('_ago.hoursAgo', { n: Math.round(ago / 3600).toString() }) : + ago >= 60 ? i18n.t('_ago.minutesAgo', { n: (~~(ago / 60)).toString() }) : + ago >= 10 ? i18n.t('_ago.secondsAgo', { n: (~~(ago % 60)).toString() }) : + ago >= -1 ? i18n.ts._ago.justNow : i18n.ts._ago.future); }); @@ -50,7 +50,7 @@ if (props.mode === 'relative' || props.mode === 'detail') { tickId = window.requestAnimationFrame(tick); onUnmounted(() => { - window.clearTimeout(tickId); + window.cancelAnimationFrame(tickId); }); } </script> diff --git a/packages/client/src/components/img-with-blurhash.vue b/packages/client/src/components/img-with-blurhash.vue index 06ad764403..80d7c201a4 100644 --- a/packages/client/src/components/img-with-blurhash.vue +++ b/packages/client/src/components/img-with-blurhash.vue @@ -11,7 +11,7 @@ import { decode } from 'blurhash'; const props = withDefaults(defineProps<{ src?: string | null; - hash: string; + hash?: string; alt?: string; title?: string | null; size?: number; diff --git a/packages/client/src/components/index.ts b/packages/client/src/components/index.ts index 26bac63245..aa8a591e51 100644 --- a/packages/client/src/components/index.ts +++ b/packages/client/src/components/index.ts @@ -10,15 +10,17 @@ import MkEllipsis from './global/ellipsis.vue'; import MkTime from './global/time.vue'; import MkUrl from './global/url.vue'; import I18n from './global/i18n'; +import RouterView from './global/router-view.vue'; import MkLoading from './global/loading.vue'; import MkError from './global/error.vue'; import MkAd from './global/ad.vue'; -import MkHeader from './global/header.vue'; +import MkPageHeader from './global/page-header.vue'; import MkSpacer from './global/spacer.vue'; import MkStickyContainer from './global/sticky-container.vue'; export default function(app: App) { app.component('I18n', I18n); + app.component('RouterView', RouterView); app.component('Mfm', Mfm); app.component('MkA', MkA); app.component('MkAcct', MkAcct); @@ -31,7 +33,7 @@ export default function(app: App) { app.component('MkLoading', MkLoading); app.component('MkError', MkError); app.component('MkAd', MkAd); - app.component('MkHeader', MkHeader); + app.component('MkPageHeader', MkPageHeader); app.component('MkSpacer', MkSpacer); app.component('MkStickyContainer', MkStickyContainer); } @@ -39,6 +41,7 @@ export default function(app: App) { declare module '@vue/runtime-core' { export interface GlobalComponents { I18n: typeof I18n; + RouterView: typeof RouterView; Mfm: typeof Mfm; MkA: typeof MkA; MkAcct: typeof MkAcct; @@ -51,7 +54,7 @@ declare module '@vue/runtime-core' { MkLoading: typeof MkLoading; MkError: typeof MkError; MkAd: typeof MkAd; - MkHeader: typeof MkHeader; + MkPageHeader: typeof MkPageHeader; MkSpacer: typeof MkSpacer; MkStickyContainer: typeof MkStickyContainer; } diff --git a/packages/client/src/components/instance-card-mini.vue b/packages/client/src/components/instance-card-mini.vue new file mode 100644 index 0000000000..88621e72c2 --- /dev/null +++ b/packages/client/src/components/instance-card-mini.vue @@ -0,0 +1,100 @@ +<template> +<div :class="[$style.root, { yellow: instance.isNotResponding, red: instance.isBlocked, gray: instance.isSuspended }]"> + <img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/> + <div class="body"> + <span class="host">{{ instance.name ?? instance.host }}</span> + <span class="sub _monospace"><b>{{ instance.host }}</b> / {{ instance.softwareName || '?' }} {{ instance.softwareVersion }}</span> + </div> + <MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/> +</div> +</template> + +<script lang="ts" setup> +import * as misskey from 'misskey-js'; +import MkMiniChart from '@/components/mini-chart.vue'; +import * as os from '@/os'; + +const props = defineProps<{ + instance: misskey.entities.Instance; +}>(); + +let chartValues = $ref<number[] | null>(null); + +os.apiGet('charts/instance', { host: props.instance.host, limit: 16 + 1, span: 'day' }).then(res => { + // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く + res.requests.received.splice(0, 1); + chartValues = res.requests.received; +}); +</script> + +<style lang="scss" module> +.root { + $bodyTitleHieght: 18px; + $bodyInfoHieght: 16px; + + display: flex; + align-items: center; + padding: 16px; + background: var(--panel); + border-radius: 8px; + + > :global(.icon) { + display: block; + width: ($bodyTitleHieght + $bodyInfoHieght); + height: ($bodyTitleHieght + $bodyInfoHieght); + object-fit: cover; + border-radius: 4px; + margin-right: 10px; + } + + > :global(.body) { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; + + > :global(.host) { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: $bodyTitleHieght; + } + + > :global(.sub) { + display: block; + width: 100%; + font-size: 80%; + opacity: 0.7; + line-height: $bodyInfoHieght; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + > :global(.chart) { + height: 30px; + } + + &:global(.yellow) { + --c: rgb(255 196 0 / 15%); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } + + &:global(.red) { + --c: rgb(255 0 0 / 15%); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } + + &:global(.gray) { + --c: var(--bg); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } +} +</style> diff --git a/packages/client/src/components/instance-stats.vue b/packages/client/src/components/instance-stats.vue index f386a8de9a..1a811c2d87 100644 --- a/packages/client/src/components/instance-stats.vue +++ b/packages/client/src/components/instance-stats.vue @@ -1,81 +1,219 @@ <template> <div class="zbcjwnqg"> - <div class="selects" style="display: flex;"> - <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> - <optgroup :label="$ts.federation"> - <option value="federation">{{ $ts._charts.federation }}</option> - <option value="ap-request">{{ $ts._charts.apRequest }}</option> - </optgroup> - <optgroup :label="$ts.users"> - <option value="users">{{ $ts._charts.usersIncDec }}</option> - <option value="users-total">{{ $ts._charts.usersTotal }}</option> - <option value="active-users">{{ $ts._charts.activeUsers }}</option> - </optgroup> - <optgroup :label="$ts.notes"> - <option value="notes">{{ $ts._charts.notesIncDec }}</option> - <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option> - <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option> - <option value="notes-total">{{ $ts._charts.notesTotal }}</option> - </optgroup> - <optgroup :label="$ts.drive"> - <option value="drive-files">{{ $ts._charts.filesIncDec }}</option> - <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option> - </optgroup> - </MkSelect> - <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> - <option value="hour">{{ $ts.perHour }}</option> - <option value="day">{{ $ts.perDay }}</option> - </MkSelect> + <div class="main"> + <div class="body"> + <div class="selects" style="display: flex;"> + <MkSelect v-model="chartSrc" style="margin: 0; flex: 1;"> + <optgroup :label="$ts.federation"> + <option value="federation">{{ $ts._charts.federation }}</option> + <option value="ap-request">{{ $ts._charts.apRequest }}</option> + </optgroup> + <optgroup :label="$ts.users"> + <option value="users">{{ $ts._charts.usersIncDec }}</option> + <option value="users-total">{{ $ts._charts.usersTotal }}</option> + <option value="active-users">{{ $ts._charts.activeUsers }}</option> + </optgroup> + <optgroup :label="$ts.notes"> + <option value="notes">{{ $ts._charts.notesIncDec }}</option> + <option value="local-notes">{{ $ts._charts.localNotesIncDec }}</option> + <option value="remote-notes">{{ $ts._charts.remoteNotesIncDec }}</option> + <option value="notes-total">{{ $ts._charts.notesTotal }}</option> + </optgroup> + <optgroup :label="$ts.drive"> + <option value="drive-files">{{ $ts._charts.filesIncDec }}</option> + <option value="drive">{{ $ts._charts.storageUsageIncDec }}</option> + </optgroup> + </MkSelect> + <MkSelect v-model="chartSpan" style="margin: 0 0 0 10px;"> + <option value="hour">{{ $ts.perHour }}</option> + <option value="day">{{ $ts.perDay }}</option> + </MkSelect> + </div> + <div class="chart"> + <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart> + </div> + </div> </div> - <div class="chart"> - <MkChart :src="chartSrc" :span="chartSpan" :limit="chartLimit" :detailed="detailed"></MkChart> + <div class="subpub"> + <div class="sub"> + <div class="title">Sub</div> + <canvas ref="subDoughnutEl"></canvas> + </div> + <div class="pub"> + <div class="title">Pub</div> + <canvas ref="pubDoughnutEl"></canvas> + </div> </div> </div> </template> -<script lang="ts"> -import { defineComponent, ref } from 'vue'; +<script lang="ts" setup> +import { onMounted } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + DoughnutController, +} from 'chart.js'; import MkSelect from '@/components/form/select.vue'; import MkChart from '@/components/chart.vue'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import * as os from '@/os'; -export default defineComponent({ - components: { - MkSelect, - MkChart, - }, +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + DoughnutController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); - props: { - chartLimit: { - type: Number, - required: false, - default: 90 +const props = withDefaults(defineProps<{ + chartLimit?: number; + detailed?: boolean; +}>(), { + chartLimit: 90, +}); + +const chartSpan = $ref<'hour' | 'day'>('hour'); +const chartSrc = $ref('active-users'); +let subDoughnutEl = $ref<HTMLCanvasElement>(); +let pubDoughnutEl = $ref<HTMLCanvasElement>(); + +const { handler: externalTooltipHandler1 } = useChartTooltip(); +const { handler: externalTooltipHandler2 } = useChartTooltip(); + +function createDoughnut(chartEl, tooltip, data) { + const chartInstance = new Chart(chartEl, { + type: 'doughnut', + data: { + labels: data.map(x => x.name), + datasets: [{ + backgroundColor: data.map(x => x.color), + borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'), + borderWidth: 2, + hoverOffset: 0, + data: data.map(x => x.value), + }], }, - detailed: { - type: Boolean, - required: false, - default: false + options: { + maintainAspectRatio: false, + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 16, + }, + }, + onClick: (ev) => { + const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0]; + if (hit && data[hit.index].onClick) { + data[hit.index].onClick(); + } + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: tooltip, + }, + }, }, - }, + }); + + return chartInstance; +} - setup() { - const chartSpan = ref<'hour' | 'day'>('hour'); - const chartSrc = ref('active-users'); +onMounted(() => { + os.apiGet('federation/stats', { limit: 30 }).then(fedStats => { + createDoughnut(subDoughnutEl, externalTooltipHandler1, fedStats.topSubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followersCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowersCount }])); - return { - chartSrc, - chartSpan, - }; - }, + createDoughnut(pubDoughnutEl, externalTooltipHandler2, fedStats.topPubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followingCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: fedStats.otherFollowingCount }])); + }); }); </script> <style lang="scss" scoped> .zbcjwnqg { - > .selects { + > .main { + background: var(--panel); + border-radius: var(--radius); + padding: 24px; + margin-bottom: 16px; + + > .body { + > .chart { + padding: 8px 0 0 0; + } + } } - > .chart { - padding: 8px 0 0 0; + > .subpub { + display: flex; + gap: 16px; + + > .sub, > .pub { + flex: 1; + min-width: 0; + position: relative; + background: var(--panel); + border-radius: var(--radius); + padding: 24px; + max-height: 300px; + + > .title { + position: absolute; + top: 24px; + left: 24px; + } + } + + @media (max-width: 600px) { + flex-direction: column; + } } } </style> diff --git a/packages/client/src/components/key-value.vue b/packages/client/src/components/key-value.vue index da98abd77c..3d665e159d 100644 --- a/packages/client/src/components/key-value.vue +++ b/packages/client/src/components/key-value.vue @@ -10,46 +10,27 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import * as os from '@/os'; -export default defineComponent({ - props: { - copy: { - type: String, - required: false, - default: null, - }, - oneline: { - type: Boolean, - required: false, - default: false, - }, - }, - - setup(props) { - const copy_ = () => { - copyToClipboard(props.copy); - os.success(); - }; - - return { - copy_ - }; - }, +const props = withDefaults(defineProps<{ + copy?: string | null; + oneline?: boolean; +}>(), { + copy: null, + oneline: false, }); + +const copy_ = () => { + copyToClipboard(props.copy); + os.success(); +}; </script> <style lang="scss" scoped> .alqyeyti { - > .key, > .value { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - > .key { font-size: 0.85em; padding: 0 0 0.25em 0; @@ -67,6 +48,9 @@ export default defineComponent({ > .value { width: 70%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } } } diff --git a/packages/client/src/components/launch-pad.vue b/packages/client/src/components/launch-pad.vue index ffefc1b085..a6025f8b27 100644 --- a/packages/client/src/components/launch-pad.vue +++ b/packages/client/src/components/launch-pad.vue @@ -16,13 +16,13 @@ </template> </div> <div class="sub"> - <a v-click-anime href="https://misskey-hub.net/help.html" target="_blank" @click.passive="close()"> + <button v-click-anime class="_button" @click="help"> <i class="fas fa-question-circle icon"></i> <div class="text">{{ $ts.help }}</div> - </a> + </button> <MkA v-click-anime to="/about" @click.passive="close()"> <i class="fas fa-info-circle icon"></i> - <div class="text">{{ $t('aboutX', { x: instanceName }) }}</div> + <div class="text">{{ $ts.instanceInfo }}</div> </MkA> <MkA v-click-anime to="/about-misskey" @click.passive="close()"> <img src="/static-assets/favicon.png" class="icon"/> @@ -34,13 +34,14 @@ </template> <script lang="ts" setup> -import { } from 'vue'; +import { } from 'vue'; import MkModal from '@/components/ui/modal.vue'; import { menuDef } from '@/menu'; import { instanceName } from '@/config'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; import { deviceKind } from '@/scripts/device-kind'; +import * as os from '@/os'; const props = withDefaults(defineProps<{ src?: HTMLElement; @@ -73,6 +74,28 @@ const items = Object.keys(menuDef).filter(k => !menu.includes(k)).map(k => menuD function close() { modal.close(); } + +function help(ev: MouseEvent) { + os.popupMenu([{ + type: 'link', + to: '/mfm-cheat-sheet', + text: i18n.ts._mfm.cheatSheet, + icon: 'fas fa-code', + }, { + type: 'link', + to: '/scratchpad', + text: i18n.ts.scratchpad, + icon: 'fas fa-terminal', + }, null, { + text: i18n.ts.document, + icon: 'fas fa-question-circle', + action: () => { + window.open('https://misskey-hub.net/help.html', '_blank'); + }, + }], ev.currentTarget ?? ev.target); + + close(); +} </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/marquee.vue b/packages/client/src/components/marquee.vue new file mode 100644 index 0000000000..ad9de9b845 --- /dev/null +++ b/packages/client/src/components/marquee.vue @@ -0,0 +1,106 @@ +<script lang="ts"> +import { h, onMounted, onUnmounted, ref, watch } from 'vue'; + +export default { + name: 'MarqueeText', + props: { + duration: { + type: Number, + default: 15, + }, + repeat: { + type: Number, + default: 2, + }, + paused: { + type: Boolean, + default: false, + }, + reverse: { + type: Boolean, + default: false, + }, + }, + setup(props) { + const contentEl = ref(); + + function calc() { + const eachLength = contentEl.value.offsetWidth / props.repeat; + const factor = 3000; + const duration = props.duration / ((1 / eachLength) * factor); + + contentEl.value.style.animationDuration = `${duration}s`; + } + + watch(() => props.duration, calc); + + onMounted(() => { + calc(); + }); + + onUnmounted(() => { + }); + + return { + contentEl, + }; + }, + render({ + $slots, $style, $props: { + duration, repeat, paused, reverse, + }, + }) { + return h('div', { class: [$style.wrap] }, [ + h('span', { + ref: 'contentEl', + class: [ + paused + ? $style.paused + : undefined, + $style.content, + ], + }, Array(repeat).fill( + h('span', { + class: $style.text, + style: { + animationDirection: reverse + ? 'reverse' + : undefined, + }, + }, $slots.default()), + )), + ]); + }, +}; +</script> + +<style lang="scss" module> +.wrap { + overflow: hidden; overflow: clip; + animation-play-state: running; + + &:hover { + animation-play-state: paused; + } +} +.content { + display: inline-block; + white-space: nowrap; + animation-play-state: inherit; +} +.text { + display: inline-block; + animation-name: marquee; + animation-timing-function: linear; + animation-iteration-count: infinite; + animation-duration: inherit; + animation-play-state: inherit; +} +.paused .text { + animation-play-state: paused; +} +@keyframes marquee { + 0% { transform:translateX(0); } + 100% { transform:translateX(-100%); } +} +</style> diff --git a/packages/client/src/components/media-image.vue b/packages/client/src/components/media-image.vue index 43639f6771..9d417bd99f 100644 --- a/packages/client/src/components/media-image.vue +++ b/packages/client/src/components/media-image.vue @@ -2,9 +2,9 @@ <div v-if="hide" class="qjewsnkg" @click="hide = false"> <ImgWithBlurhash class="bg" :hash="image.blurhash" :title="image.comment" :alt="image.comment"/> <div class="text"> - <div> - <b><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> - <span>{{ $ts.clickToShow }}</span> + <div class="wrapper"> + <b style="display: block;"><i class="fas fa-exclamation-triangle"></i> {{ $ts.sensitive }}</b> + <span style="display: block;">{{ $ts.clickToShow }}</span> </div> </div> </div> @@ -37,8 +37,8 @@ let hide = $ref(true); const url = (props.raw || defaultStore.state.loadRawImages) ? props.image.url : defaultStore.state.disableShowingAnimatedImages - ? getStaticImageUrl(props.image.thumbnailUrl) - : props.image.thumbnailUrl; + ? getStaticImageUrl(props.image.thumbnailUrl) + : props.image.thumbnailUrl; // Plugin:register_note_view_interruptor を使って書き換えられる可能性があるためwatchする watch(() => props.image, () => { @@ -68,15 +68,11 @@ watch(() => props.image, () => { justify-content: center; align-items: center; - > div { + > .wrapper { display: table-cell; text-align: center; font-size: 0.8em; color: #fff; - - > * { - display: block; - } } } } diff --git a/packages/client/src/components/mention.vue b/packages/client/src/components/mention.vue index 70c2f49afa..2c8bc0c04e 100644 --- a/packages/client/src/components/mention.vue +++ b/packages/client/src/components/mention.vue @@ -1,12 +1,12 @@ <template> -<MkA v-if="url.startsWith('/')" v-user-preview="canonical" :class="[$style.root, { isMe }]" :to="url" :style="{ background: bg }"> +<MkA v-if="url.startsWith('/')" v-user-preview="canonical" :class="[$style.root, { isMe }]" :to="url" :style="{ background: bgCss }"> <img :class="$style.icon" :src="`/avatar/@${username}@${host}`" alt=""> <span class="main"> <span class="username">@{{ username }}</span> <span v-if="(host != localHost) || $store.state.showFullAcct" :class="$style.mainHost">@{{ toUnicode(host) }}</span> </span> </MkA> -<a v-else :class="$style.root" :href="url" target="_blank" rel="noopener" :style="{ background: bg }"> +<a v-else :class="$style.root" :href="url" target="_blank" rel="noopener" :style="{ background: bgCss }"> <span class="main"> <span class="username">@{{ username }}</span> <span :class="$style.mainHost">@{{ toUnicode(host) }}</span> @@ -14,49 +14,31 @@ </a> </template> -<script lang="ts"> -import { defineComponent, useCssModule } from 'vue'; -import tinycolor from 'tinycolor2'; +<script lang="ts" setup> import { toUnicode } from 'punycode'; +import { useCssModule } from 'vue'; +import tinycolor from 'tinycolor2'; import { host as localHost } from '@/config'; import { $i } from '@/account'; -export default defineComponent({ - props: { - username: { - type: String, - required: true - }, - host: { - type: String, - required: true - } - }, - - setup(props) { - const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`; +const props = defineProps<{ + username: string; + host: string; +}>(); - const url = `/${canonical}`; +const canonical = props.host === localHost ? `@${props.username}` : `@${props.username}@${toUnicode(props.host)}`; - const isMe = $i && ( - `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() - ); +const url = `/${canonical}`; - const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention')); - bg.setAlpha(0.1); +const isMe = $i && ( + `@${props.username}@${toUnicode(props.host)}` === `@${$i.username}@${toUnicode(localHost)}`.toLowerCase() +); - useCssModule(); +const bg = tinycolor(getComputedStyle(document.documentElement).getPropertyValue(isMe ? '--mentionMe' : '--mention')); +bg.setAlpha(0.1); +const bgCss = bg.toRgbString(); - return { - localHost, - isMe, - url, - canonical, - toUnicode, - bg: bg.toRgbString(), - }; - }, -}); +useCssModule(); </script> <style lang="scss" module> diff --git a/packages/client/src/components/mfm.ts b/packages/client/src/components/mfm.ts index 4556a82d55..f7dbca0d30 100644 --- a/packages/client/src/components/mfm.ts +++ b/packages/client/src/components/mfm.ts @@ -17,30 +17,30 @@ export default defineComponent({ props: { text: { type: String, - required: true + required: true, }, plain: { type: Boolean, - default: false + default: false, }, nowrap: { type: Boolean, - default: false + default: false, }, author: { type: Object, - default: null + default: null, }, i: { type: Object, - default: null + default: null, }, customEmojis: { required: false, }, isNote: { type: Boolean, - default: true + default: true, }, }, @@ -82,7 +82,7 @@ export default defineComponent({ case 'italic': { return h('i', { - style: 'font-style: oblique;' + style: 'font-style: oblique;', }, genEl(token.children)); } @@ -201,13 +201,13 @@ export default defineComponent({ case 'small': { return [h('small', { - style: 'opacity: 0.7;' + style: 'opacity: 0.7;', }, genEl(token.children))]; } case 'center': { return [h('div', { - style: 'text-align:center;' + style: 'text-align:center;', }, genEl(token.children))]; } @@ -231,7 +231,7 @@ export default defineComponent({ return [h(MkMention, { key: Math.random(), host: (token.props.host == null && this.author && this.author.host != null ? this.author.host : token.props.host) || host, - username: token.props.username + username: token.props.username, })]; } @@ -239,7 +239,7 @@ export default defineComponent({ return [h(MkA, { key: Math.random(), to: this.isNote ? `/tags/${encodeURIComponent(token.props.hashtag)}` : `/explore/tags/${encodeURIComponent(token.props.hashtag)}`, - style: 'color:var(--hashtag);' + style: 'color:var(--hashtag);', }, `#${token.props.hashtag}`)]; } @@ -255,18 +255,18 @@ export default defineComponent({ return [h(MkCode, { key: Math.random(), code: token.props.code, - inline: true + inline: true, })]; } case 'quote': { if (!this.nowrap) { return [h('div', { - class: 'quote' + class: 'quote', }, genEl(token.children))]; } else { return [h('span', { - class: 'quote' + class: 'quote', }, genEl(token.children))]; } } @@ -276,7 +276,7 @@ export default defineComponent({ key: Math.random(), emoji: `:${token.props.name}:`, customEmojis: this.customEmojis, - normal: this.plain + normal: this.plain, })]; } @@ -285,7 +285,7 @@ export default defineComponent({ key: Math.random(), emoji: token.props.emoji, customEmojis: this.customEmojis, - normal: this.plain + normal: this.plain, })]; } @@ -293,7 +293,7 @@ export default defineComponent({ return [h(MkFormula, { key: Math.random(), formula: token.props.formula, - block: false + block: false, })]; } @@ -301,14 +301,14 @@ export default defineComponent({ return [h(MkFormula, { key: Math.random(), formula: token.props.formula, - block: true + block: true, })]; } case 'search': { return [h(MkGoogle, { key: Math.random(), - q: token.props.query + q: token.props.query, })]; } @@ -322,5 +322,5 @@ export default defineComponent({ // Parse ast to DOM return h('span', genEl(ast)); - } + }, }); diff --git a/packages/client/src/components/mini-chart.vue b/packages/client/src/components/mini-chart.vue index 8c74eae876..c64ce163f9 100644 --- a/packages/client/src/components/mini-chart.vue +++ b/packages/client/src/components/mini-chart.vue @@ -2,89 +2,72 @@ <svg :viewBox="`0 0 ${ viewBoxX } ${ viewBoxY }`" style="overflow:visible"> <defs> <linearGradient :id="gradientId" x1="0" x2="0" y1="1" y2="0"> - <stop offset="0%" stop-color="hsl(200, 80%, 70%)"></stop> - <stop offset="100%" stop-color="hsl(90, 80%, 70%)"></stop> + <stop offset="0%" :stop-color="color" stop-opacity="0"></stop> + <stop offset="100%" :stop-color="color" stop-opacity="0.65"></stop> </linearGradient> - <mask :id="maskId" x="0" y="0" :width="viewBoxX" :height="viewBoxY"> - <polygon - :points="polygonPoints" - fill="#fff" - fill-opacity="0.5"/> - <polyline - :points="polylinePoints" - fill="none" - stroke="#fff" - stroke-width="2"/> - <circle - :cx="headX" - :cy="headY" - r="3" - fill="#fff"/> - </mask> </defs> - <rect - x="-10" y="-10" - :width="viewBoxX + 20" :height="viewBoxY + 20" - :style="`stroke: none; fill: url(#${ gradientId }); mask: url(#${ maskId })`"/> + <polygon + :points="polygonPoints" + :style="`stroke: none; fill: url(#${ gradientId });`" + /> + <polyline + :points="polylinePoints" + fill="none" + :stroke="color" + stroke-width="2" + /> + <circle + :cx="headX" + :cy="headY" + r="3" + :fill="color" + /> </svg> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { onUnmounted, watch } from 'vue'; import { v4 as uuid } from 'uuid'; -import * as os from '@/os'; +import tinycolor from 'tinycolor2'; +import { useInterval } from '@/scripts/use-interval'; -export default defineComponent({ - props: { - src: { - type: Array, - required: true - } - }, - data() { - return { - viewBoxX: 50, - viewBoxY: 30, - gradientId: uuid(), - maskId: uuid(), - polylinePoints: '', - polygonPoints: '', - headX: null, - headY: null, - clock: null - }; - }, - watch: { - src() { - this.draw(); - } - }, - created() { - this.draw(); +const props = defineProps<{ + src: number[]; +}>(); - // Vueが何故かWatchを発動させない場合があるので - this.clock = window.setInterval(this.draw, 1000); - }, - beforeUnmount() { - window.clearInterval(this.clock); - }, - methods: { - draw() { - const stats = this.src.slice().reverse(); - const peak = Math.max.apply(null, stats) || 1; +const viewBoxX = 50; +const viewBoxY = 50; +const gradientId = uuid(); +let polylinePoints = $ref(''); +let polygonPoints = $ref(''); +let headX = $ref<number | null>(null); +let headY = $ref<number | null>(null); +let clock = $ref<number | null>(null); +const accent = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent')); +const color = accent.toRgbString(); - const polylinePoints = stats.map((n, i) => [ - i * (this.viewBoxX / (stats.length - 1)), - (1 - (n / peak)) * this.viewBoxY - ]); +function draw(): void { + const stats = props.src.slice().reverse(); + const peak = Math.max.apply(null, stats) || 1; - this.polylinePoints = polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); + const _polylinePoints = stats.map((n, i) => [ + i * (viewBoxX / (stats.length - 1)), + (1 - (n / peak)) * viewBoxY, + ]); - this.polygonPoints = `0,${ this.viewBoxY } ${ this.polylinePoints } ${ this.viewBoxX },${ this.viewBoxY }`; + polylinePoints = _polylinePoints.map(xy => `${xy[0]},${xy[1]}`).join(' '); - this.headX = polylinePoints[polylinePoints.length - 1][0]; - this.headY = polylinePoints[polylinePoints.length - 1][1]; - } - } + polygonPoints = `0,${ viewBoxY } ${ polylinePoints } ${ viewBoxX },${ viewBoxY }`; + + headX = _polylinePoints[_polylinePoints.length - 1][0]; + headY = _polylinePoints[_polylinePoints.length - 1][1]; +} + +watch(() => props.src, draw, { immediate: true }); + +// Vueが何故かWatchを発動させない場合があるので +useInterval(draw, 1000, { + immediate: false, + afterMounted: true, }); </script> diff --git a/packages/client/src/components/modal-page-window.vue b/packages/client/src/components/modal-page-window.vue index 21bdb657b7..2fed0d35e8 100644 --- a/packages/client/src/components/modal-page-window.vue +++ b/packages/client/src/components/modal-page-window.vue @@ -1,163 +1,118 @@ <template> <MkModal ref="modal" @click="$emit('click')" @closed="$emit('closed')"> - <div class="hrmcaedk _window _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }"> + <div ref="rootEl" class="hrmcaedk _narrow_" :style="{ width: `${width}px`, height: (height ? `min(${height}px, 100%)` : '100%') }"> <div class="header" @contextmenu="onContextmenu"> <button v-if="history.length > 0" v-tooltip="$ts.goBack" class="_button" @click="back()"><i class="fas fa-arrow-left"></i></button> <span v-else style="display: inline-block; width: 20px"></span> - <span v-if="pageInfo" class="title"> - <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon"></i> - <span>{{ pageInfo.title }}</span> + <span v-if="pageMetadata?.value" class="title"> + <i v-if="pageMetadata?.value.icon" class="icon" :class="pageMetadata?.value.icon"></i> + <span>{{ pageMetadata?.value.title }}</span> </span> <button class="_button" @click="$refs.modal.close()"><i class="fas fa-times"></i></button> </div> <div class="body"> <MkStickyContainer> - <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> - <keep-alive> - <component :is="component" v-bind="props" :ref="changePage"/> - </keep-alive> + <template #header><MkPageHeader v-if="pageMetadata?.value && !pageMetadata?.value.hideHeader" :info="pageMetadata?.value"/></template> + <RouterView :router="router"/> </MkStickyContainer> </div> </div> </MkModal> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { ComputedRef, provide } from 'vue'; import MkModal from '@/components/ui/modal.vue'; -import { popout } from '@/scripts/popout'; +import { popout as _popout } from '@/scripts/popout'; import copyToClipboard from '@/scripts/copy-to-clipboard'; -import { resolve } from '@/router'; import { url } from '@/config'; -import * as symbols from '@/symbols'; import * as os from '@/os'; +import { mainRouter, routes } from '@/router'; +import { i18n } from '@/i18n'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import { Router } from '@/nirax'; -export default defineComponent({ - components: { - MkModal, - }, +const props = defineProps<{ + initialPath: string; +}>(); - inject: { - sideViewHook: { - default: null, - }, - }, - - provide() { - return { - navHook: (path) => { - this.navigate(path); - }, - shouldHeaderThin: true, - }; - }, +defineEmits<{ + (ev: 'closed'): void; + (ev: 'click'): void; +}>(); - props: { - initialPath: { - type: String, - required: true, - }, - initialComponent: { - type: Object, - required: true, - }, - initialProps: { - type: Object, - required: false, - default: () => {}, - }, - }, +const router = new Router(routes, props.initialPath); - emits: ['closed'], +router.addListener('push', ctx => { + +}); - data() { - return { - width: 860, - height: 660, - pageInfo: null, - path: this.initialPath, - component: this.initialComponent, - props: this.initialProps, - history: [], - }; - }, +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); +let rootEl = $ref(); +let modal = $ref<InstanceType<typeof MkModal>>(); +let path = $ref(props.initialPath); +let width = $ref(860); +let height = $ref(660); +const history = []; - computed: { - url(): string { - return url + this.path; - }, +provide('router', router); +provideMetadataReceiver((info) => { + pageMetadata = info; +}); +provide('shouldOmitHeaderTitle', true); +provide('shouldHeaderThin', true); - contextmenu() { - return [{ - type: 'label', - text: this.path, - }, { - icon: 'fas fa-expand-alt', - text: this.$ts.showInPage, - action: this.expand, - }, this.sideViewHook ? { - icon: 'fas fa-columns', - text: this.$ts.openInSideView, - action: () => { - this.sideViewHook(this.path); - this.$refs.window.close(); - }, - } : undefined, { - icon: 'fas fa-external-link-alt', - text: this.$ts.popout, - action: this.popout, - }, null, { - icon: 'fas fa-external-link-alt', - text: this.$ts.openInNewTab, - action: () => { - window.open(this.url, '_blank'); - this.$refs.window.close(); - }, - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: () => { - copyToClipboard(this.url); - }, - }]; +const pageUrl = $computed(() => url + path); +const contextmenu = $computed(() => { + return [{ + type: 'label', + text: path, + }, { + icon: 'fas fa-expand-alt', + text: i18n.ts.showInPage, + action: expand, + }, { + icon: 'fas fa-external-link-alt', + text: i18n.ts.popout, + action: popout, + }, null, { + icon: 'fas fa-external-link-alt', + text: i18n.ts.openInNewTab, + action: () => { + window.open(pageUrl, '_blank'); + modal.close(); }, - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - } + }, { + icon: 'fas fa-link', + text: i18n.ts.copyLink, + action: () => { + copyToClipboard(pageUrl); }, + }]; +}); - navigate(path, record = true) { - if (record) this.history.push(this.path); - this.path = path; - const { component, props } = resolve(path); - this.component = component; - this.props = props; - }, +function navigate(path, record = true) { + if (record) history.push(router.getCurrentPath()); + router.push(path); +} - back() { - this.navigate(this.history.pop(), false); - }, +function back() { + navigate(history.pop(), false); +} - expand() { - this.$router.push(this.path); - this.$refs.window.close(); - }, +function expand() { + mainRouter.push(path); + modal.close(); +} - popout() { - popout(this.path, this.$el); - this.$refs.window.close(); - }, +function popout() { + _popout(path, rootEl); + modal.close(); +} - onContextmenu(ev: MouseEvent) { - os.contextMenu(this.contextmenu, ev); - }, - }, -}); +function onContextmenu(ev: MouseEvent) { + os.contextMenu(contextmenu, ev); +} </script> <style lang="scss" scoped> @@ -166,6 +121,7 @@ export default defineComponent({ display: flex; flex-direction: column; contain: content; + border-radius: var(--radius); --root-margin: 24px; @@ -184,7 +140,9 @@ export default defineComponent({ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; - box-shadow: 0px 1px var(--divider); + background: var(--windowHeader); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); > button { height: $height; diff --git a/packages/client/src/components/note-detailed.vue b/packages/client/src/components/note-detailed.vue index 6234b710d2..c05ab7fec4 100644 --- a/packages/client/src/components/note-detailed.vue +++ b/packages/client/src/components/note-detailed.vue @@ -26,12 +26,7 @@ <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> <MkTime :time="note.createdAt"/> </button> - <span v-if="note.visibility !== 'public'" class="visibility"> - <i v-if="note.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> + <MkVisibility :note="note"/> </div> </div> <article class="article" @contextmenu.stop="onContextmenu"> @@ -43,12 +38,9 @@ <MkUserName :user="appearNote.user"/> </MkA> <span v-if="appearNote.user.isBot" class="is-bot">bot</span> - <span v-if="appearNote.visibility !== 'public'" class="visibility"> - <i v-if="appearNote.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="appearNote.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="appearNote.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span v-if="appearNote.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> + <div class="info"> + <MkVisibility :note="appearNote"/> + </div> </div> <div class="username"><MkAcct :user="appearNote.user"/></div> <MkInstanceTicker v-if="showTicker" class="ticker" :instance="appearNote.user.instance"/> @@ -134,6 +126,7 @@ import XPoll from './poll.vue'; import XRenoteButton from './renote-button.vue'; import MkUrlPreview from '@/components/url-preview.vue'; import MkInstanceTicker from '@/components/instance-ticker.vue'; +import MkVisibility from '@/components/visibility.vue'; import { pleaseLogin } from '@/scripts/please-login'; import { checkWordMute } from '@/scripts/check-word-mute'; import { userPage } from '@/filters/user'; @@ -251,12 +244,12 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton }), ev).then(focus); + os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), ev).then(focus); } } function menu(viaKeyboard = false): void { - os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton }), menuButton.value, { + os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted }), menuButton.value, { viaKeyboard, }).then(focus); } @@ -388,14 +381,6 @@ if (appearNote.replyId) { margin-right: 4px; } } - - > .visibility { - margin-left: 8px; - } - - > .localOnly { - margin-left: 8px; - } } } @@ -441,6 +426,10 @@ if (appearNote.replyId) { border: solid 0.5px var(--divider); border-radius: 4px; } + + > .info { + float: right; + } } } } diff --git a/packages/client/src/components/note-header.vue b/packages/client/src/components/note-header.vue index 56a3a37e75..0b05498566 100644 --- a/packages/client/src/components/note-header.vue +++ b/packages/client/src/components/note-header.vue @@ -9,12 +9,7 @@ <MkA class="created-at" :to="notePage(note)"> <MkTime :time="note.createdAt"/> </MkA> - <span v-if="note.visibility !== 'public'" class="visibility"> - <i v-if="note.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> + <MkVisibility :note="note"/> </div> </header> </template> @@ -22,6 +17,7 @@ <script lang="ts" setup> import { } from 'vue'; import * as misskey from 'misskey-js'; +import MkVisibility from '@/components/visibility.vue'; import { notePage } from '@/filters/note'; import { userPage } from '@/filters/user'; @@ -74,14 +70,6 @@ defineProps<{ flex-shrink: 0; margin-left: auto; font-size: 0.9em; - - > .visibility { - margin-left: 8px; - } - - > .localOnly { - margin-left: 8px; - } } } </style> diff --git a/packages/client/src/components/note-preview.vue b/packages/client/src/components/note-preview.vue index a78b499654..be7214db19 100644 --- a/packages/client/src/components/note-preview.vue +++ b/packages/client/src/components/note-preview.vue @@ -27,7 +27,7 @@ const props = defineProps<{ display: flex; margin: 0; padding: 0; - overflow: clip; + overflow: hidden; overflow: clip; font-size: 0.95em; &.min-width_350px { diff --git a/packages/client/src/components/note-simple.vue b/packages/client/src/components/note-simple.vue index b813b9a2b9..93c34b6bf4 100644 --- a/packages/client/src/components/note-simple.vue +++ b/packages/client/src/components/note-simple.vue @@ -36,7 +36,7 @@ const showContent = $ref(false); display: flex; margin: 0; padding: 0; - overflow: clip; + overflow: hidden; overflow: clip; font-size: 0.95em; &.min-width_350px { diff --git a/packages/client/src/components/note.vue b/packages/client/src/components/note.vue index e5744d1ce9..b494c70392 100644 --- a/packages/client/src/components/note.vue +++ b/packages/client/src/components/note.vue @@ -28,12 +28,7 @@ <i v-if="isMyRenote" class="fas fa-ellipsis-h dropdownIcon"></i> <MkTime :time="note.createdAt"/> </button> - <span v-if="note.visibility !== 'public'" class="visibility"> - <i v-if="note.visibility === 'home'" class="fas fa-home"></i> - <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> - <i v-else-if="note.visibility === 'specified'" class="fas fa-envelope"></i> - </span> - <span v-if="note.localOnly" class="localOnly"><i class="fas fa-biohazard"></i></span> + <MkVisibility :note="note"/> </div> </div> <article class="article" @contextmenu.stop="onContextmenu"> @@ -105,7 +100,7 @@ </template> <script lang="ts" setup> -import { computed, inject, onMounted, onUnmounted, reactive, ref } from 'vue'; +import { computed, inject, onMounted, onUnmounted, reactive, ref, Ref } from 'vue'; import * as mfm from 'mfm-js'; import * as misskey from 'misskey-js'; import MkNoteSub from './MkNoteSub.vue'; @@ -118,6 +113,7 @@ import XPoll from './poll.vue'; import XRenoteButton from './renote-button.vue'; import MkUrlPreview from '@/components/url-preview.vue'; import MkInstanceTicker from '@/components/instance-ticker.vue'; +import MkVisibility from '@/components/visibility.vue'; import { pleaseLogin } from '@/scripts/please-login'; import { focusPrev, focusNext } from '@/scripts/focus'; import { checkWordMute } from '@/scripts/check-word-mute'; @@ -225,6 +221,8 @@ function undoReact(note): void { }); } +const currentClipPage = inject<Ref<misskey.entities.Clip> | null>('currentClipPage', null); + function onContextmenu(ev: MouseEvent): void { const isLink = (el: HTMLElement) => { if (el.tagName === 'A') return true; @@ -239,12 +237,12 @@ function onContextmenu(ev: MouseEvent): void { ev.preventDefault(); react(); } else { - os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton }), ev).then(focus); + os.contextMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), ev).then(focus); } } function menu(viaKeyboard = false): void { - os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton }), menuButton.value, { + os.popupMenu(getNoteMenu({ note: note, translating, translation, menuButton, isDeleted, currentClipPage }), menuButton.value, { viaKeyboard, }).then(focus); } @@ -295,7 +293,7 @@ function readPromo() { position: relative; transition: box-shadow 0.1s ease; font-size: 1.05em; - overflow: clip; + overflow: hidden; overflow: clip; contain: content; // これらの指定はパフォーマンス向上には有効だが、ノートの高さは一定でないため、 @@ -404,14 +402,6 @@ function readPromo() { margin-right: 4px; } } - - > .visibility { - margin-left: 8px; - } - - > .localOnly { - margin-left: 8px; - } } } diff --git a/packages/client/src/components/notification-setting-window.vue b/packages/client/src/components/notification-setting-window.vue index 64d828394b..bf0a148f59 100644 --- a/packages/client/src/components/notification-setting-window.vue +++ b/packages/client/src/components/notification-setting-window.vue @@ -6,95 +6,82 @@ :with-ok-button="true" :ok-button-disabled="false" @ok="ok()" - @close="$refs.dialog.close()" - @closed="$emit('closed')" + @close="dialog.close()" + @closed="emit('closed')" > - <template #header>{{ $ts.notificationSetting }}</template> + <template #header>{{ i18n.ts.notificationSetting }}</template> <div class="_monolithic_"> <div v-if="showGlobalToggle" class="_section"> <MkSwitch v-model="useGlobalSetting"> - {{ $ts.useGlobalSetting }} - <template #caption>{{ $ts.useGlobalSettingDesc }}</template> + {{ i18n.ts.useGlobalSetting }} + <template #caption>{{ i18n.ts.useGlobalSettingDesc }}</template> </MkSwitch> </div> <div v-if="!useGlobalSetting" class="_section"> - <MkInfo>{{ $ts.notificationSettingDesc }}</MkInfo> - <MkButton inline @click="disableAll">{{ $ts.disableAll }}</MkButton> - <MkButton inline @click="enableAll">{{ $ts.enableAll }}</MkButton> - <MkSwitch v-for="type in notificationTypes" :key="type" v-model="typesMap[type]">{{ $t(`_notification._types.${type}`) }}</MkSwitch> + <MkInfo>{{ i18n.ts.notificationSettingDesc }}</MkInfo> + <MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton> + <MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton> + <MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype]">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch> </div> </div> </XModalWindow> </template> -<script lang="ts"> -import { defineComponent, PropType } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import { notificationTypes } from 'misskey-js'; import MkSwitch from './form/switch.vue'; import MkInfo from './ui/info.vue'; import MkButton from './ui/button.vue'; import XModalWindow from '@/components/ui/modal-window.vue'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - XModalWindow, - MkSwitch, - MkInfo, - MkButton, - }, +const emit = defineEmits<{ + (ev: 'done', v: { includingTypes: string[] | null }): void, + (ev: 'closed'): void, +}>(); - props: { - includingTypes: { - // TODO: これで型に合わないものを弾いてくれるのかどうか要調査 - type: Array as PropType<typeof notificationTypes[number][]>, - required: false, - default: null, - }, - showGlobalToggle: { - type: Boolean, - required: false, - default: true, - }, - }, +const props = withDefaults(defineProps<{ + includingTypes?: typeof notificationTypes[number][] | null; + showGlobalToggle?: boolean; +}>(), { + includingTypes: () => [], + showGlobalToggle: true, +}); - emits: ['done', 'closed'], +let includingTypes = $computed(() => props.includingTypes || []); - data() { - return { - typesMap: {} as Record<typeof notificationTypes[number], boolean>, - useGlobalSetting: false, - notificationTypes, - }; - }, +const dialog = $ref<InstanceType<typeof XModalWindow>>(); - created() { - this.useGlobalSetting = this.includingTypes === null && this.showGlobalToggle; +let typesMap = $ref<Record<typeof notificationTypes[number], boolean>>({}); +let useGlobalSetting = $ref((includingTypes === null || includingTypes.length === 0) && props.showGlobalToggle); - for (const type of this.notificationTypes) { - this.typesMap[type] = this.includingTypes === null || this.includingTypes.includes(type); - } - }, +for (const ntype of notificationTypes) { + typesMap[ntype] = includingTypes.includes(ntype); +} - methods: { - ok() { - const includingTypes = this.useGlobalSetting ? null : (Object.keys(this.typesMap) as typeof notificationTypes[number][]) - .filter(type => this.typesMap[type]); +function ok() { + if (useGlobalSetting) { + emit('done', { includingTypes: null }); + } else { + emit('done', { + includingTypes: (Object.keys(typesMap) as typeof notificationTypes[number][]) + .filter(type => typesMap[type]), + }); + } - this.$emit('done', { includingTypes }); - this.$refs.dialog.close(); - }, + dialog.close(); +} - disableAll() { - for (const type in this.typesMap) { - this.typesMap[type as typeof notificationTypes[number]] = false; - } - }, +function disableAll() { + for (const type in typesMap) { + typesMap[type as typeof notificationTypes[number]] = false; + } +} - enableAll() { - for (const type in this.typesMap) { - this.typesMap[type as typeof notificationTypes[number]] = true; - } - }, - }, -}); +function enableAll() { + for (const type in typesMap) { + typesMap[type as typeof notificationTypes[number]] = true; + } +} </script> diff --git a/packages/client/src/components/notification.vue b/packages/client/src/components/notification.vue index cbfd809f37..32f9fd07d8 100644 --- a/packages/client/src/components/notification.vue +++ b/packages/client/src/components/notification.vue @@ -72,8 +72,8 @@ </div> </template> -<script lang="ts"> -import { defineComponent, ref, onMounted, onUnmounted, watch } from 'vue'; +<script lang="ts" setup> +import { ref, onMounted, onUnmounted, watch } from 'vue'; import * as misskey from 'misskey-js'; import XReactionIcon from './reaction-icon.vue'; import MkFollowButton from './follow-button.vue'; @@ -86,105 +86,77 @@ import * as os from '@/os'; import { stream } from '@/stream'; import { useTooltip } from '@/scripts/use-tooltip'; -export default defineComponent({ - components: { - XReactionIcon, MkFollowButton, - }, - - props: { - notification: { - type: Object, - required: true, - }, - withTime: { - type: Boolean, - required: false, - default: false, - }, - full: { - type: Boolean, - required: false, - default: false, - }, - }, +const props = withDefaults(defineProps<{ + notification: misskey.entities.Notification; + withTime?: boolean; + full?: boolean; +}>(), { + withTime: false, + full: false, +}); - setup(props) { - const elRef = ref<HTMLElement>(null); - const reactionRef = ref(null); +const elRef = ref<HTMLElement>(null); +const reactionRef = ref(null); - onMounted(() => { - if (!props.notification.isRead) { - const readObserver = new IntersectionObserver((entries, observer) => { - if (!entries.some(entry => entry.isIntersecting)) return; - stream.send('readNotification', { - id: props.notification.id, - }); - observer.disconnect(); - }); +let readObserver: IntersectionObserver | undefined; +let connection; - readObserver.observe(elRef.value); +onMounted(() => { + if (!props.notification.isRead) { + readObserver = new IntersectionObserver((entries, observer) => { + if (!entries.some(entry => entry.isIntersecting)) return; + stream.send('readNotification', { + id: props.notification.id, + }); + observer.disconnect(); + }); - const connection = stream.useChannel('main'); - connection.on('readAllNotifications', () => readObserver.disconnect()); + readObserver.observe(elRef.value); - watch(props.notification.isRead, () => { - readObserver.disconnect(); - }); + connection = stream.useChannel('main'); + connection.on('readAllNotifications', () => readObserver.disconnect()); - onUnmounted(() => { - readObserver.disconnect(); - connection.dispose(); - }); - } + watch(props.notification.isRead, () => { + readObserver.disconnect(); }); + } +}); - const followRequestDone = ref(false); - const groupInviteDone = ref(false); +onUnmounted(() => { + if (readObserver) readObserver.disconnect(); + if (connection) connection.dispose(); +}); - const acceptFollowRequest = () => { - followRequestDone.value = true; - os.api('following/requests/accept', { userId: props.notification.user.id }); - }; +const followRequestDone = ref(false); +const groupInviteDone = ref(false); - const rejectFollowRequest = () => { - followRequestDone.value = true; - os.api('following/requests/reject', { userId: props.notification.user.id }); - }; +const acceptFollowRequest = () => { + followRequestDone.value = true; + os.api('following/requests/accept', { userId: props.notification.user.id }); +}; - const acceptGroupInvitation = () => { - groupInviteDone.value = true; - os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id }); - }; +const rejectFollowRequest = () => { + followRequestDone.value = true; + os.api('following/requests/reject', { userId: props.notification.user.id }); +}; - const rejectGroupInvitation = () => { - groupInviteDone.value = true; - os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id }); - }; +const acceptGroupInvitation = () => { + groupInviteDone.value = true; + os.apiWithDialog('users/groups/invitations/accept', { invitationId: props.notification.invitation.id }); +}; - useTooltip(reactionRef, (showing) => { - os.popup(XReactionTooltip, { - showing, - reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction, - emojis: props.notification.note.emojis, - targetElement: reactionRef.value.$el, - }, {}, 'closed'); - }); +const rejectGroupInvitation = () => { + groupInviteDone.value = true; + os.api('users/groups/invitations/reject', { invitationId: props.notification.invitation.id }); +}; - return { - getNoteSummary: (note: misskey.entities.Note) => getNoteSummary(note), - followRequestDone, - groupInviteDone, - notePage, - userPage, - acceptFollowRequest, - rejectFollowRequest, - acceptGroupInvitation, - rejectGroupInvitation, - elRef, - reactionRef, - i18n, - }; - }, +useTooltip(reactionRef, (showing) => { + os.popup(XReactionTooltip, { + showing, + reaction: props.notification.reaction ? props.notification.reaction.replace(/^:(\w+):$/, ':$1@.:') : props.notification.reaction, + emojis: props.notification.note.emojis, + targetElement: reactionRef.value.$el, + }, {}, 'closed'); }); </script> diff --git a/packages/client/src/components/notifications.vue b/packages/client/src/components/notifications.vue index 8eb569c369..eb19ad488c 100644 --- a/packages/client/src/components/notifications.vue +++ b/packages/client/src/components/notifications.vue @@ -60,8 +60,10 @@ const onNotification = (notification) => { } }; +let connection; + onMounted(() => { - const connection = stream.useChannel('main'); + connection = stream.useChannel('main'); connection.on('notification', onNotification); connection.on('readAllNotifications', () => { if (pagingComponent.value) { @@ -87,10 +89,10 @@ onMounted(() => { } } }); +}); - onUnmounted(() => { - connection.dispose(); - }); +onUnmounted(() => { + if (connection) connection.dispose(); }); </script> diff --git a/packages/client/src/components/object-view.value.vue b/packages/client/src/components/object-view.value.vue index 6f388636dd..0c7230d783 100644 --- a/packages/client/src/components/object-view.value.vue +++ b/packages/client/src/components/object-view.value.vue @@ -1,31 +1,35 @@ <template> <div class="igpposuu _monospace"> <div v-if="value === null" class="null">null</div> - <div v-else-if="typeof value === 'boolean'" class="boolean">{{ value ? 'true' : 'false' }}</div> + <div v-else-if="typeof value === 'boolean'" class="boolean" :class="{ true: value, false: !value }">{{ value ? 'true' : 'false' }}</div> <div v-else-if="typeof value === 'string'" class="string">"{{ value }}"</div> <div v-else-if="typeof value === 'number'" class="number">{{ number(value) }}</div> - <div v-else-if="Array.isArray(value)" class="array"> - <button @click="collapsed_ = !collapsed_">[ {{ collapsed_ ? '+' : '-' }} ]</button> - <template v-if="!collapsed_"> - <div v-for="i in value.length" class="element"> - {{ i }}: <XValue :value="value[i - 1]" collapsed/> - </div> - </template> + <div v-else-if="isArray(value) && isEmpty(value)" class="array empty">[]</div> + <div v-else-if="isArray(value)" class="array"> + <div v-for="i in value.length" class="element"> + {{ i }}: <XValue :value="value[i - 1]" collapsed/> + </div> </div> - <div v-else-if="typeof value === 'object'" class="object"> - <button @click="collapsed_ = !collapsed_">{ {{ collapsed_ ? '+' : '-' }} }</button> - <template v-if="!collapsed_"> - <div v-for="k in Object.keys(value)" class="kv"> - <div class="k">{{ k }}:</div> - <div class="v"><XValue :value="value[k]" collapsed/></div> + <div v-else-if="isObject(value) && isEmpty(value)" class="object empty">{}</div> + <div v-else-if="isObject(value)" class="object"> + <div v-for="k in Object.keys(value)" class="kv"> + <button class="toggle _button" :class="{ visible: collapsable(value[k]) }" @click="collapsed[k] = !collapsed[k]">{{ collapsed[k] ? '+' : '-' }}</button> + <div class="k">{{ k }}:</div> + <div v-if="collapsed[k]" class="v"> + <button class="_button" @click="collapsed[k] = !collapsed[k]"> + <template v-if="typeof value[k] === 'string'">"..."</template> + <template v-else-if="isArray(value[k])">[...]</template> + <template v-else-if="isObject(value[k])">{...}</template> + </button> </div> - </template> + <div v-else class="v"><XValue :value="value[k]"/></div> + </div> </div> </div> </template> <script lang="ts"> -import { computed, defineComponent, ref } from 'vue'; +import { computed, defineComponent, reactive, ref } from 'vue'; import number from '@/filters/number'; export default defineComponent({ @@ -33,24 +37,44 @@ export default defineComponent({ props: { value: { - type: Object, required: true, }, - collapsed: { - type: Boolean, - required: false, - default: false, - }, }, setup(props) { - const collapsed_ = ref(props.collapsed); + const collapsed = reactive({}); + + if (isObject(props.value)) { + for (const key in props.value) { + collapsed[key] = collapsable(props.value[key]); + } + } + + function isObject(v): boolean { + return typeof v === 'object' && !Array.isArray(v) && v !== null; + } + + function isArray(v): boolean { + return Array.isArray(v); + } + + function isEmpty(v): boolean { + return (isArray(v) && v.length === 0) || (isObject(v) && Object.keys(v).length === 0); + } + + function collapsable(v): boolean { + return (isObject(v) || isArray(v)) && !isEmpty(v); + } return { number, - collapsed_, + collapsed, + isObject, + isArray, + isEmpty, + collapsable, }; - } + }, }); </script> @@ -66,6 +90,14 @@ export default defineComponent({ > .boolean { display: inline; color: var(--codeBoolean); + + &.true { + font-weight: bold; + } + + &.false { + opacity: 0.7; + } } > .string { @@ -78,7 +110,12 @@ export default defineComponent({ color: var(--codeNumber); } - > .array { + > .array.empty { + display: inline; + opacity: 0.7; + } + + > .array:not(.empty) { display: inline; > .element { @@ -87,13 +124,28 @@ export default defineComponent({ } } - > .object { + > .object.empty { + display: inline; + opacity: 0.7; + } + + > .object:not(.empty) { display: inline; > .kv { display: block; padding-left: 16px; + > .toggle { + width: 16px; + color: var(--accent); + visibility: hidden; + + &.visible { + visibility: visible; + } + } + > .k { display: inline; margin-right: 8px; diff --git a/packages/client/src/components/object-view.vue b/packages/client/src/components/object-view.vue index e9db96de8c..db66049fce 100644 --- a/packages/client/src/components/object-view.vue +++ b/packages/client/src/components/object-view.vue @@ -4,26 +4,13 @@ </div> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import XValue from './object-view.value.vue'; -export default defineComponent({ - components: { - XValue - }, - - props: { - value: { - type: Object, - required: true, - }, - }, - - setup(props) { - - } -}); +const props = defineProps<{ + value: Record<string, unknown>; +}>(); </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/page-preview.vue b/packages/client/src/components/page-preview.vue index 090aff6c65..009582e540 100644 --- a/packages/client/src/components/page-preview.vue +++ b/packages/client/src/components/page-preview.vue @@ -1,5 +1,5 @@ <template> -<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block _isolated" tabindex="-1"> +<MkA :to="`/@${page.user.username}/pages/${page.name}`" class="vhpxefrj _block" tabindex="-1"> <div v-if="page.eyeCatchingImage" class="thumbnail" :style="`background-image: url('${page.eyeCatchingImage.thumbnailUrl}')`"></div> <article> <header> @@ -23,12 +23,12 @@ export default defineComponent({ props: { page: { type: Object, - required: true + required: true, }, }, methods: { - userName - } + userName, + }, }); </script> diff --git a/packages/client/src/components/page-window.vue b/packages/client/src/components/page-window.vue index 7455236bad..5b06c7718c 100644 --- a/packages/client/src/components/page-window.vue +++ b/packages/client/src/components/page-window.vue @@ -1,191 +1,144 @@ <template> -<XWindow ref="window" +<XWindow + ref="windowEl" :initial-width="500" :initial-height="500" :can-resize="true" :close-button="true" + :buttons-left="buttonsLeft" + :buttons-right="buttonsRight" :contextmenu="contextmenu" @closed="$emit('closed')" > <template #header> - <template v-if="pageInfo"> - <i v-if="pageInfo.icon" class="icon" :class="pageInfo.icon" style="margin-right: 0.5em;"></i> - <span>{{ pageInfo.title }}</span> + <template v-if="pageMetadata?.value"> + <i v-if="pageMetadata.value.icon" class="icon" :class="pageMetadata.value.icon" style="margin-right: 0.5em;"></i> + <span>{{ pageMetadata.value.title }}</span> </template> </template> - <template #headerLeft> - <button v-if="history.length > 0" v-tooltip="$ts.goBack" class="_button" @click="back()"><i class="fas fa-arrow-left"></i></button> - </template> - <template #headerRight> - <button v-tooltip="$ts.showInPage" class="_button" @click="expand()"><i class="fas fa-expand-alt"></i></button> - <button v-tooltip="$ts.popout" class="_button" @click="popout()"><i class="fas fa-external-link-alt"></i></button> - <button class="_button" @click="menu"><i class="fas fa-ellipsis-h"></i></button> - </template> - <div class="yrolvcoq" :style="{ background: pageInfo?.bg }"> - <MkStickyContainer> - <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> - <component :is="component" v-bind="props" :ref="changePage"/> - </MkStickyContainer> + <div class="yrolvcoq" :style="{ background: pageMetadata?.value?.bg }"> + <RouterView :router="router"/> </div> </XWindow> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { ComputedRef, inject, provide } from 'vue'; +import RouterView from './global/router-view.vue'; import XWindow from '@/components/ui/window.vue'; -import { popout } from '@/scripts/popout'; +import { popout as _popout } from '@/scripts/popout'; import copyToClipboard from '@/scripts/copy-to-clipboard'; -import { resolve } from '@/router'; import { url } from '@/config'; -import * as symbols from '@/symbols'; import * as os from '@/os'; +import { mainRouter, routes } from '@/router'; +import { Router } from '@/nirax'; +import { i18n } from '@/i18n'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; -export default defineComponent({ - components: { - XWindow, - }, +const props = defineProps<{ + initialPath: string; +}>(); - inject: { - sideViewHook: { - default: null - } - }, +defineEmits<{ + (ev: 'closed'): void; +}>(); - provide() { - return { - navHook: (path) => { - this.navigate(path); - }, - shouldHeaderThin: true, - }; - }, +const router = new Router(routes, props.initialPath); - props: { - initialPath: { - type: String, - required: true, - }, - initialComponent: { - type: Object, - required: true, - }, - initialProps: { - type: Object, - required: false, - default: () => {}, - }, - }, +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); +let windowEl = $ref<InstanceType<typeof XWindow>>(); +const history = $ref<{ path: string; key: any; }[]>([{ + path: router.getCurrentPath(), + key: router.getCurrentKey(), +}]); +const buttonsLeft = $computed(() => { + const buttons = []; - emits: ['closed'], + if (history.length > 1) { + buttons.push({ + icon: 'fas fa-arrow-left', + onClick: back, + }); + } - data() { - return { - pageInfo: null, - path: this.initialPath, - component: this.initialComponent, - props: this.initialProps, - history: [], - }; - }, + return buttons; +}); +const buttonsRight = $computed(() => { + const buttons = [{ + icon: 'fas fa-expand-alt', + title: i18n.ts.showInPage, + onClick: expand, + }]; - computed: { - url(): string { - return url + this.path; - }, + return buttons; +}); - contextmenu() { - return [{ - type: 'label', - text: this.path, - }, { - icon: 'fas fa-expand-alt', - text: this.$ts.showInPage, - action: this.expand - }, this.sideViewHook ? { - icon: 'fas fa-columns', - text: this.$ts.openInSideView, - action: () => { - this.sideViewHook(this.path); - this.$refs.window.close(); - } - } : undefined, { - icon: 'fas fa-external-link-alt', - text: this.$ts.popout, - action: this.popout - }, null, { - icon: 'fas fa-external-link-alt', - text: this.$ts.openInNewTab, - action: () => { - window.open(this.url, '_blank'); - this.$refs.window.close(); - } - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: () => { - copyToClipboard(this.url); - } - }]; - }, - }, +router.addListener('push', ctx => { + history.push({ path: ctx.path, key: ctx.key }); +}); - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - } - }, +provide('router', router); +provideMetadataReceiver((info) => { + pageMetadata = info; +}); +provide('shouldOmitHeaderTitle', true); +provide('shouldHeaderThin', true); - navigate(path, record = true) { - if (record) this.history.push(this.path); - this.path = path; - const { component, props } = resolve(path); - this.component = component; - this.props = props; - }, +const contextmenu = $computed(() => ([{ + icon: 'fas fa-expand-alt', + text: i18n.ts.showInPage, + action: expand, +}, { + icon: 'fas fa-external-link-alt', + text: i18n.ts.popout, + action: popout, +}, { + icon: 'fas fa-external-link-alt', + text: i18n.ts.openInNewTab, + action: () => { + window.open(url + router.getCurrentPath(), '_blank'); + windowEl.close(); + }, +}, { + icon: 'fas fa-link', + text: i18n.ts.copyLink, + action: () => { + copyToClipboard(url + router.getCurrentPath()); + }, +}])); + +function menu(ev) { + os.popupMenu(contextmenu, ev.currentTarget ?? ev.target); +} - menu(ev) { - os.popupMenu([{ - icon: 'fas fa-external-link-alt', - text: this.$ts.openInNewTab, - action: () => { - window.open(this.url, '_blank'); - this.$refs.window.close(); - } - }, { - icon: 'fas fa-link', - text: this.$ts.copyLink, - action: () => { - copyToClipboard(this.url); - } - }], ev.currentTarget ?? ev.target); - }, +function back() { + history.pop(); + router.change(history[history.length - 1].path, history[history.length - 1].key); +} - back() { - this.navigate(this.history.pop(), false); - }, +function close() { + windowEl.close(); +} - close() { - this.$refs.window.close(); - }, +function expand() { + mainRouter.push(router.getCurrentPath()); + windowEl.close(); +} - expand() { - this.$router.push(this.path); - this.$refs.window.close(); - }, +function popout() { + _popout(router.getCurrentPath(), windowEl.$el); + windowEl.close(); +} - popout() { - popout(this.path, this.$el); - this.$refs.window.close(); - }, - }, +defineExpose({ + close, }); </script> <style lang="scss" scoped> .yrolvcoq { min-height: 100%; + background: var(--bg); } </style> diff --git a/packages/client/src/components/page/page.vue b/packages/client/src/components/page/page.vue index a067762372..58c43b22bc 100644 --- a/packages/client/src/components/page/page.vue +++ b/packages/client/src/components/page/page.vue @@ -24,7 +24,6 @@ export default defineComponent({ }, }, setup(props, ctx) { - const hpml = new Hpml(props.page, { randomSeed: Math.random(), visitor: $i, diff --git a/packages/client/src/components/poll-editor.vue b/packages/client/src/components/poll-editor.vue index 9aa5510c7f..a068aca79e 100644 --- a/packages/client/src/components/poll-editor.vue +++ b/packages/client/src/components/poll-editor.vue @@ -5,7 +5,7 @@ </p> <ul> <li v-for="(choice, i) in choices" :key="i"> - <MkInput class="input" :model-value="choice" :placeholder="$t('_poll.choiceN', { n: i + 1 })" @update:modelValue="onInput(i, $event)"> + <MkInput class="input" small :model-value="choice" :placeholder="$t('_poll.choiceN', { n: i + 1 })" @update:modelValue="onInput(i, $event)"> </MkInput> <button class="_button" @click="remove(i)"> <i class="fas fa-times"></i> @@ -17,25 +17,25 @@ <MkSwitch v-model="multiple">{{ $ts._poll.canMultipleVote }}</MkSwitch> <section> <div> - <MkSelect v-model="expiration"> + <MkSelect v-model="expiration" small> <template #label>{{ $ts._poll.expiration }}</template> <option value="infinite">{{ $ts._poll.infinite }}</option> <option value="at">{{ $ts._poll.at }}</option> <option value="after">{{ $ts._poll.after }}</option> </MkSelect> <section v-if="expiration === 'at'"> - <MkInput v-model="atDate" type="date" class="input"> + <MkInput v-model="atDate" small type="date" class="input"> <template #label>{{ $ts._poll.deadlineDate }}</template> </MkInput> - <MkInput v-model="atTime" type="time" class="input"> + <MkInput v-model="atTime" small type="time" class="input"> <template #label>{{ $ts._poll.deadlineTime }}</template> </MkInput> </section> <section v-else-if="expiration === 'after'"> - <MkInput v-model="after" type="number" class="input"> + <MkInput v-model="after" small type="number" class="input"> <template #label>{{ $ts._poll.duration }}</template> </MkInput> - <MkSelect v-model="unit"> + <MkSelect v-model="unit" small> <option value="second">{{ $ts._time.second }}</option> <option value="minute">{{ $ts._time.minute }}</option> <option value="hour">{{ $ts._time.hour }}</option> @@ -49,12 +49,12 @@ <script lang="ts" setup> import { ref, watch } from 'vue'; -import { addTime } from '@/scripts/time'; -import { formatDateTimeString } from '@/scripts/format-time-string'; import MkInput from './form/input.vue'; import MkSelect from './form/select.vue'; import MkSwitch from './form/switch.vue'; import MkButton from './ui/button.vue'; +import { formatDateTimeString } from '@/scripts/format-time-string'; +import { addTime } from '@/scripts/time'; const props = defineProps<{ modelValue: { @@ -116,8 +116,11 @@ function get() { let base = parseInt(after.value); switch (unit.value) { case 'day': base *= 24; + // fallthrough case 'hour': base *= 60; + // fallthrough case 'minute': base *= 60; + // fallthrough case 'second': return base *= 1000; default: return null; } @@ -129,7 +132,7 @@ function get() { ...( expiration.value === 'at' ? { expiresAt: calcAt() } : expiration.value === 'after' ? { expiredAfter: calcAfter() } : {} - ) + ), }; } diff --git a/packages/client/src/components/poll.vue b/packages/client/src/components/poll.vue index 171b4a4770..35f87325d8 100644 --- a/packages/client/src/components/poll.vue +++ b/packages/client/src/components/poll.vue @@ -24,20 +24,22 @@ <script lang="ts"> import { computed, defineComponent, onUnmounted, ref, toRef } from 'vue'; import { sum } from '@/scripts/array'; +import { pleaseLogin } from '@/scripts/please-login'; import * as os from '@/os'; import { i18n } from '@/i18n'; +import { useInterval } from '@/scripts/use-interval'; export default defineComponent({ props: { note: { type: Object, - required: true + required: true, }, readOnly: { type: Boolean, required: false, default: false, - } + }, }, setup(props) { @@ -53,7 +55,7 @@ export default defineComponent({ s: Math.floor(remaining.value % 60), m: Math.floor(remaining.value / 60) % 60, h: Math.floor(remaining.value / 3600) % 24, - d: Math.floor(remaining.value / 86400) + d: Math.floor(remaining.value / 86400), })); const showResult = ref(props.readOnly || isVoted.value); @@ -67,14 +69,15 @@ export default defineComponent({ } }; - tick(); - const intevalId = window.setInterval(tick, 3000); - onUnmounted(() => { - window.clearInterval(intevalId); + useInterval(tick, 3000, { + immediate: true, + afterMounted: false, }); } const vote = async (id) => { + pleaseLogin(); + if (props.readOnly || closed.value || isVoted.value) return; const { canceled } = await os.confirm({ diff --git a/packages/client/src/components/post-form-attaches.vue b/packages/client/src/components/post-form-attaches.vue index 6b9827407b..98bf2df09a 100644 --- a/packages/client/src/components/post-form-attaches.vue +++ b/packages/client/src/components/post-form-attaches.vue @@ -2,7 +2,7 @@ <div v-show="files.length != 0" class="skeikyzd"> <XDraggable v-model="_files" class="files" item-key="id" animation="150" delay="100" delay-on-touch-only="true"> <template #item="{element}"> - <div @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> + <div class="file" @click="showFileMenu(element, $event)" @contextmenu.prevent="showFileMenu(element, $event)"> <MkDriveFileThumbnail :data-id="element.id" class="thumbnail" :file="element" fit="cover"/> <div v-if="element.isSensitive" class="sensitive"> <i class="fas fa-exclamation-triangle icon"></i> @@ -22,18 +22,18 @@ import * as os from '@/os'; export default defineComponent({ components: { XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), - MkDriveFileThumbnail + MkDriveFileThumbnail, }, props: { files: { type: Array, - required: true + required: true, }, detachMediaFn: { type: Function, - required: false - } + required: false, + }, }, emits: ['updated', 'detach', 'changeSensitive', 'changeName'], @@ -51,8 +51,8 @@ export default defineComponent({ }, set(value) { this.$emit('updated', value); - } - } + }, + }, }, methods: { @@ -66,7 +66,7 @@ export default defineComponent({ toggleSensitive(file) { os.api('drive/files/update', { fileId: file.id, - isSensitive: !file.isSensitive + isSensitive: !file.isSensitive, }).then(() => { this.$emit('changeSensitive', file, !file.isSensitive); }); @@ -75,12 +75,12 @@ export default defineComponent({ const { canceled, result } = await os.inputText({ title: this.$ts.enterFileName, default: file.name, - allowEmpty: false + allowEmpty: false, }); if (canceled) return; os.api('drive/files/update', { fileId: file.id, - name: result + name: result, }).then(() => { this.$emit('changeName', file, result); file.name = result; @@ -88,13 +88,13 @@ export default defineComponent({ }, async describe(file) { - os.popup(defineAsyncComponent(() => import("@/components/media-caption.vue")), { + os.popup(defineAsyncComponent(() => import('@/components/media-caption.vue')), { title: this.$ts.describeFile, input: { placeholder: this.$ts.inputNewDescription, - default: file.comment !== null ? file.comment : "", + default: file.comment !== null ? file.comment : '', }, - image: file + image: file, }, { done: result => { if (!result || result.canceled) return; @@ -105,7 +105,7 @@ export default defineComponent({ }).then(() => { file.comment = comment; }); - } + }, }, 'closed'); }, @@ -114,22 +114,22 @@ export default defineComponent({ this.menu = os.popupMenu([{ text: this.$ts.renameFile, icon: 'fas fa-i-cursor', - action: () => { this.rename(file); } + action: () => { this.rename(file); }, }, { text: file.isSensitive ? this.$ts.unmarkAsSensitive : this.$ts.markAsSensitive, icon: file.isSensitive ? 'fas fa-eye-slash' : 'fas fa-eye', - action: () => { this.toggleSensitive(file); } + action: () => { this.toggleSensitive(file); }, }, { text: this.$ts.describeFile, icon: 'fas fa-i-cursor', - action: () => { this.describe(file); } + action: () => { this.describe(file); }, }, { text: this.$ts.attachCancel, icon: 'fas fa-times-circle', - action: () => { this.detachMedia(file.id); } + action: () => { this.detachMedia(file.id); }, }], ev.currentTarget ?? ev.target).then(() => this.menu = null); - } - } + }, + }, }); </script> @@ -142,7 +142,7 @@ export default defineComponent({ display: flex; flex-wrap: wrap; - > div { + > .file { position: relative; width: 64px; height: 64px; diff --git a/packages/client/src/components/post-form.vue b/packages/client/src/components/post-form.vue index 0197313e0e..77fcd79c13 100644 --- a/packages/client/src/components/post-form.vue +++ b/packages/client/src/components/post-form.vue @@ -1,5 +1,6 @@ <template> -<div v-size="{ max: [310, 500] }" class="gafaadew" +<div + v-size="{ max: [310, 500] }" class="gafaadew" :class="{ modal, _popup: modal }" @dragover.stop="onDragover" @dragenter="onDragenter" @@ -11,7 +12,7 @@ <button v-click-anime v-tooltip="i18n.ts.switchAccount" class="account _button" @click="openAccountMenu"> <MkAvatar :user="postAccount ?? $i" class="avatar"/> </button> - <div> + <div class="right"> <span class="text-count" :class="{ over: textLength > maxTextLength }">{{ maxTextLength - textLength }}</span> <span v-if="localOnly" class="local-only"><i class="fas fa-biohazard"></i></span> <button ref="visibilityButton" v-tooltip="i18n.ts.visibility" class="_button visibility" :disabled="channel != null" @click="setVisibility"> @@ -68,6 +69,8 @@ import * as misskey from 'misskey-js'; import insertTextAtCursor from 'insert-text-at-cursor'; import { length } from 'stringz'; import { toASCII } from 'punycode/'; +import * as Acct from 'misskey-js/built/acct'; +import { throttle } from 'throttle-debounce'; import XNoteSimple from './note-simple.vue'; import XNotePreview from './note-preview.vue'; import XPostFormAttaches from './post-form-attaches.vue'; @@ -75,14 +78,12 @@ import XPollEditor from './poll-editor.vue'; import { host, url } from '@/config'; import { erase, unique } from '@/scripts/array'; import { extractMentions } from '@/scripts/extract-mentions'; -import * as Acct from 'misskey-js/built/acct'; import { formatTimeString } from '@/scripts/format-time-string'; import { Autocomplete } from '@/scripts/autocomplete'; import * as os from '@/os'; import { stream } from '@/stream'; import { selectFiles } from '@/scripts/select-file'; import { defaultStore, notePostInterruptors, postFormActions } from '@/store'; -import { throttle } from 'throttle-debounce'; import MkInfo from '@/components/ui/info.vue'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; @@ -181,7 +182,7 @@ const placeholder = $computed((): string => { i18n.ts._postForm._placeholders.c, i18n.ts._postForm._placeholders.d, i18n.ts._postForm._placeholders.e, - i18n.ts._postForm._placeholders.f + i18n.ts._postForm._placeholders.f, ]; return xs[Math.floor(Math.random() * xs.length)]; } @@ -238,10 +239,10 @@ if (props.reply && props.reply.text != null) { for (const x of extractMentions(ast)) { const mention = x.host ? - `@${x.username}@${toASCII(x.host)}` : - (otherHost == null || otherHost === host) ? - `@${x.username}` : - `@${x.username}@${toASCII(otherHost)}`; + `@${x.username}@${toASCII(x.host)}` : + (otherHost == null || otherHost === host) ? + `@${x.username}` : + `@${x.username}@${toASCII(otherHost)}`; // 自分は除外 if ($i.username === x.username && (x.host == null || x.host === host)) continue; @@ -263,7 +264,7 @@ if (props.reply && ['home', 'followers', 'specified'].includes(props.reply.visib visibility = props.reply.visibility; if (props.reply.visibility === 'specified') { os.api('users/show', { - userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId) + userIds: props.reply.visibleUserIds.filter(uid => uid !== $i.id && uid !== props.reply.userId), }).then(users => { users.forEach(pushVisibleUser); }); @@ -399,7 +400,7 @@ function setVisibility() { if (defaultStore.state.rememberNoteVisibility) { defaultStore.set('localOnly', localOnly); } - } + }, }, 'closed'); } @@ -522,8 +523,8 @@ function saveDraft() { visibility: visibility, localOnly: localOnly, files: files, - poll: poll - } + poll: poll, + }, }; localStorage.setItem('drafts', JSON.stringify(draftData)); @@ -612,11 +613,11 @@ function showActions(ev) { text: action.title, action: () => { action.handler({ - text: text + text: text, }, (key, value) => { if (key === 'text') { text = value; } }); - } + }, })), ev.currentTarget ?? ev.target); } @@ -726,7 +727,7 @@ onMounted(() => { } } - > div { + > .right { position: absolute; top: 0; right: 0; @@ -924,7 +925,7 @@ onMounted(() => { line-height: 50px; } - > div { + > .right { > .text-count { line-height: 50px; } diff --git a/packages/client/src/components/queue-chart.vue b/packages/client/src/components/queue-chart.vue deleted file mode 100644 index 7bb548cf06..0000000000 --- a/packages/client/src/components/queue-chart.vue +++ /dev/null @@ -1,232 +0,0 @@ -<template> -<canvas ref="chartEl"></canvas> -</template> - -<script lang="ts"> -import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; -import { - Chart, - ArcElement, - LineElement, - BarElement, - PointElement, - BarController, - LineController, - CategoryScale, - LinearScale, - TimeScale, - Legend, - Title, - Tooltip, - SubTitle, - Filler, -} from 'chart.js'; -import number from '@/filters/number'; -import * as os from '@/os'; -import { defaultStore } from '@/store'; - -Chart.register( - ArcElement, - LineElement, - BarElement, - PointElement, - BarController, - LineController, - CategoryScale, - LinearScale, - TimeScale, - Legend, - Title, - Tooltip, - SubTitle, - Filler, -); - -const alpha = (hex, a) => { - const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; - const r = parseInt(result[1], 16); - const g = parseInt(result[2], 16); - const b = parseInt(result[3], 16); - return `rgba(${r}, ${g}, ${b}, ${a})`; -}; - -export default defineComponent({ - props: { - domain: { - type: String, - required: true, - }, - connection: { - required: true, - }, - }, - - setup(props) { - const chartEl = ref<HTMLCanvasElement>(null); - - const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; - - // フォントカラー - Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); - - onMounted(() => { - const chartInstance = new Chart(chartEl.value, { - type: 'line', - data: { - labels: [], - datasets: [{ - label: 'Process', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#00E396', - backgroundColor: alpha('#00E396', 0.1), - data: [] - }, { - label: 'Active', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#00BCD4', - backgroundColor: alpha('#00BCD4', 0.1), - data: [] - }, { - label: 'Waiting', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#FFB300', - backgroundColor: alpha('#FFB300', 0.1), - yAxisID: 'y2', - data: [] - }, { - label: 'Delayed', - pointRadius: 0, - tension: 0, - borderWidth: 2, - borderJoinStyle: 'round', - borderColor: '#E53935', - borderDash: [5, 5], - fill: false, - yAxisID: 'y2', - data: [] - }], - }, - options: { - aspectRatio: 2.5, - layout: { - padding: { - left: 16, - right: 16, - top: 16, - bottom: 8, - }, - }, - scales: { - x: { - grid: { - display: true, - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', - }, - ticks: { - display: false, - maxTicksLimit: 10 - }, - }, - y: { - min: 0, - stack: 'queue', - stackWeight: 2, - grid: { - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', - }, - }, - y2: { - min: 0, - offset: true, - stack: 'queue', - stackWeight: 1, - grid: { - color: gridColor, - borderColor: 'rgb(0, 0, 0, 0)', - }, - }, - }, - interaction: { - intersect: false, - }, - plugins: { - legend: { - position: 'bottom', - labels: { - boxWidth: 16, - }, - }, - tooltip: { - mode: 'index', - animation: { - duration: 0, - }, - }, - }, - }, - }); - - const onStats = (stats) => { - chartInstance.data.labels.push(''); - chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); - chartInstance.data.datasets[1].data.push(stats[props.domain].active); - chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); - chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); - if (chartInstance.data.datasets[0].data.length > 200) { - chartInstance.data.labels.shift(); - chartInstance.data.datasets[0].data.shift(); - chartInstance.data.datasets[1].data.shift(); - chartInstance.data.datasets[2].data.shift(); - chartInstance.data.datasets[3].data.shift(); - } - chartInstance.update(); - }; - - const onStatsLog = (statsLog) => { - for (const stats of [...statsLog].reverse()) { - chartInstance.data.labels.push(''); - chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); - chartInstance.data.datasets[1].data.push(stats[props.domain].active); - chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); - chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); - if (chartInstance.data.datasets[0].data.length > 200) { - chartInstance.data.labels.shift(); - chartInstance.data.datasets[0].data.shift(); - chartInstance.data.datasets[1].data.shift(); - chartInstance.data.datasets[2].data.shift(); - chartInstance.data.datasets[3].data.shift(); - } - } - chartInstance.update(); - }; - - props.connection.on('stats', onStats); - props.connection.on('statsLog', onStatsLog); - - onUnmounted(() => { - props.connection.off('stats', onStats); - props.connection.off('statsLog', onStatsLog); - }); - }); - - return { - chartEl, - }; - }, -}); -</script> - -<style lang="scss" scoped> - -</style> diff --git a/packages/client/src/components/reactions-viewer.reaction.vue b/packages/client/src/components/reactions-viewer.reaction.vue index 91a90a6996..c29bd46400 100644 --- a/packages/client/src/components/reactions-viewer.reaction.vue +++ b/packages/client/src/components/reactions-viewer.reaction.vue @@ -12,106 +12,82 @@ </button> </template> -<script lang="ts"> -import { computed, defineComponent, onMounted, ref, watch } from 'vue'; +<script lang="ts" setup> +import { computed, onMounted, ref, watch } from 'vue'; +import * as misskey from 'misskey-js'; import XDetails from '@/components/reactions-viewer.details.vue'; import XReactionIcon from '@/components/reaction-icon.vue'; import * as os from '@/os'; import { useTooltip } from '@/scripts/use-tooltip'; import { $i } from '@/account'; -export default defineComponent({ - components: { - XReactionIcon - }, +const props = defineProps<{ + reaction: string; + count: number; + isInitial: boolean; + note: misskey.entities.Note; +}>(); - props: { - reaction: { - type: String, - required: true, - }, - count: { - type: Number, - required: true, - }, - isInitial: { - type: Boolean, - required: true, - }, - note: { - type: Object, - required: true, - }, - }, +const buttonRef = ref<HTMLElement>(); - setup(props) { - const buttonRef = ref<HTMLElement>(); +const canToggle = computed(() => !props.reaction.match(/@\w/) && $i); - const canToggle = computed(() => !props.reaction.match(/@\w/) && $i); +const toggleReaction = () => { + if (!canToggle.value) return; - const toggleReaction = () => { - if (!canToggle.value) return; - - const oldReaction = props.note.myReaction; - if (oldReaction) { - os.api('notes/reactions/delete', { - noteId: props.note.id - }).then(() => { - if (oldReaction !== props.reaction) { - os.api('notes/reactions/create', { - noteId: props.note.id, - reaction: props.reaction - }); - } - }); - } else { + const oldReaction = props.note.myReaction; + if (oldReaction) { + os.api('notes/reactions/delete', { + noteId: props.note.id, + }).then(() => { + if (oldReaction !== props.reaction) { os.api('notes/reactions/create', { noteId: props.note.id, - reaction: props.reaction + reaction: props.reaction, }); } - }; - - const anime = () => { - if (document.hidden) return; - - // TODO: 新しくリアクションが付いたことが視覚的に分かりやすいアニメーション - }; - - watch(() => props.count, (newCount, oldCount) => { - if (oldCount < newCount) anime(); }); - - onMounted(() => { - if (!props.isInitial) anime(); + } else { + os.api('notes/reactions/create', { + noteId: props.note.id, + reaction: props.reaction, }); + } +}; - useTooltip(buttonRef, async (showing) => { - const reactions = await os.api('notes/reactions', { - noteId: props.note.id, - type: props.reaction, - limit: 11 - }); +const anime = () => { + if (document.hidden) return; - const users = reactions.map(x => x.user); + // TODO: 新しくリアクションが付いたことが視覚的に分かりやすいアニメーション +}; - os.popup(XDetails, { - showing, - reaction: props.reaction, - emojis: props.note.emojis, - users, - count: props.count, - targetElement: buttonRef.value, - }, {}, 'closed'); - }); +watch(() => props.count, (newCount, oldCount) => { + if (oldCount < newCount) anime(); +}); - return { - buttonRef, - canToggle, - toggleReaction, - }; - }, +onMounted(() => { + if (!props.isInitial) anime(); }); + +useTooltip(buttonRef, async (showing) => { + const reactions = await os.apiGet('notes/reactions', { + noteId: props.note.id, + type: props.reaction, + limit: 11, + _cacheKey_: props.count, + }); + + const users = reactions.map(x => x.user); + + os.popup(XDetails, { + showing, + reaction: props.reaction, + emojis: props.note.emojis, + users, + count: props.count, + targetElement: buttonRef.value, + }, {}, 'closed'); +}, 100); </script> <style lang="scss" scoped> diff --git a/packages/client/src/components/remote-caution.vue b/packages/client/src/components/remote-caution.vue index aa623f0fb0..130a0249b6 100644 --- a/packages/client/src/components/remote-caution.vue +++ b/packages/client/src/components/remote-caution.vue @@ -1,5 +1,5 @@ <template> -<div class="jmgmzlwq _block"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i>{{ $ts.remoteUserCaution }}<a :href="href" rel="nofollow noopener" target="_blank">{{ $ts.showOnRemote }}</a></div> +<div class="jmgmzlwq _block"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i>{{ $ts.remoteUserCaution }}<a class="link" :href="href" rel="nofollow noopener" target="_blank">{{ $ts.showOnRemote }}</a></div> </template> <script lang="ts" setup> @@ -15,7 +15,7 @@ defineProps<{ background: var(--infoWarnBg); color: var(--infoWarnFg); - > a { + > .link { margin-left: 4px; color: var(--accent); } diff --git a/packages/client/src/components/renote-button.vue b/packages/client/src/components/renote-button.vue index 8d9f08b4c2..3bcbe665bf 100644 --- a/packages/client/src/components/renote-button.vue +++ b/packages/client/src/components/renote-button.vue @@ -14,7 +14,7 @@ <script lang="ts"> import { computed, defineComponent, ref } from 'vue'; -import XDetails from '@/components/renote.details.vue'; +import XDetails from '@/components/users-tooltip.vue'; import { pleaseLogin } from '@/scripts/please-login'; import * as os from '@/os'; import { $i } from '@/account'; diff --git a/packages/client/src/components/signin.vue b/packages/client/src/components/signin.vue index b772d1479b..dacc610165 100644 --- a/packages/client/src/components/signin.vue +++ b/packages/client/src/components/signin.vue @@ -6,7 +6,7 @@ {{ message }} </MkInfo> <div v-if="!totpLogin" class="normal-signin"> - <MkInput v-model="username" class="_formBlock" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" spellcheck="false" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange"> + <MkInput v-model="username" class="_formBlock" :placeholder="i18n.ts.username" type="text" pattern="^[a-zA-Z0-9_]+$" :spellcheck="false" autofocus required data-cy-signin-username @update:modelValue="onUsernameChange"> <template #prefix>@</template> <template #suffix>@{{ host }}</template> </MkInput> @@ -32,7 +32,7 @@ <template #label>{{ i18n.ts.password }}</template> <template #prefix><i class="fas fa-lock"></i></template> </MkInput> - <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false" required> + <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false" required> <template #label>{{ i18n.ts.token }}</template> <template #prefix><i class="fas fa-gavel"></i></template> </MkInput> diff --git a/packages/client/src/components/signup.vue b/packages/client/src/components/signup.vue index 3f2af306e5..dd4a2b18b8 100644 --- a/packages/client/src/components/signup.vue +++ b/packages/client/src/components/signup.vue @@ -1,11 +1,11 @@ <template> <form class="qlvuhzng _formRoot" autocomplete="new-password" @submit.prevent="onSubmit"> <template v-if="meta"> - <MkInput v-if="meta.disableRegistration" v-model="invitationCode" class="_formBlock" type="text" spellcheck="false" required> + <MkInput v-if="meta.disableRegistration" v-model="invitationCode" class="_formBlock" type="text" :spellcheck="false" required> <template #label>{{ $ts.invitationCode }}</template> <template #prefix><i class="fas fa-key"></i></template> </MkInput> - <MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" spellcheck="false" required data-cy-signup-username @update:modelValue="onChangeUsername"> + <MkInput v-model="username" class="_formBlock" type="text" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-signup-username @update:modelValue="onChangeUsername"> <template #label>{{ $ts.username }} <div v-tooltip:dialog="$ts.usernameInfo" class="_button _help"><i class="far fa-question-circle"></i></div></template> <template #prefix>@</template> <template #suffix>@{{ host }}</template> @@ -19,7 +19,7 @@ <span v-else-if="usernameState === 'max-range'" style="color: var(--error)"><i class="fas fa-exclamation-triangle fa-fw"></i> {{ $ts.tooLong }}</span> </template> </MkInput> - <MkInput v-if="meta.emailRequiredForSignup" v-model="email" class="_formBlock" :debounce="true" type="email" spellcheck="false" required data-cy-signup-email @update:modelValue="onChangeEmail"> + <MkInput v-if="meta.emailRequiredForSignup" v-model="email" class="_formBlock" :debounce="true" type="email" :spellcheck="false" required data-cy-signup-email @update:modelValue="onChangeEmail"> <template #label>{{ $ts.emailAddress }} <div v-tooltip:dialog="$ts._signup.emailAddressInfo" class="_button _help"><i class="far fa-question-circle"></i></div></template> <template #prefix><i class="fas fa-envelope"></i></template> <template #caption> @@ -67,12 +67,12 @@ <script lang="ts"> import { defineComponent, defineAsyncComponent } from 'vue'; -const getPasswordStrength = await import('syuilo-password-strength'); +import getPasswordStrength from 'syuilo-password-strength'; import { toUnicode } from 'punycode/'; -import { host, url } from '@/config'; import MkButton from './ui/button.vue'; import MkInput from './form/input.vue'; import MkSwitch from './form/switch.vue'; +import { host, url } from '@/config'; import * as os from '@/os'; import { login } from '@/account'; @@ -89,7 +89,7 @@ export default defineComponent({ type: Boolean, required: false, default: false, - } + }, }, emits: ['signup'], @@ -132,7 +132,7 @@ export default defineComponent({ this.usernameState !== 'invalid-format' && this.usernameState !== 'min-range' && this.usernameState !== 'max-range'); - } + }, }, methods: { @@ -156,7 +156,7 @@ export default defineComponent({ this.usernameState = 'wait'; os.api('username/available', { - username: this.username + username: this.username, }).then(result => { this.usernameState = result.available ? 'ok' : 'unavailable'; }).catch(err => { @@ -173,7 +173,7 @@ export default defineComponent({ this.emailState = 'wait'; os.api('email-address/available', { - emailAddress: this.email + emailAddress: this.email, }).then(result => { this.emailState = result.available ? 'ok' : result.reason === 'used' ? 'unavailable:used' : @@ -228,7 +228,7 @@ export default defineComponent({ } else { os.api('signin', { username: this.username, - password: this.password + password: this.password, }).then(res => { this.$emit('signup', res); @@ -244,11 +244,11 @@ export default defineComponent({ os.alert({ type: 'error', - text: this.$ts.somethingHappened + text: this.$ts.somethingHappened, }); }); - } - } + }, + }, }); </script> diff --git a/packages/client/src/components/sparkle.vue b/packages/client/src/components/sparkle.vue index f52e5a3f9b..b52dbe31c4 100644 --- a/packages/client/src/components/sparkle.vue +++ b/packages/client/src/components/sparkle.vue @@ -33,7 +33,8 @@ </svg> --> <svg v-for="particle in particles" :key="particle.id" :width="width" :height="height" :viewBox="`0 0 ${width} ${height}`" xmlns="http://www.w3.org/2000/svg"> - <path style="transform-origin: center; transform-box: fill-box;" + <path + style="transform-origin: center; transform-box: fill-box;" :transform="`translate(${particle.x} ${particle.y})`" :fill="particle.color" d="M29.427,2.011C29.721,0.83 30.782,0 32,0C33.218,0 34.279,0.83 34.573,2.011L39.455,21.646C39.629,22.347 39.991,22.987 40.502,23.498C41.013,24.009 41.653,24.371 42.354,24.545L61.989,29.427C63.17,29.721 64,30.782 64,32C64,33.218 63.17,34.279 61.989,34.573L42.354,39.455C41.653,39.629 41.013,39.991 40.502,40.502C39.991,41.013 39.629,41.653 39.455,42.354L34.573,61.989C34.279,63.17 33.218,64 32,64C30.782,64 29.721,63.17 29.427,61.989L24.545,42.354C24.371,41.653 24.009,41.013 23.498,40.502C22.987,39.991 22.347,39.629 21.646,39.455L2.011,34.573C0.83,34.279 0,33.218 0,32C0,30.782 0.83,29.721 2.011,29.427L21.646,24.545C22.347,24.371 22.987,24.009 23.498,23.498C24.009,22.987 24.371,22.347 24.545,21.646L29.427,2.011Z" @@ -73,14 +74,15 @@ export default defineComponent({ const width = ref(0); const height = ref(0); const colors = ['#FF1493', '#00FFFF', '#FFE202', '#FFE202', '#FFE202']; + let stop = false; + let ro: ResizeObserver | undefined; onMounted(() => { - const ro = new ResizeObserver((entries, observer) => { + ro = new ResizeObserver((entries, observer) => { width.value = el.value?.offsetWidth + 64; height.value = el.value?.offsetHeight + 64; }); ro.observe(el.value); - let stop = false; const add = () => { if (stop) return; const x = (Math.random() * (width.value - 64)); @@ -104,10 +106,11 @@ export default defineComponent({ }, 500 + (Math.random() * 500)); }; add(); - onUnmounted(() => { - ro.disconnect(); - stop = true; - }); + }); + + onUnmounted(() => { + if (ro) ro.disconnect(); + stop = true; }); return { diff --git a/packages/client/src/components/tag-cloud.vue b/packages/client/src/components/tag-cloud.vue new file mode 100644 index 0000000000..9f3bc1c603 --- /dev/null +++ b/packages/client/src/components/tag-cloud.vue @@ -0,0 +1,88 @@ +<template> +<div ref="rootEl" class="meijqfqm"> + <canvas :id="idForCanvas" ref="canvasEl" class="canvas" :width="width" height="300" @contextmenu.prevent="() => {}"></canvas> + <div :id="idForTags" ref="tagsEl" class="tags"> + <ul> + <slot></slot> + </ul> + </div> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, ref, watch, PropType, onBeforeUnmount } from 'vue'; +import tinycolor from 'tinycolor2'; + +const loaded = !!window.TagCanvas; +const SAFE_FOR_HTML_ID = 'abcdefghijklmnopqrstuvwxyz'; +const computedStyle = getComputedStyle(document.documentElement); +const idForCanvas = Array.from(Array(16)).map(() => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join(''); +const idForTags = Array.from(Array(16)).map(() => SAFE_FOR_HTML_ID[Math.floor(Math.random() * SAFE_FOR_HTML_ID.length)]).join(''); +let available = $ref(false); +let rootEl = $ref<HTMLElement | null>(null); +let canvasEl = $ref<HTMLCanvasElement | null>(null); +let tagsEl = $ref<HTMLElement | null>(null); +let width = $ref(300); + +watch($$(available), () => { + window.TagCanvas.Start(idForCanvas, idForTags, { + textColour: '#ffffff', + outlineColour: tinycolor(computedStyle.getPropertyValue('--accent')).toHexString(), + outlineRadius: 10, + initial: [-0.030, -0.010], + frontSelect: true, + imageRadius: 8, + //dragControl: true, + dragThreshold: 3, + wheelZoom: false, + reverse: true, + depth: 0.5, + maxSpeed: 0.2, + minSpeed: 0.003, + stretchX: 0.8, + stretchY: 0.8, + }); +}); + +onMounted(() => { + width = rootEl.offsetWidth; + + if (loaded) { + available = true; + } else { + document.head.appendChild(Object.assign(document.createElement('script'), { + async: true, + src: '/client-assets/tagcanvas.min.js', + })).addEventListener('load', () => available = true); + } +}); + +onBeforeUnmount(() => { + window.TagCanvas.Delete(idForCanvas); +}); + +defineExpose({ + update: () => { + window.TagCanvas.Update(idForCanvas); + }, +}); +</script> + +<style lang="scss" scoped> +.meijqfqm { + position: relative; + overflow: hidden; overflow: clip; + display: grid; + place-items: center; + + > .canvas { + display: block; + } + + > .tags { + position: absolute; + top: 999px; + left: 999px; + } +} +</style> diff --git a/packages/client/src/components/toast.vue b/packages/client/src/components/toast.vue index c9fad64eb6..e0230dccd4 100644 --- a/packages/client/src/components/toast.vue +++ b/packages/client/src/components/toast.vue @@ -54,7 +54,7 @@ onMounted(() => { width: min-content; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); border-radius: 8px; - overflow: clip; + overflow: hidden; overflow: clip; text-align: center; pointer-events: none; diff --git a/packages/client/src/components/ui/button.vue b/packages/client/src/components/ui/button.vue index e6b20d9881..d8052b511c 100644 --- a/packages/client/src/components/ui/button.vue +++ b/packages/client/src/components/ui/button.vue @@ -148,7 +148,7 @@ export default defineComponent({ text-decoration: none; background: var(--buttonBg); border-radius: 5px; - overflow: clip; + overflow: hidden; overflow: clip; box-sizing: border-box; transition: background 0.1s ease; diff --git a/packages/client/src/components/ui/container.vue b/packages/client/src/components/ui/container.vue index 7c595d8116..784414e791 100644 --- a/packages/client/src/components/ui/container.vue +++ b/packages/client/src/components/ui/container.vue @@ -10,7 +10,8 @@ </button> </div> </header> - <transition :name="$store.state.animation ? 'container-toggle' : ''" + <transition + :name="$store.state.animation ? 'container-toggle' : ''" @enter="enter" @after-enter="afterEnter" @leave="leave" @@ -34,37 +35,37 @@ export default defineComponent({ showHeader: { type: Boolean, required: false, - default: true + default: true, }, thin: { type: Boolean, required: false, - default: false + default: false, }, naked: { type: Boolean, required: false, - default: false + default: false, }, foldable: { type: Boolean, required: false, - default: false + default: false, }, expanded: { type: Boolean, required: false, - default: true + default: true, }, scrollable: { type: Boolean, required: false, - default: false + default: false, }, maxHeight: { type: Number, required: false, - default: null + default: null, }, }, data() { @@ -79,12 +80,12 @@ export default defineComponent({ const headerHeight = this.showHeader ? this.$refs.header.offsetHeight : 0; this.$el.style.minHeight = `${headerHeight}px`; if (showBody) { - this.$el.style.flexBasis = `auto`; + this.$el.style.flexBasis = 'auto'; } else { this.$el.style.flexBasis = `${headerHeight}px`; } }, { - immediate: true + immediate: true, }); this.$el.style.setProperty('--maxHeight', this.maxHeight + 'px'); @@ -124,7 +125,7 @@ export default defineComponent({ afterLeave(el) { el.style.height = null; }, - } + }, }); </script> @@ -142,7 +143,8 @@ export default defineComponent({ .ukygtjoj { position: relative; - overflow: clip; + overflow: hidden; overflow: clip; + contain: content; &.naked { background: transparent !important; diff --git a/packages/client/src/components/ui/hr.vue b/packages/client/src/components/ui/hr.vue index 6b075cb440..0cb5b48875 100644 --- a/packages/client/src/components/ui/hr.vue +++ b/packages/client/src/components/ui/hr.vue @@ -3,7 +3,8 @@ </template> <script lang="ts"> -import { defineComponent } from 'vue';import * as os from '@/os'; +import { defineComponent } from 'vue'; +import * as os from '@/os'; export default defineComponent({}); </script> diff --git a/packages/client/src/components/ui/menu.vue b/packages/client/src/components/ui/menu.vue index dad5dfa8b0..1f3d508975 100644 --- a/packages/client/src/components/ui/menu.vue +++ b/packages/client/src/components/ui/menu.vue @@ -136,11 +136,11 @@ function focusDown() { > .item { display: block; position: relative; - padding: 8px 18px; + padding: 6px 16px; width: 100%; box-sizing: border-box; white-space: nowrap; - font-size: 0.9em; + font-size: 0.85em; line-height: 20px; text-align: left; overflow: hidden; diff --git a/packages/client/src/components/ui/modal-window.vue b/packages/client/src/components/ui/modal-window.vue index d2b2ccff7a..b7faea736b 100644 --- a/packages/client/src/components/ui/modal-window.vue +++ b/packages/client/src/components/ui/modal-window.vue @@ -1,6 +1,6 @@ <template> <MkModal ref="modal" :prefer-type="'dialog'" @click="onBgClick" @closed="$emit('closed')"> - <div ref="rootEl" class="ebkgoccj _window _narrow_" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown"> + <div ref="rootEl" class="ebkgoccj _narrow_" :style="{ width: `${width}px`, height: scroll ? (height ? `${height}px` : null) : (height ? `min(${height}px, 100%)` : '100%') }" @keydown="onKeydown"> <div ref="headerEl" class="header"> <button v-if="withOkButton" class="_button" @click="$emit('close')"><i class="fas fa-times"></i></button> <span class="title"> @@ -9,12 +9,7 @@ <button v-if="!withOkButton" class="_button" @click="$emit('close')"><i class="fas fa-times"></i></button> <button v-if="withOkButton" class="_button" :disabled="okButtonDisabled" @click="$emit('ok')"><i class="fas fa-check"></i></button> </div> - <div v-if="padding" class="body"> - <div class="_section"> - <slot :width="bodyWidth" :height="bodyHeight"></slot> - </div> - </div> - <div v-else class="body"> + <div class="body"> <slot :width="bodyWidth" :height="bodyHeight"></slot> </div> </div> @@ -28,14 +23,12 @@ import MkModal from './modal.vue'; const props = withDefaults(defineProps<{ withOkButton: boolean; okButtonDisabled: boolean; - padding: boolean; width: number; height: number | null; scroll: boolean; }>(), { withOkButton: false, okButtonDisabled: false, - padding: false, width: 400, height: null, scroll: true, @@ -96,6 +89,7 @@ defineExpose({ display: flex; flex-direction: column; contain: content; + border-radius: var(--radius); --root-margin: 24px; @@ -108,7 +102,9 @@ defineExpose({ $height-narrow: 42px; display: flex; flex-shrink: 0; - box-shadow: 0px 1px var(--divider); + background: var(--windowHeader); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); > button { height: $height; @@ -143,6 +139,7 @@ defineExpose({ > .body { overflow: auto; + background: var(--panel); } } </style> diff --git a/packages/client/src/components/ui/modal.vue b/packages/client/src/components/ui/modal.vue index d6a29ec4b7..385f6cdb2b 100644 --- a/packages/client/src/components/ui/modal.vue +++ b/packages/client/src/components/ui/modal.vue @@ -389,7 +389,7 @@ defineExpose({ left: 0; width: 100%; height: 100%; - overflow: clip; + overflow: hidden; overflow: clip; > .content { position: fixed; diff --git a/packages/client/src/components/ui/pagination.vue b/packages/client/src/components/ui/pagination.vue index c081e06acd..a03c2b3a1d 100644 --- a/packages/client/src/components/ui/pagination.vue +++ b/packages/client/src/components/ui/pagination.vue @@ -133,8 +133,10 @@ const fetchMore = async (): Promise<void> => { limit: SECOND_FETCH_LIMIT + 1, ...(props.pagination.offsetMode ? { offset: offset.value, + } : props.pagination.reversed ? { + sinceId: items.value[0].id, } : { - untilId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id, + untilId: items.value[items.value.length - 1].id, }), }).then(res => { for (let i = 0; i < res.length; i++) { @@ -169,8 +171,10 @@ const fetchMoreAhead = async (): Promise<void> => { limit: SECOND_FETCH_LIMIT + 1, ...(props.pagination.offsetMode ? { offset: offset.value, + } : props.pagination.reversed ? { + untilId: items.value[0].id, } : { - sinceId: props.pagination.reversed ? items.value[0].id : items.value[items.value.length - 1].id, + sinceId: items.value[items.value.length - 1].id, }), }).then(res => { if (res.length > SECOND_FETCH_LIMIT) { diff --git a/packages/client/src/components/ui/tooltip.vue b/packages/client/src/components/ui/tooltip.vue index 571d11ba3b..152c939a1a 100644 --- a/packages/client/src/components/ui/tooltip.vue +++ b/packages/client/src/components/ui/tooltip.vue @@ -1,7 +1,10 @@ <template> <transition :name="$store.state.animation ? 'tooltip' : ''" appear @after-leave="emit('closed')"> <div v-show="showing" ref="el" class="buebdbiu _acrylic _shadow" :style="{ zIndex, maxWidth: maxWidth + 'px' }"> - <slot>{{ text }}</slot> + <slot> + <Mfm v-if="asMfm" :text="text"/> + <span v-else>{{ text }}</span> + </slot> </div> </transition> </template> @@ -16,6 +19,7 @@ const props = withDefaults(defineProps<{ x?: number; y?: number; text?: string; + asMfm?: boolean; maxWidth?: number; direction?: 'top' | 'bottom' | 'right' | 'left'; innerMargin?: number; @@ -170,8 +174,6 @@ const setPosition = () => { return { left, top, transformOrigin: 'left center' }; } } - - return null as never; }; const { left, top, transformOrigin } = calc(); diff --git a/packages/client/src/components/ui/window.vue b/packages/client/src/components/ui/window.vue index 2066cf579d..6892b1924e 100644 --- a/packages/client/src/components/ui/window.vue +++ b/packages/client/src/components/ui/window.vue @@ -1,25 +1,20 @@ <template> <transition :name="$store.state.animation ? 'window' : ''" appear @after-leave="$emit('closed')"> <div v-if="showing" class="ebkgocck"> - <div class="body _window _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown"> + <div class="body _shadow _narrow_" @mousedown="onBodyMousedown" @keydown="onKeydown"> <div class="header" :class="{ mini }" @contextmenu.prevent.stop="onContextmenu"> <span class="left"> - <slot name="headerLeft"></slot> + <button v-for="button in buttonsLeft" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button> </span> <span class="title" @mousedown.prevent="onHeaderMousedown" @touchstart.prevent="onHeaderMousedown"> <slot name="header"></slot> </span> <span class="right"> - <slot name="headerRight"></slot> - <button v-if="closeButton" class="_button" @click="close()"><i class="fas fa-times"></i></button> + <button v-for="button in buttonsRight" v-tooltip="button.title" class="button _button" :class="{ highlighted: button.highlighted }" @click="button.onClick"><i :class="button.icon"></i></button> + <button v-if="closeButton" class="button _button" @click="close()"><i class="fas fa-times"></i></button> </span> </div> - <div v-if="padding" class="body"> - <div class="_section"> - <slot></slot> - </div> - </div> - <div v-else class="body"> + <div class="body"> <slot></slot> </div> </div> @@ -46,41 +41,36 @@ const minHeight = 50; const minWidth = 250; function dragListen(fn) { - window.addEventListener('mousemove', fn); - window.addEventListener('touchmove', fn); + window.addEventListener('mousemove', fn); + window.addEventListener('touchmove', fn); window.addEventListener('mouseleave', dragClear.bind(null, fn)); - window.addEventListener('mouseup', dragClear.bind(null, fn)); - window.addEventListener('touchend', dragClear.bind(null, fn)); + window.addEventListener('mouseup', dragClear.bind(null, fn)); + window.addEventListener('touchend', dragClear.bind(null, fn)); } function dragClear(fn) { - window.removeEventListener('mousemove', fn); - window.removeEventListener('touchmove', fn); + window.removeEventListener('mousemove', fn); + window.removeEventListener('touchmove', fn); window.removeEventListener('mouseleave', dragClear); - window.removeEventListener('mouseup', dragClear); - window.removeEventListener('touchend', dragClear); + window.removeEventListener('mouseup', dragClear); + window.removeEventListener('touchend', dragClear); } export default defineComponent({ provide: { - inWindow: true + inWindow: true, }, props: { - padding: { - type: Boolean, - required: false, - default: false - }, initialWidth: { type: Number, required: false, - default: 400 + default: 400, }, initialHeight: { type: Number, required: false, - default: null + default: null, }, canResize: { type: Boolean, @@ -105,7 +95,17 @@ export default defineComponent({ contextmenu: { type: Array, required: false, - } + }, + buttonsLeft: { + type: Array, + required: false, + default: () => [], + }, + buttonsRight: { + type: Array, + required: false, + default: () => [], + }, }, emits: ['closed'], @@ -162,7 +162,10 @@ export default defineComponent({ this.top(); }, - onHeaderMousedown(evt) { + onHeaderMousedown(evt: MouseEvent) { + // 右クリックはコンテキストメニューを開こうとした可能性が高いため無視 + if (evt.button === 2) return; + const main = this.$el as any; if (!contains(main, document.activeElement)) main.focus(); @@ -356,12 +359,12 @@ export default defineComponent({ const browserHeight = window.innerHeight; const windowWidth = main.offsetWidth; const windowHeight = main.offsetHeight; - if (position.left < 0) main.style.left = 0; // 左はみ出し - if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し - if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し - if (position.top < 0) main.style.top = 0; // 上はみ出し - } - } + if (position.left < 0) main.style.left = 0; // 左はみ出し + if (position.top + windowHeight > browserHeight) main.style.top = browserHeight - windowHeight + 'px'; // 下はみ出し + if (position.left + windowWidth > browserWidth) main.style.left = browserWidth - windowWidth + 'px'; // 右はみ出し + if (position.top < 0) main.style.top = 0; // 上はみ出し + }, + }, }); </script> @@ -386,10 +389,11 @@ export default defineComponent({ flex-direction: column; contain: content; width: 100%; - height: 100%; + height: 100%; + border-radius: var(--radius); > .header { - --height: 50px; + --height: 45px; &.mini { --height: 38px; @@ -401,20 +405,33 @@ export default defineComponent({ flex-shrink: 0; user-select: none; height: var(--height); - border-bottom: solid 1px var(--divider); + background: var(--windowHeader); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + //border-bottom: solid 1px var(--divider); + font-size: 95%; + font-weight: bold; > .left, > .right { - > ::v-deep(button) { + > .button { height: var(--height); width: var(--height); &:hover { color: var(--fgHighlighted); } + + &.highlighted { + color: var(--accent); + } } } > .left { + margin-right: 16px; + } + + > .right { min-width: 16px; } @@ -432,6 +449,7 @@ export default defineComponent({ > .body { flex: 1; overflow: auto; + background: var(--panel); } } diff --git a/packages/client/src/components/url-preview.vue b/packages/client/src/components/url-preview.vue index 6c593c7b41..e15d28a382 100644 --- a/packages/client/src/components/url-preview.vue +++ b/packages/client/src/components/url-preview.vue @@ -1,14 +1,14 @@ <template> <div v-if="playerEnabled" class="player" :style="`padding: ${(player.height || 0) / (player.width || 1) * 100}% 0 0`"> <button class="disablePlayer" :title="$ts.disablePlayer" @click="playerEnabled = false"><i class="fas fa-times"></i></button> - <iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen /> + <iframe :src="player.url + (player.url.match(/\?/) ? '&autoplay=1&auto_play=1' : '?autoplay=1&auto_play=1')" :width="player.width || '100%'" :heigth="player.height || 250" frameborder="0" allow="autoplay; encrypted-media" allowfullscreen/> </div> <div v-else-if="tweetId && tweetExpanded" ref="twitter" class="twitter"> <iframe ref="tweet" scrolling="no" frameborder="no" :style="{ position: 'relative', width: '100%', height: `${tweetHeight}px` }" :src="`https://platform.twitter.com/embed/index.html?embedId=${embedId}&hideCard=false&hideThread=false&lang=en&theme=${$store.state.darkMode ? 'dark' : 'light'}&id=${tweetId}`"></iframe> </div> <div v-else v-size="{ max: [400, 350] }" class="mk-url-preview"> <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> - <component :is="self ? 'MkA' : 'a'" v-if="!fetching" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url"> + <component :is="self ? 'MkA' : 'a'" v-if="!fetching" class="link" :class="{ compact }" :[attr]="self ? url.substr(local.length) : url" rel="nofollow noopener" :target="target" :title="url"> <div v-if="thumbnail" class="thumbnail" :style="`background-image: url('${thumbnail}')`"> <button v-if="!playerEnabled && player.url" class="_button" :title="$ts.enablePlayer" @click.prevent="playerEnabled = true"><i class="fas fa-play-circle"></i></button> </div> @@ -57,7 +57,7 @@ let sitename = $ref<string | null>(null); let player = $ref({ url: null, width: null, - height: null + height: null, }); let playerEnabled = $ref(false); let tweetId = $ref<string | null>(null); @@ -143,7 +143,7 @@ onUnmounted(() => { .mk-url-preview { &.max-width_400px { - > a { + > .link { font-size: 12px; > .thumbnail { @@ -157,7 +157,7 @@ onUnmounted(() => { } &.max-width_350px { - > a { + > .link { font-size: 10px; > .thumbnail { @@ -205,7 +205,7 @@ onUnmounted(() => { } } - > a { + > .link { position: relative; display: block; font-size: 14px; diff --git a/packages/client/src/components/user-card-mini.vue b/packages/client/src/components/user-card-mini.vue new file mode 100644 index 0000000000..732adf7f5b --- /dev/null +++ b/packages/client/src/components/user-card-mini.vue @@ -0,0 +1,99 @@ +<template> +<div :class="[$style.root, { yellow: user.isSilenced, red: user.isSuspended, gray: false }]"> + <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/> + <div class="body"> + <span class="name"><MkUserName class="name" :user="user"/></span> + <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> + </div> + <MkMiniChart v-if="chartValues" class="chart" :src="chartValues"/> +</div> +</template> + +<script lang="ts" setup> +import * as misskey from 'misskey-js'; +import MkMiniChart from '@/components/mini-chart.vue'; +import * as os from '@/os'; +import { acct } from '@/filters/user'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +let chartValues = $ref<number[] | null>(null); + +os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16 + 1, span: 'day' }).then(res => { + // 今日のぶんの値はまだ途中の値であり、それも含めると大抵の場合前日よりも下降しているようなグラフになってしまうため今日は弾く + res.inc.splice(0, 1); + chartValues = res.inc; +}); +</script> + +<style lang="scss" module> +.root { + $bodyTitleHieght: 18px; + $bodyInfoHieght: 16px; + + display: flex; + align-items: center; + padding: 16px; + background: var(--panel); + border-radius: 8px; + + > :global(.avatar) { + display: block; + width: ($bodyTitleHieght + $bodyInfoHieght); + height: ($bodyTitleHieght + $bodyInfoHieght); + margin-right: 12px; + } + + > :global(.body) { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; + + > :global(.name) { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: $bodyTitleHieght; + } + + > :global(.sub) { + display: block; + width: 100%; + font-size: 95%; + opacity: 0.7; + line-height: $bodyInfoHieght; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + > :global(.chart) { + height: 30px; + } + + &:global(.yellow) { + --c: rgb(255 196 0 / 15%); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } + + &:global(.red) { + --c: rgb(255 0 0 / 15%); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } + + &:global(.gray) { + --c: var(--bg); + background-image: linear-gradient(45deg, var(--c) 16.67%, transparent 16.67%, transparent 50%, var(--c) 50%, var(--c) 66.67%, transparent 66.67%, transparent 100%); + background-size: 16px 16px; + } +} +</style> diff --git a/packages/client/src/components/user-select-dialog.vue b/packages/client/src/components/user-select-dialog.vue index b34d21af07..972d353486 100644 --- a/packages/client/src/components/user-select-dialog.vue +++ b/packages/client/src/components/user-select-dialog.vue @@ -11,7 +11,7 @@ <div class="tbhwbxda"> <div class="form"> <FormSplit :min-width="170"> - <MkInput ref="usernameEl" v-model="username" @update:modelValue="search"> + <MkInput v-model="username" :autofocus="true" @update:modelValue="search"> <template #label>{{ $ts.username }}</template> <template #prefix>@</template> </MkInput> @@ -70,15 +70,8 @@ let host = $ref(''); let users: misskey.entities.UserDetailed[] = $ref([]); let recentUsers: misskey.entities.UserDetailed[] = $ref([]); let selected: misskey.entities.UserDetailed | null = $ref(null); -let usernameEl: HTMLElement = $ref(); let dialogEl = $ref(); -const focus = () => { - if (usernameEl) { - usernameEl.focus(); - } -}; - const search = () => { if (username === '' && host === '') { users = []; @@ -112,12 +105,6 @@ const cancel = () => { }; onMounted(() => { - focus(); - - nextTick(() => { - focus(); - }); - os.api('users/show', { userIds: defaultStore.state.recentlyUsedUsers, }).then(users => { diff --git a/packages/client/src/components/renote.details.vue b/packages/client/src/components/users-tooltip.vue index 2df19bcd3f..2df19bcd3f 100644 --- a/packages/client/src/components/renote.details.vue +++ b/packages/client/src/components/users-tooltip.vue diff --git a/packages/client/src/components/visibility.vue b/packages/client/src/components/visibility.vue new file mode 100644 index 0000000000..b41c950331 --- /dev/null +++ b/packages/client/src/components/visibility.vue @@ -0,0 +1,47 @@ +<template> +<span v-if="note.visibility !== 'public'" :class="$style.visibility"> + <i v-if="note.visibility === 'home'" class="fas fa-home"></i> + <i v-else-if="note.visibility === 'followers'" class="fas fa-unlock"></i> + <i v-else-if="note.visibility === 'specified'" ref="specified" class="fas fa-envelope"></i> +</span> +<span v-if="note.localOnly" :class="$style.localOnly"><i class="fas fa-biohazard"></i></span> +</template> + +<script lang="ts" setup> +import { ref } from 'vue'; +import XDetails from '@/components/users-tooltip.vue'; +import * as os from '@/os'; +import { useTooltip } from '@/scripts/use-tooltip'; + +const props = defineProps<{ + note: { + visibility: string; + localOnly?: boolean; + visibleUserIds?: string[]; + }, +}>(); + +const specified = $ref<HTMLElement>(); + +if (props.note.visibility === 'specified') { + useTooltip($$(specified), async (showing) => { + const users = await os.api('users/show', { + userIds: props.note.visibleUserIds, + limit: 10, + }); + + os.popup(XDetails, { + showing, + users, + count: props.note.visibleUserIds.length, + targetElement: specified, + }, {}, 'closed'); + }); +} +</script> + +<style lang="scss" module> +.visibility, .localOnly { + margin-left: 0.5em; +} +</style> diff --git a/packages/client/src/components/widgets.vue b/packages/client/src/components/widgets.vue index 74dd79f733..85b8ae0ed3 100644 --- a/packages/client/src/components/widgets.vue +++ b/packages/client/src/components/widgets.vue @@ -19,7 +19,9 @@ <div class="customize-container"> <button class="config _button" @click.prevent.stop="configWidget(element.id)"><i class="fas fa-cog"></i></button> <button class="remove _button" @click.prevent.stop="removeWidget(element)"><i class="fas fa-times"></i></button> - <component :is="`mkw-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="handle" :widget="element" @updateProps="updateWidget(element.id, $event)"/> + <div class="handle"> + <component :is="`mkw-${element.name}`" :ref="el => widgetRefs[element.id] = el" class="widget" :widget="element" @updateProps="updateWidget(element.id, $event)"/> + </div> </div> </template> </XDraggable> @@ -111,6 +113,7 @@ export default defineComponent({ } > .widget, .customize-container { + contain: content; margin: var(--margin) 0; &:first-of-type { @@ -141,6 +144,12 @@ export default defineComponent({ > .remove { right: 8px; } + + > .handle { + > .widget { + pointer-events: none; + } + } } } </style> diff --git a/packages/client/src/directives/get-size.ts b/packages/client/src/directives/get-size.ts index 2c4e9c188d..76b54ea4b0 100644 --- a/packages/client/src/directives/get-size.ts +++ b/packages/client/src/directives/get-size.ts @@ -34,7 +34,6 @@ function calc(src: Element) { export default { mounted(src, binding, vn) { - const resize = new ResizeObserver((entries, observer) => { calc(src); }); diff --git a/packages/client/src/directives/sticky-container.ts b/packages/client/src/directives/sticky-container.ts index 9610eba4da..3cf813054b 100644 --- a/packages/client/src/directives/sticky-container.ts +++ b/packages/client/src/directives/sticky-container.ts @@ -5,8 +5,10 @@ export default { //const query = binding.value; const header = src.children[0]; + const body = src.children[1]; const currentStickyTop = getComputedStyle(src).getPropertyValue('--stickyTop') || '0px'; src.style.setProperty('--stickyTop', `calc(${currentStickyTop} + ${header.offsetHeight}px)`); + if (body) body.dataset.stickyContainerHeaderHeight = header.offsetHeight.toString(); header.style.setProperty('--stickyTop', currentStickyTop); header.style.position = 'sticky'; header.style.top = 'var(--stickyTop)'; diff --git a/packages/client/src/directives/tooltip.ts b/packages/client/src/directives/tooltip.ts index 0e69da954e..e1b85291bd 100644 --- a/packages/client/src/directives/tooltip.ts +++ b/packages/client/src/directives/tooltip.ts @@ -48,6 +48,8 @@ export default { popup(defineAsyncComponent(() => import('@/components/ui/tooltip.vue')), { showing, text: self.text, + asMfm: binding.modifiers.mfm, + direction: binding.modifiers.left ? 'left' : binding.modifiers.right ? 'right' : binding.modifiers.top ? 'top' : binding.modifiers.bottom ? 'bottom' : 'top', targetElement: el, }, {}, 'closed'); diff --git a/packages/client/src/init.ts b/packages/client/src/init.ts index bb6176e409..98f69c701f 100644 --- a/packages/client/src/init.ts +++ b/packages/client/src/init.ts @@ -21,7 +21,6 @@ import widgets from '@/widgets'; import directives from '@/directives'; import components from '@/components'; import { version, ui, lang, host } from '@/config'; -import { router } from '@/router'; import { applyTheme } from '@/scripts/theme'; import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; import { i18n } from '@/i18n'; @@ -170,11 +169,10 @@ fetchInstanceMetaPromise.then(() => { const app = createApp( window.location.search === '?zen' ? defineAsyncComponent(() => import('@/ui/zen.vue')) : - !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : - ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) : - ui === 'desktop' ? defineAsyncComponent(() => import('@/ui/desktop.vue')) : - ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) : - defineAsyncComponent(() => import('@/ui/universal.vue')) + !$i ? defineAsyncComponent(() => import('@/ui/visitor.vue')) : + ui === 'deck' ? defineAsyncComponent(() => import('@/ui/deck.vue')) : + ui === 'classic' ? defineAsyncComponent(() => import('@/ui/classic.vue')) : + defineAsyncComponent(() => import('@/ui/universal.vue')), ); if (_DEV_) { @@ -189,14 +187,10 @@ app.config.globalProperties = { $ts: i18n.ts, }; -app.use(router); - widgets(app); directives(app); components(app); -await router.isReady(); - const splash = document.getElementById('splash'); // 念のためnullチェック(HTMLが古い場合があるため(そのうち消す)) if (splash) splash.addEventListener('transitionend', () => { @@ -293,16 +287,6 @@ fetchInstanceMetaPromise.then(() => { } }); -// shortcut -document.addEventListener('keydown', makeHotkey({ - 'd': () => { - defaultStore.set('darkMode', !defaultStore.state.darkMode); - }, - 'p|n': post, - 's': search, - //TODO: 'h|/': help -})); - watch(defaultStore.reactiveState.useBlurEffectForModal, v => { document.documentElement.style.setProperty('--modalBgFilter', v ? 'blur(4px)' : 'none'); }, { immediate: true }); @@ -345,7 +329,17 @@ for (const plugin of ColdDeviceStorage.get('plugins').filter(p => p.active)) { }); } +const hotkeys = { + 'd': (): void => { + defaultStore.set('darkMode', !defaultStore.state.darkMode); + }, + 's': search, +}; + if ($i) { + // only add post shortcuts if logged in + hotkeys['p|n'] = post; + if ($i.isDeleted) { alert({ type: 'warning', @@ -440,3 +434,6 @@ if ($i) { signout(); }); } + +// shortcut +document.addEventListener('keydown', makeHotkey(hotkeys)); diff --git a/packages/client/src/menu.ts b/packages/client/src/menu.ts index ebc7898101..31b2ed597c 100644 --- a/packages/client/src/menu.ts +++ b/packages/client/src/menu.ts @@ -1,11 +1,10 @@ import { computed, ref, reactive } from 'vue'; +import { $i } from './account'; import { search } from '@/scripts/search'; import * as os from '@/os'; import { i18n } from '@/i18n'; import { ui } from '@/config'; -import { $i } from './account'; import { unisonReload } from '@/scripts/unison-reload'; -import { router } from './router'; export const menuDef = reactive({ notifications: { @@ -35,11 +34,6 @@ export const menuDef = reactive({ indicated: computed(() => $i != null && $i.hasPendingReceivedFollowRequest), to: '/my/follow-requests', }, - featured: { - title: 'featured', - icon: 'fas fa-fire-alt', - to: '/featured', - }, explore: { title: 'explore', icon: 'fas fa-hashtag', @@ -60,71 +54,21 @@ export const menuDef = reactive({ title: 'lists', icon: 'fas fa-list-ul', show: computed(() => $i != null), - active: computed(() => router.currentRoute.value.path.startsWith('/timeline/list/') || router.currentRoute.value.path === '/my/lists' || router.currentRoute.value.path.startsWith('/my/lists/')), - action: (ev) => { - const items = ref([{ - type: 'pending' - }]); - os.api('users/lists/list').then(lists => { - const _items = [...lists.map(list => ({ - type: 'link', - text: list.name, - to: `/timeline/list/${list.id}` - })), null, { - type: 'link', - to: '/my/lists', - text: i18n.ts.manageLists, - icon: 'fas fa-cog', - }]; - items.value = _items; - }); - os.popupMenu(items, ev.currentTarget ?? ev.target); - }, + to: '/my/lists', }, + /* groups: { title: 'groups', icon: 'fas fa-users', show: computed(() => $i != null), to: '/my/groups', }, + */ antennas: { title: 'antennas', icon: 'fas fa-satellite', show: computed(() => $i != null), - active: computed(() => router.currentRoute.value.path.startsWith('/timeline/antenna/') || router.currentRoute.value.path === '/my/antennas' || router.currentRoute.value.path.startsWith('/my/antennas/')), - action: (ev) => { - const items = ref([{ - type: 'pending' - }]); - os.api('antennas/list').then(antennas => { - const _items = [...antennas.map(antenna => ({ - type: 'link', - text: antenna.name, - to: `/timeline/antenna/${antenna.id}` - })), null, { - type: 'link', - to: '/my/antennas', - text: i18n.ts.manageAntennas, - icon: 'fas fa-cog', - }]; - items.value = _items; - }); - os.popupMenu(items, ev.currentTarget ?? ev.target); - }, - }, - mentions: { - title: 'mentions', - icon: 'fas fa-at', - show: computed(() => $i != null), - indicated: computed(() => $i != null && $i.hasUnreadMentions), - to: '/my/mentions', - }, - messages: { - title: 'directNotes', - icon: 'fas fa-envelope', - show: computed(() => $i != null), - indicated: computed(() => $i != null && $i.hasUnreadSpecifiedNotes), - to: '/my/messages', + to: '/my/antennas', }, favorites: { title: 'favorites', @@ -153,21 +97,6 @@ export const menuDef = reactive({ icon: 'fas fa-satellite-dish', to: '/channels', }, - federation: { - title: 'federation', - icon: 'fas fa-globe', - to: '/federation', - }, - emojis: { - title: 'emojis', - icon: 'fas fa-laugh', - to: '/emojis', - }, - scratchpad: { - title: 'scratchpad', - icon: 'fas fa-terminal', - to: '/scratchpad', - }, ui: { title: 'switchUi', icon: 'fas fa-columns', @@ -178,29 +107,29 @@ export const menuDef = reactive({ action: () => { localStorage.setItem('ui', 'default'); unisonReload(); - } + }, }, { text: i18n.ts.deck, active: ui === 'deck', action: () => { localStorage.setItem('ui', 'deck'); unisonReload(); - } + }, }, { text: i18n.ts.classic, active: ui === 'classic', action: () => { localStorage.setItem('ui', 'classic'); unisonReload(); - } - }, /*{ - text: i18n.ts.desktop + ' (β)', - active: ui === 'desktop', - action: () => { - localStorage.setItem('ui', 'desktop'); - unisonReload(); - } - }*/], ev.currentTarget ?? ev.target); + }, + }], ev.currentTarget ?? ev.target); + }, + }, + reload: { + title: 'reload', + icon: 'fas fa-refresh', + action: (ev) => { + location.reload(); }, }, }); diff --git a/packages/client/src/nirax.ts b/packages/client/src/nirax.ts new file mode 100644 index 0000000000..cd29ae87a5 --- /dev/null +++ b/packages/client/src/nirax.ts @@ -0,0 +1,236 @@ +// NIRAX --- A lightweight router + +import { EventEmitter } from 'eventemitter3'; +import { Ref, Component, ref, shallowRef, ShallowRef } from 'vue'; +import { pleaseLogin } from '@/scripts/please-login'; + +type RouteDef = { + path: string; + component: Component; + query?: Record<string, string>; + loginRequired?: boolean; + name?: string; + hash?: string; + globalCacheKey?: string; +}; + +type ParsedPath = (string | { + name: string; + startsWith?: string; + wildcard?: boolean; + optional?: boolean; +})[]; + +function parsePath(path: string): ParsedPath { + const res = [] as ParsedPath; + + path = path.substring(1); + + for (const part of path.split('/')) { + if (part.includes(':')) { + const prefix = part.substring(0, part.indexOf(':')); + const placeholder = part.substring(part.indexOf(':') + 1); + const wildcard = placeholder.includes('(*)'); + const optional = placeholder.endsWith('?'); + res.push({ + name: placeholder.replace('(*)', '').replace('?', ''), + startsWith: prefix !== '' ? prefix : undefined, + wildcard, + optional, + }); + } else if (part.length !== 0) { + res.push(part); + } + } + + return res; +} + +export class Router extends EventEmitter<{ + change: (ctx: { + beforePath: string; + path: string; + route: RouteDef | null; + props: Map<string, string> | null; + key: string; + }) => void; + push: (ctx: { + beforePath: string; + path: string; + route: RouteDef | null; + props: Map<string, string> | null; + key: string; + }) => void; + same: () => void; +}> { + private routes: RouteDef[]; + private currentPath: string; + private currentComponent: Component | null = null; + private currentProps: Map<string, string> | null = null; + private currentKey = Date.now().toString(); + + public currentRoute: ShallowRef<RouteDef | null> = shallowRef(null); + public navHook: ((path: string) => boolean) | null = null; + + constructor(routes: Router['routes'], currentPath: Router['currentPath']) { + super(); + + this.routes = routes; + this.currentPath = currentPath; + this.navigate(currentPath, null, true); + } + + public resolve(path: string): { route: RouteDef; props: Map<string, string>; } | null { + let queryString: string | null = null; + let hash: string | null = null; + if (path[0] === '/') path = path.substring(1); + if (path.includes('#')) { + hash = path.substring(path.indexOf('#') + 1); + path = path.substring(0, path.indexOf('#')); + } + if (path.includes('?')) { + queryString = path.substring(path.indexOf('?') + 1); + path = path.substring(0, path.indexOf('?')); + } + + if (_DEV_) console.log('Routing: ', path, queryString); + + const _parts = path.split('/').filter(part => part.length !== 0); + + forEachRouteLoop: + for (const route of this.routes) { + let parts = [ ..._parts ]; + const props = new Map<string, string>(); + + pathMatchLoop: + for (const p of parsePath(route.path)) { + if (typeof p === 'string') { + if (p === parts[0]) { + parts.shift(); + } else { + continue forEachRouteLoop; + } + } else { + if (parts[0] == null && !p.optional) { + continue forEachRouteLoop; + } + if (p.wildcard) { + if (parts.length !== 0) { + props.set(p.name, parts.join('/')); + parts = []; + } + break pathMatchLoop; + } else { + if (p.startsWith) { + if (parts[0] == null || !parts[0].startsWith(p.startsWith)) continue forEachRouteLoop; + + props.set(p.name, parts[0].substring(p.startsWith.length)); + parts.shift(); + } else { + props.set(p.name, parts[0]); + parts.shift(); + } + } + } + } + + if (parts.length !== 0) continue forEachRouteLoop; + + if (route.hash != null && hash != null) { + props.set(route.hash, hash); + } + + if (route.query != null && queryString != null) { + const queryObject = [...new URLSearchParams(queryString).entries()] + .reduce((obj, entry) => ({ ...obj, [entry[0]]: entry[1] }), {}); + + for (const q in route.query) { + const as = route.query[q]; + if (queryObject[q]) { + props.set(as, queryObject[q]); + } + } + } + + return { + route, + props, + }; + } + + return null; + } + + private navigate(path: string, key: string | null | undefined, initial = false) { + const beforePath = this.currentPath; + const beforeRoute = this.currentRoute.value; + this.currentPath = path; + + const res = this.resolve(this.currentPath); + + if (res == null) { + throw new Error('no route found for: ' + path); + } + + if (res.route.loginRequired) { + pleaseLogin('/'); + } + + const isSamePath = beforePath === path; + if (isSamePath && key == null) key = this.currentKey; + this.currentComponent = res.route.component; + this.currentProps = res.props; + this.currentRoute.value = res.route; + this.currentKey = this.currentRoute.value.globalCacheKey ?? key ?? Date.now().toString(); + + if (!initial) { + this.emit('change', { + beforePath, + path, + route: this.currentRoute.value, + props: this.currentProps, + key: this.currentKey, + }); + } + } + + public getCurrentComponent() { + return this.currentComponent; + } + + public getCurrentProps() { + return this.currentProps; + } + + public getCurrentPath() { + return this.currentPath; + } + + public getCurrentKey() { + return this.currentKey; + } + + public push(path: string) { + const beforePath = this.currentPath; + if (path === beforePath) { + this.emit('same'); + return; + } + if (this.navHook) { + const cancel = this.navHook(path); + if (cancel) return; + } + this.navigate(path, null); + this.emit('push', { + beforePath, + path, + route: this.currentRoute.value, + props: this.currentProps, + key: this.currentKey, + }); + } + + public change(path: string, key?: string | null) { + this.navigate(path, key); + } +} diff --git a/packages/client/src/os.ts b/packages/client/src/os.ts index 14860465fa..00dae867d6 100644 --- a/packages/client/src/os.ts +++ b/packages/client/src/os.ts @@ -8,7 +8,6 @@ import { apiUrl, url } from '@/config'; import MkPostFormDialog from '@/components/post-form-dialog.vue'; import MkWaitingDialog from '@/components/waiting-dialog.vue'; import { MenuItem } from '@/types/menu'; -import { resolve } from '@/router'; import { $i } from '@/account'; export const pendingApiRequestsCount = ref(0); @@ -53,6 +52,39 @@ export const api = ((endpoint: string, data: Record<string, any> = {}, token?: s return promise; }) as typeof apiClient.request; +export const apiGet = ((endpoint: string, data: Record<string, any> = {}) => { + pendingApiRequestsCount.value++; + + const onFinally = () => { + pendingApiRequestsCount.value--; + }; + + const query = new URLSearchParams(data); + + const promise = new Promise((resolve, reject) => { + // Send request + fetch(`${apiUrl}/${endpoint}?${query}`, { + method: 'GET', + credentials: 'omit', + cache: 'default', + }).then(async (res) => { + const body = res.status === 204 ? null : await res.json(); + + if (res.status === 200) { + resolve(body); + } else if (res.status === 204) { + resolve(); + } else { + reject(body.error); + } + }).catch(reject); + }); + + promise.then(onFinally, onFinally); + + return promise; +}) as typeof apiClient.request; + export const apiWithDialog = (( endpoint: string, data: Record<string, any> = {}, @@ -155,20 +187,14 @@ export async function popup(component: Component, props: Record<string, any>, ev } export function pageWindow(path: string) { - const { component, props } = resolve(path); popup(defineAsyncComponent(() => import('@/components/page-window.vue')), { initialPath: path, - initialComponent: markRaw(component), - initialProps: props, }, {}, 'closed'); } export function modalPageWindow(path: string) { - const { component, props } = resolve(path); popup(defineAsyncComponent(() => import('@/components/modal-page-window.vue')), { initialPath: path, - initialComponent: markRaw(component), - initialProps: props, }, {}, 'closed'); } diff --git a/packages/client/src/pages/_error_.vue b/packages/client/src/pages/_error_.vue index 4cfe2e255c..6ac1f4297a 100644 --- a/packages/client/src/pages/_error_.vue +++ b/packages/client/src/pages/_error_.vue @@ -21,11 +21,11 @@ import { } from 'vue'; import * as misskey from 'misskey-js'; import MkButton from '@/components/ui/button.vue'; -import * as symbols from '@/symbols'; import { version } from '@/config'; import * as os from '@/os'; import { unisonReload } from '@/scripts/unison-reload'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const props = withDefaults(defineProps<{ error?: Error; @@ -52,11 +52,13 @@ function reload() { unisonReload(); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.error, - icon: 'fas fa-exclamation-triangle', - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.error, + icon: 'fas fa-exclamation-triangle', }); </script> diff --git a/packages/client/src/pages/about-misskey.vue b/packages/client/src/pages/about-misskey.vue index 691bc4f07b..a80041b5ce 100644 --- a/packages/client/src/pages/about-misskey.vue +++ b/packages/client/src/pages/about-misskey.vue @@ -1,62 +1,65 @@ <template> -<div style="overflow: clip;"> - <MkSpacer :content-max="600" :margin-min="20"> - <div class="_formRoot znqjceqz"> - <div id="debug"></div> - <div ref="containerEl" v-panel class="_formBlock about" :class="{ playing: easterEggEngine != null }"> - <img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/> - <div class="misskey">Misskey</div> - <div class="version">v{{ version }}</div> - <span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span> - </div> - <div class="_formBlock" style="text-align: center;"> - {{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a> - </div> - <div class="_formBlock" style="text-align: center;"> - <MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Misskey</MkButton> - </div> - <FormSection> - <div class="_formLinks"> - <FormLink to="https://github.com/misskey-dev/misskey" external> - <template #icon><i class="fas fa-code"></i></template> - {{ i18n.ts._aboutMisskey.source }} - <template #suffix>GitHub</template> - </FormLink> - <FormLink to="https://crowdin.com/project/misskey" external> - <template #icon><i class="fas fa-language"></i></template> - {{ i18n.ts._aboutMisskey.translation }} - <template #suffix>Crowdin</template> - </FormLink> - <FormLink to="https://www.patreon.com/syuilo" external> - <template #icon><i class="fas fa-hand-holding-medical"></i></template> - {{ i18n.ts._aboutMisskey.donate }} - <template #suffix>Patreon</template> - </FormLink> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <div style="overflow: hidden; overflow: clip;"> + <MkSpacer :content-max="600" :margin-min="20"> + <div class="_formRoot znqjceqz"> + <div id="debug"></div> + <div ref="containerEl" v-panel class="_formBlock about" :class="{ playing: easterEggEngine != null }"> + <img src="/client-assets/about-icon.png" alt="" class="icon" draggable="false" @load="iconLoaded" @click="gravity"/> + <div class="misskey">Misskey</div> + <div class="version">v{{ version }}</div> + <span v-for="emoji in easterEggEmojis" :key="emoji.id" class="emoji" :data-physics-x="emoji.left" :data-physics-y="emoji.top" :class="{ _physics_circle_: !emoji.emoji.startsWith(':') }"><MkEmoji class="emoji" :emoji="emoji.emoji" :custom-emojis="$instance.emojis" :is-reaction="false" :normal="true" :no-style="true"/></span> + </div> + <div class="_formBlock" style="text-align: center;"> + {{ i18n.ts._aboutMisskey.about }}<br><a href="https://misskey-hub.net/docs/misskey.html" target="_blank" class="_link">{{ i18n.ts.learnMore }}</a> </div> - </FormSection> - <FormSection> - <template #label>{{ i18n.ts._aboutMisskey.contributors }}</template> - <div class="_formLinks"> - <FormLink to="https://github.com/syuilo" external>@syuilo</FormLink> - <FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink> - <FormLink to="https://github.com/mei23" external>@mei23</FormLink> - <FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink> - <FormLink to="https://github.com/tamaina" external>@tamaina</FormLink> - <FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink> - <FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink> - <FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink> - <FormLink to="https://github.com/marihachi" external>@marihachi</FormLink> + <div class="_formBlock" style="text-align: center;"> + <MkButton primary rounded inline @click="iLoveMisskey">I <Mfm text="$[jelly ❤]"/> #Misskey</MkButton> </div> - <template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template> - </FormSection> - <FormSection> - <template #label><Mfm text="$[jelly ❤]"/> {{ i18n.ts._aboutMisskey.patrons }}</template> - <div v-for="patron in patrons" :key="patron">{{ patron }}</div> - <template #caption>{{ i18n.ts._aboutMisskey.morePatrons }}</template> - </FormSection> - </div> - </MkSpacer> -</div> + <FormSection> + <div class="_formLinks"> + <FormLink to="https://github.com/misskey-dev/misskey" external> + <template #icon><i class="fas fa-code"></i></template> + {{ i18n.ts._aboutMisskey.source }} + <template #suffix>GitHub</template> + </FormLink> + <FormLink to="https://crowdin.com/project/misskey" external> + <template #icon><i class="fas fa-language"></i></template> + {{ i18n.ts._aboutMisskey.translation }} + <template #suffix>Crowdin</template> + </FormLink> + <FormLink to="https://www.patreon.com/syuilo" external> + <template #icon><i class="fas fa-hand-holding-medical"></i></template> + {{ i18n.ts._aboutMisskey.donate }} + <template #suffix>Patreon</template> + </FormLink> + </div> + </FormSection> + <FormSection> + <template #label>{{ i18n.ts._aboutMisskey.contributors }}</template> + <div class="_formLinks"> + <FormLink to="https://github.com/syuilo" external>@syuilo</FormLink> + <FormLink to="https://github.com/AyaMorisawa" external>@AyaMorisawa</FormLink> + <FormLink to="https://github.com/mei23" external>@mei23</FormLink> + <FormLink to="https://github.com/acid-chicken" external>@acid-chicken</FormLink> + <FormLink to="https://github.com/tamaina" external>@tamaina</FormLink> + <FormLink to="https://github.com/rinsuki" external>@rinsuki</FormLink> + <FormLink to="https://github.com/Xeltica" external>@Xeltica</FormLink> + <FormLink to="https://github.com/u1-liquid" external>@u1-liquid</FormLink> + <FormLink to="https://github.com/marihachi" external>@marihachi</FormLink> + </div> + <template #caption><MkLink url="https://github.com/misskey-dev/misskey/graphs/contributors">{{ i18n.ts._aboutMisskey.allContributors }}</MkLink></template> + </FormSection> + <FormSection> + <template #label><Mfm text="$[jelly ❤]"/> {{ i18n.ts._aboutMisskey.patrons }}</template> + <div v-for="patron in patrons" :key="patron">{{ patron }}</div> + <template #caption>{{ i18n.ts._aboutMisskey.morePatrons }}</template> + </FormSection> + </div> + </MkSpacer> + </div> +</MkStickyContainer> </template> <script lang="ts" setup> @@ -67,10 +70,10 @@ import FormSection from '@/components/form/section.vue'; import MkButton from '@/components/ui/button.vue'; import MkLink from '@/components/link.vue'; import { physics } from '@/scripts/physics'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; const patrons = [ 'まっちゃとーにゅ', @@ -194,12 +197,13 @@ onBeforeUnmount(() => { } }); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.aboutMisskey, - icon: null, - bg: 'var(--bg)', - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.aboutMisskey, + icon: null, }); </script> diff --git a/packages/client/src/pages/emojis.category.vue b/packages/client/src/pages/about.emojis.vue index c47870f4d4..6d915c5843 100644 --- a/packages/client/src/pages/emojis.category.vue +++ b/packages/client/src/pages/about.emojis.vue @@ -36,7 +36,6 @@ import MkSelect from '@/components/form/select.vue'; import MkFolder from '@/components/ui/folder.vue'; import MkTab from '@/components/tab.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { emojiCategories, emojiTags } from '@/instance'; import XEmoji from './emojis.emoji.vue'; diff --git a/packages/client/src/pages/about.federation.vue b/packages/client/src/pages/about.federation.vue new file mode 100644 index 0000000000..00ca44eec8 --- /dev/null +++ b/packages/client/src/pages/about.federation.vue @@ -0,0 +1,106 @@ +<template> +<div class="taeiyria"> + <div class="query"> + <MkInput v-model="host" :debounce="true" class=""> + <template #prefix><i class="fas fa-search"></i></template> + <template #label>{{ $ts.host }}</template> + </MkInput> + <FormSplit style="margin-top: var(--margin);"> + <MkSelect v-model="state"> + <template #label>{{ $ts.state }}</template> + <option value="all">{{ $ts.all }}</option> + <option value="federating">{{ $ts.federating }}</option> + <option value="subscribing">{{ $ts.subscribing }}</option> + <option value="publishing">{{ $ts.publishing }}</option> + <option value="suspended">{{ $ts.suspended }}</option> + <option value="blocked">{{ $ts.blocked }}</option> + <option value="notResponding">{{ $ts.notResponding }}</option> + </MkSelect> + <MkSelect v-model="sort"> + <template #label>{{ $ts.sort }}</template> + <option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option> + <option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option> + <option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option> + <option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option> + <option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option> + <option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option> + <option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option> + <option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option> + <option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option> + <option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option> + <option value="+caughtAt">{{ $ts.registeredAt }} ({{ $ts.descendingOrder }})</option> + <option value="-caughtAt">{{ $ts.registeredAt }} ({{ $ts.ascendingOrder }})</option> + <option value="+lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.descendingOrder }})</option> + <option value="-lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.ascendingOrder }})</option> + </MkSelect> + </FormSplit> + </div> + + <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination"> + <div class="dqokceoi"> + <MkA v-for="instance in items" :key="instance.id" v-tooltip.mfm="`Last communicated: ${new Date(instance.lastCommunicatedAt).toLocaleString()}\nStatus: ${getStatus(instance)}`" class="instance" :to="`/instance-info/${instance.host}`"> + <MkInstanceCardMini :instance="instance"/> + </MkA> + </div> + </MkPagination> +</div> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSelect from '@/components/form/select.vue'; +import MkPagination from '@/components/ui/pagination.vue'; +import MkInstanceCardMini from '@/components/instance-card-mini.vue'; +import FormSplit from '@/components/form/split.vue'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; + +let host = $ref(''); +let state = $ref('federating'); +let sort = $ref('+pubSub'); +const pagination = { + endpoint: 'federation/instances' as const, + limit: 10, + offsetMode: true, + params: computed(() => ({ + sort: sort, + host: host !== '' ? host : null, + ...( + state === 'federating' ? { federating: true } : + state === 'subscribing' ? { subscribing: true } : + state === 'publishing' ? { publishing: true } : + state === 'suspended' ? { suspended: true } : + state === 'blocked' ? { blocked: true } : + state === 'notResponding' ? { notResponding: true } : + {}), + })), +}; + +function getStatus(instance) { + if (instance.isSuspended) return 'Suspended'; + if (instance.isBlocked) return 'Blocked'; + if (instance.isNotResponding) return 'Error'; + return 'Alive'; +} +</script> + +<style lang="scss" scoped> +.taeiyria { + > .query { + background: var(--bg); + margin-bottom: 16px; + } +} + +.dqokceoi { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + grid-gap: 12px; + + > .instance:hover { + text-decoration: none; + } +} +</style> diff --git a/packages/client/src/pages/about.vue b/packages/client/src/pages/about.vue index 6cc2e387ec..6241bbbdda 100644 --- a/packages/client/src/pages/about.vue +++ b/packages/client/src/pages/about.vue @@ -1,78 +1,89 @@ <template> -<MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20"> - <div class="_formRoot"> - <div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> - <div class="content"> - <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> - <div class="name"> - <b>{{ $instance.name || host }}</b> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer v-if="tab === 'overview'" :content-max="600" :margin-min="20"> + <div class="_formRoot"> + <div class="_formBlock fwhjspax" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> + <div class="content"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + <div class="name"> + <b>{{ $instance.name || host }}</b> + </div> </div> </div> - </div> - - <MkKeyValue class="_formBlock"> - <template #key>{{ $ts.description }}</template> - <template #value>{{ $instance.description }}</template> - </MkKeyValue> - <FormSection> - <MkKeyValue class="_formBlock" :copy="version"> - <template #key>Misskey</template> - <template #value>{{ version }}</template> + <MkKeyValue class="_formBlock"> + <template #key>{{ $ts.description }}</template> + <template #value>{{ $instance.description }}</template> </MkKeyValue> - <FormLink to="/about-misskey">{{ $ts.aboutMisskey }}</FormLink> - </FormSection> - <FormSection> - <FormSplit> - <MkKeyValue class="_formBlock"> - <template #key>{{ $ts.administrator }}</template> - <template #value>{{ $instance.maintainerName }}</template> - </MkKeyValue> - <MkKeyValue class="_formBlock"> - <template #key>{{ $ts.contact }}</template> - <template #value>{{ $instance.maintainerEmail }}</template> + <FormSection> + <MkKeyValue class="_formBlock" :copy="version"> + <template #key>Misskey</template> + <template #value>{{ version }}</template> </MkKeyValue> - </FormSplit> - <FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" class="_formBlock" external>{{ $ts.tos }}</FormLink> - </FormSection> + <FormLink to="/about-misskey">{{ $ts.aboutMisskey }}</FormLink> + </FormSection> - <FormSuspense :p="initStats"> <FormSection> - <template #label>{{ $ts.statistics }}</template> <FormSplit> <MkKeyValue class="_formBlock"> - <template #key>{{ $ts.users }}</template> - <template #value>{{ number(stats.originalUsersCount) }}</template> + <template #key>{{ $ts.administrator }}</template> + <template #value>{{ $instance.maintainerName }}</template> </MkKeyValue> <MkKeyValue class="_formBlock"> - <template #key>{{ $ts.notes }}</template> - <template #value>{{ number(stats.originalNotesCount) }}</template> + <template #key>{{ $ts.contact }}</template> + <template #value>{{ $instance.maintainerEmail }}</template> </MkKeyValue> </FormSplit> + <FormLink v-if="$instance.tosUrl" :to="$instance.tosUrl" class="_formBlock" external>{{ $ts.tos }}</FormLink> </FormSection> - </FormSuspense> - <FormSection> - <template #label>Well-known resources</template> - <div class="_formLinks"> - <FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink> - <FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink> - <FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink> - <FormLink :to="`/robots.txt`" external>robots.txt</FormLink> - <FormLink :to="`/manifest.json`" external>manifest.json</FormLink> - </div> - </FormSection> - </div> -</MkSpacer> -<MkSpacer v-else-if="tab === 'charts'" :content-max="1200" :margin-min="20"> - <MkInstanceStats :chart-limit="500" :detailed="true"/> -</MkSpacer> + <FormSuspense :p="initStats"> + <FormSection> + <template #label>{{ $ts.statistics }}</template> + <FormSplit> + <MkKeyValue class="_formBlock"> + <template #key>{{ $ts.users }}</template> + <template #value>{{ number(stats.originalUsersCount) }}</template> + </MkKeyValue> + <MkKeyValue class="_formBlock"> + <template #key>{{ $ts.notes }}</template> + <template #value>{{ number(stats.originalNotesCount) }}</template> + </MkKeyValue> + </FormSplit> + </FormSection> + </FormSuspense> + + <FormSection> + <template #label>Well-known resources</template> + <div class="_formLinks"> + <FormLink :to="`/.well-known/host-meta`" external>host-meta</FormLink> + <FormLink :to="`/.well-known/host-meta.json`" external>host-meta.json</FormLink> + <FormLink :to="`/.well-known/nodeinfo`" external>nodeinfo</FormLink> + <FormLink :to="`/robots.txt`" external>robots.txt</FormLink> + <FormLink :to="`/manifest.json`" external>manifest.json</FormLink> + </div> + </FormSection> + </div> + </MkSpacer> + <MkSpacer v-else-if="tab === 'emojis'" :content-max="1000" :margin-min="20"> + <XEmojis/> + </MkSpacer> + <MkSpacer v-else-if="tab === 'federation'" :content-max="1000" :margin-min="20"> + <XFederation/> + </MkSpacer> + <MkSpacer v-else-if="tab === 'charts'" :content-max="1000" :margin-min="20"> + <MkInstanceStats :chart-limit="500" :detailed="true"/> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { ref, computed } from 'vue'; -import { version, instanceName } from '@/config'; +import XEmojis from './about.emojis.vue'; +import XFederation from './about.federation.vue'; +import { version, instanceName , host } from '@/config'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import FormSuspense from '@/components/form/suspense.vue'; @@ -81,42 +92,53 @@ import MkKeyValue from '@/components/key-value.vue'; import MkInstanceStats from '@/components/instance-stats.vue'; import * as os from '@/os'; import number from '@/filters/number'; -import * as symbols from '@/symbols'; -import { host } from '@/config'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const props = withDefaults(defineProps<{ + initialTab?: string; +}>(), { + initialTab: 'overview', +}); let stats = $ref(null); -let tab = $ref('overview'); +let tab = $ref(props.initialTab); const initStats = () => os.api('stats', { }).then((res) => { stats = res; }); -defineExpose({ - [symbols.PAGE_INFO]: computed(() => ({ - title: i18n.ts.instanceInfo, - icon: 'fas fa-info-circle', - bg: 'var(--bg)', - tabs: [{ - active: tab === 'overview', - title: i18n.ts.overview, - onClick: () => { tab = 'overview'; }, - }, { - active: tab === 'charts', - title: i18n.ts.charts, - icon: 'fas fa-chart-bar', - onClick: () => { tab = 'charts'; }, - },], - })), -}); +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => [{ + key: 'overview', + title: i18n.ts.overview, +}, { + key: 'emojis', + title: i18n.ts.customEmojis, + icon: 'fas fa-laugh', +}, { + key: 'federation', + title: i18n.ts.federation, + icon: 'fas fa-globe', +}, { + key: 'charts', + title: i18n.ts.charts, + icon: 'fas fa-chart-simple', +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.instanceInfo, + icon: 'fas fa-info-circle', +}))); </script> <style lang="scss" scoped> .fwhjspax { text-align: center; border-radius: 10px; - overflow: clip; + overflow: hidden; overflow: clip; background-size: cover; background-position: center center; diff --git a/packages/client/src/pages/admin-file.vue b/packages/client/src/pages/admin-file.vue new file mode 100644 index 0000000000..f96a41a7ea --- /dev/null +++ b/packages/client/src/pages/admin-file.vue @@ -0,0 +1,160 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer v-if="file" :content-max="600" :margin-min="16" :margin-max="32"> + <div v-if="tab === 'overview'" class="cxqhhsmd _formRoot"> + <a class="_formBlock thumbnail" :href="file.url" target="_blank"> + <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> + </a> + <div class="_formBlock"> + <MkKeyValue :copy="file.type" oneline style="margin: 1em 0;"> + <template #key>MIME Type</template> + <template #value><span class="_monospace">{{ file.type }}</span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>Size</template> + <template #value><span class="_monospace">{{ bytes(file.size) }}</span></template> + </MkKeyValue> + <MkKeyValue :copy="file.id" oneline style="margin: 1em 0;"> + <template #key>ID</template> + <template #value><span class="_monospace">{{ file.id }}</span></template> + </MkKeyValue> + <MkKeyValue :copy="file.md5" oneline style="margin: 1em 0;"> + <template #key>MD5</template> + <template #value><span class="_monospace">{{ file.md5 }}</span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.createdAt }}</template> + <template #value><span class="_monospace"><MkTime :time="file.createdAt" mode="detail" style="display: block;"/></span></template> + </MkKeyValue> + </div> + <MkA v-if="file.user" class="user" :to="`/user-info/${file.user.id}`"> + <MkUserCardMini :user="file.user"/> + </MkA> + <div class="_formBlock"> + <MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch> + </div> + + <div class="_formBlock"> + <MkButton danger @click="del"><i class="fas fa-trash-alt"></i> {{ i18n.ts.delete }}</MkButton> + </div> + </div> + <div v-else-if="tab === 'ip' && info" class="_formRoot"> + <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo> + <MkKeyValue v-if="info.requestIp" class="_formBlock _monospace" :copy="info.requestIp" oneline> + <template #key>IP</template> + <template #value>{{ info.requestIp }}</template> + </MkKeyValue> + <FormSection v-if="info.requestHeaders"> + <template #label>Headers</template> + <MkKeyValue v-for="(v, k) in info.requestHeaders" :key="k" class="_formBlock _monospace"> + <template #key>{{ k }}</template> + <template #value>{{ v }}</template> + </MkKeyValue> + </FormSection> + </div> + <div v-else-if="tab === 'raw'" class="_formRoot"> + <MkObjectView v-if="info" tall :value="info"> + </MkObjectView> + </div> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { computed } from 'vue'; +import MkButton from '@/components/ui/button.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import MkObjectView from '@/components/object-view.vue'; +import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import MkKeyValue from '@/components/key-value.vue'; +import FormSection from '@/components/form/section.vue'; +import MkUserCardMini from '@/components/user-card-mini.vue'; +import MkInfo from '@/components/ui/info.vue'; +import bytes from '@/filters/bytes'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { acct } from '@/filters/user'; +import { iAmAdmin, iAmModerator } from '@/account'; + +let tab = $ref('overview'); +let file: any = $ref(null); +let info: any = $ref(null); +let isSensitive: boolean = $ref(false); + +const props = defineProps<{ + fileId: string, +}>(); + +async function fetch() { + file = await os.api('drive/files/show', { fileId: props.fileId }); + info = await os.api('admin/drive/show-file', { fileId: props.fileId }); + isSensitive = file.isSensitive; +} + +fetch(); + +async function del() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: file.name }), + }); + if (canceled) return; + + os.apiWithDialog('drive/files/delete', { + fileId: file.id, + }); +} + +async function toggleIsSensitive(v) { + await os.api('drive/files/update', { fileId: props.fileId, isSensitive: v }); + isSensitive = v; +} + +const headerActions = $computed(() => [{ + text: i18n.ts.openInNewTab, + icon: 'fas fa-external-link-alt', + handler: () => { + window.open(file.url, '_blank'); + }, +}]); + +const headerTabs = $computed(() => [{ + key: 'overview', + title: i18n.ts.overview, + icon: 'fas fa-info-circle', +}, iAmModerator ? { + key: 'ip', + title: 'IP', + icon: 'fas fa-bars-staggered', +} : null, { + key: 'raw', + title: 'Raw data', + icon: 'fas fa-code', +}]); + +definePageMetadata(computed(() => ({ + title: file ? i18n.ts.file + ': ' + file.name : i18n.ts.file, + icon: 'fas fa-file', +}))); +</script> + +<style lang="scss" scoped> +.cxqhhsmd { + > .thumbnail { + display: block; + + > .thumbnail { + height: 300px; + max-width: 100%; + } + } + + > .user { + &:hover { + text-decoration: none; + } + } +} +</style> diff --git a/packages/client/src/pages/admin/_header_.vue b/packages/client/src/pages/admin/_header_.vue new file mode 100644 index 0000000000..aea2663c39 --- /dev/null +++ b/packages/client/src/pages/admin/_header_.vue @@ -0,0 +1,292 @@ +<template> +<div ref="el" class="fdidabkc" :style="{ background: bg }" @click="onClick"> + <template v-if="metadata"> + <div class="titleContainer" @click="showTabsPopup"> + <i v-if="metadata.icon" class="icon" :class="metadata.icon"></i> + + <div class="title"> + <div class="title">{{ metadata.title }}</div> + </div> + </div> + <div class="tabs"> + <button v-for="tab in tabs" :ref="(el) => tabRefs[tab.key] = el" v-tooltip="tab.title" class="tab _button" :class="{ active: tab.key != null && tab.key === props.tab }" @mousedown="(ev) => onTabMousedown(tab, ev)" @click="(ev) => onTabClick(tab, ev)"> + <i v-if="tab.icon" class="icon" :class="tab.icon"></i> + <span v-if="!tab.iconOnly" class="title">{{ tab.title }}</span> + </button> + <div ref="tabHighlightEl" class="highlight"></div> + </div> + </template> + <div class="buttons right"> + <template v-if="actions"> + <template v-for="action in actions"> + <MkButton v-if="action.asFullButton" class="fullButton" primary @click.stop="action.handler"><i :class="action.icon" style="margin-right: 6px;"></i>{{ action.text }}</MkButton> + <button v-else v-tooltip="action.text" class="_button button" :class="{ highlighted: action.highlighted }" @click.stop="action.handler" @touchstart="preventDrag"><i :class="action.icon"></i></button> + </template> + </template> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, onUnmounted, ref, inject, watch, nextTick } from 'vue'; +import tinycolor from 'tinycolor2'; +import { popupMenu } from '@/os'; +import { url } from '@/config'; +import { scrollToTop } from '@/scripts/scroll'; +import MkButton from '@/components/ui/button.vue'; +import { i18n } from '@/i18n'; +import { globalEvents } from '@/events'; +import { injectPageMetadata } from '@/scripts/page-metadata'; + +type Tab = { + key?: string | null; + title: string; + icon?: string; + iconOnly?: boolean; + onClick?: (ev: MouseEvent) => void; +}; + +const props = defineProps<{ + tabs?: Tab[]; + tab?: string; + actions?: { + text: string; + icon: string; + asFullButton?: boolean; + handler: (ev: MouseEvent) => void; + }[]; + thin?: boolean; +}>(); + +const emit = defineEmits<{ + (ev: 'update:tab', key: string); +}>(); + +const metadata = injectPageMetadata(); + +const el = ref<HTMLElement>(null); +const tabRefs = {}; +const tabHighlightEl = $ref<HTMLElement | null>(null); +const bg = ref(null); +const height = ref(0); +const hasTabs = computed(() => { + return props.tabs && props.tabs.length > 0; +}); + +const showTabsPopup = (ev: MouseEvent) => { + if (!hasTabs.value) return; + ev.preventDefault(); + ev.stopPropagation(); + const menu = props.tabs.map(tab => ({ + text: tab.title, + icon: tab.icon, + active: tab.key != null && tab.key === props.tab, + action: (ev) => { + onTabClick(tab, ev); + }, + })); + popupMenu(menu, ev.currentTarget ?? ev.target); +}; + +const preventDrag = (ev: TouchEvent) => { + ev.stopPropagation(); +}; + +const onClick = () => { + scrollToTop(el.value, { behavior: 'smooth' }); +}; + +function onTabMousedown(tab: Tab, ev: MouseEvent): void { + // ユーザビリティの観点からmousedown時にはonClickは呼ばない + if (tab.key) { + emit('update:tab', tab.key); + } +} + +function onTabClick(tab: Tab, ev: MouseEvent): void { + if (tab.onClick) { + ev.preventDefault(); + ev.stopPropagation(); + tab.onClick(ev); + } + if (tab.key) { + emit('update:tab', tab.key); + } +} + +const calcBg = () => { + const rawBg = metadata?.bg || 'var(--bg)'; + const tinyBg = tinycolor(rawBg.startsWith('var(') ? getComputedStyle(document.documentElement).getPropertyValue(rawBg.slice(4, -1)) : rawBg); + tinyBg.setAlpha(0.85); + bg.value = tinyBg.toRgbString(); +}; + +onMounted(() => { + calcBg(); + globalEvents.on('themeChanged', calcBg); + + watch(() => [props.tab, props.tabs], () => { + nextTick(() => { + const tabEl = tabRefs[props.tab]; + if (tabEl && tabHighlightEl) { + // offsetWidth や offsetLeft は少数を丸めてしまうため getBoundingClientRect を使う必要がある + // https://developer.mozilla.org/ja/docs/Web/API/HTMLElement/offsetWidth#%E5%80%A4 + const parentRect = tabEl.parentElement.getBoundingClientRect(); + const rect = tabEl.getBoundingClientRect(); + tabHighlightEl.style.width = rect.width + 'px'; + tabHighlightEl.style.left = (rect.left - parentRect.left) + 'px'; + } + }); + }, { + immediate: true, + }); +}); + +onUnmounted(() => { + globalEvents.off('themeChanged', calcBg); +}); +</script> + +<style lang="scss" scoped> +.fdidabkc { + --height: 60px; + display: flex; + width: 100%; + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + + > .buttons { + --margin: 8px; + display: flex; + align-items: center; + height: var(--height); + margin: 0 var(--margin); + + &.right { + margin-left: auto; + } + + &:empty { + width: var(--height); + } + + > .button { + display: flex; + align-items: center; + justify-content: center; + height: calc(var(--height) - (var(--margin) * 2)); + width: calc(var(--height) - (var(--margin) * 2)); + box-sizing: border-box; + position: relative; + border-radius: 5px; + + &:hover { + background: rgba(0, 0, 0, 0.05); + } + + &.highlighted { + color: var(--accent); + } + } + + > .fullButton { + & + .fullButton { + margin-left: 12px; + } + } + } + + > .titleContainer { + display: flex; + align-items: center; + max-width: 400px; + overflow: auto; + white-space: nowrap; + text-align: left; + font-weight: bold; + flex-shrink: 0; + margin-left: 24px; + + > .avatar { + $size: 32px; + display: inline-block; + width: $size; + height: $size; + vertical-align: bottom; + margin: 0 8px; + pointer-events: none; + } + + > .icon { + margin-right: 8px; + width: 16px; + text-align: center; + } + + > .title { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + line-height: 1.1; + + > .subtitle { + opacity: 0.6; + font-size: 0.8em; + font-weight: normal; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + + &.activeTab { + text-align: center; + + > .chevron { + display: inline-block; + margin-left: 6px; + } + } + } + } + } + + > .tabs { + position: relative; + margin-left: 16px; + font-size: 0.8em; + overflow: auto; + white-space: nowrap; + + > .tab { + display: inline-block; + position: relative; + padding: 0 10px; + height: 100%; + font-weight: normal; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + &.active { + opacity: 1; + } + + > .icon + .title { + margin-left: 8px; + } + } + + > .highlight { + position: absolute; + bottom: 0; + height: 3px; + background: var(--accent); + border-radius: 999px; + transition: all 0.2s ease; + pointer-events: none; + } + } +} +</style> diff --git a/packages/client/src/pages/admin/abuses.vue b/packages/client/src/pages/admin/abuses.vue index e1d0361c0b..11cf284b22 100644 --- a/packages/client/src/pages/admin/abuses.vue +++ b/packages/client/src/pages/admin/abuses.vue @@ -1,56 +1,62 @@ <template> -<div class="lcixvhis"> - <div class="_section reports"> - <div class="_content"> - <div class="inputs" style="display: flex;"> - <MkSelect v-model="state" style="margin: 0; flex: 1;"> - <template #label>{{ $ts.state }}</template> - <option value="all">{{ $ts.all }}</option> - <option value="unresolved">{{ $ts.unresolved }}</option> - <option value="resolved">{{ $ts.resolved }}</option> - </MkSelect> - <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;"> - <template #label>{{ $ts.reporteeOrigin }}</template> - <option value="combined">{{ $ts.all }}</option> - <option value="local">{{ $ts.local }}</option> - <option value="remote">{{ $ts.remote }}</option> - </MkSelect> - <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;"> - <template #label>{{ $ts.reporterOrigin }}</template> - <option value="combined">{{ $ts.all }}</option> - <option value="local">{{ $ts.local }}</option> - <option value="remote">{{ $ts.remote }}</option> - </MkSelect> - </div> - <!-- TODO +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="lcixvhis"> + <div class="_section reports"> + <div class="_content"> + <div class="inputs" style="display: flex;"> + <MkSelect v-model="state" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.state }}</template> + <option value="all">{{ $ts.all }}</option> + <option value="unresolved">{{ $ts.unresolved }}</option> + <option value="resolved">{{ $ts.resolved }}</option> + </MkSelect> + <MkSelect v-model="targetUserOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.reporteeOrigin }}</template> + <option value="combined">{{ $ts.all }}</option> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + </MkSelect> + <MkSelect v-model="reporterOrigin" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.reporterOrigin }}</template> + <option value="combined">{{ $ts.all }}</option> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + </MkSelect> + </div> + <!-- TODO <div class="inputs" style="display: flex; padding-top: 1.2em;"> - <MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" spellcheck="false"> + <MkInput v-model="searchUsername" style="margin: 0; flex: 1;" type="text" :spellcheck="false"> <span>{{ $ts.username }}</span> </MkInput> - <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" spellcheck="false" :disabled="pagination.params().origin === 'local'"> + <MkInput v-model="searchHost" style="margin: 0; flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params().origin === 'local'"> <span>{{ $ts.host }}</span> </MkInput> </div> --> - <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);"> - <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/> - </MkPagination> + <MkPagination v-slot="{items}" ref="reports" :pagination="pagination" style="margin-top: var(--margin);"> + <XAbuseReport v-for="report in items" :key="report.id" :report="report" @resolved="resolved"/> + </MkPagination> + </div> + </div> </div> - </div> -</div> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { computed } from 'vue'; +import XHeader from './_header_.vue'; import MkInput from '@/components/form/input.vue'; import MkSelect from '@/components/form/select.vue'; import MkPagination from '@/components/ui/pagination.vue'; import XAbuseReport from '@/components/abuse-report.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let reports = $ref<InstanceType<typeof MkPagination>>(); @@ -74,12 +80,13 @@ function resolved(reportId) { reports.removeItem(item => item.id === reportId); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.abuseReports, - icon: 'fas fa-exclamation-circle', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.abuseReports, + icon: 'fas fa-exclamation-circle', }); </script> diff --git a/packages/client/src/pages/admin/ads.vue b/packages/client/src/pages/admin/ads.vue index b18e08db96..21feafc0bb 100644 --- a/packages/client/src/pages/admin/ads.vue +++ b/packages/client/src/pages/admin/ads.vue @@ -1,21 +1,23 @@ <template> -<MkSpacer :content-max="900"> - <div class="uqshojas"> - <div v-for="ad in ads" class="_panel _formRoot ad"> - <MkAd v-if="ad.url" :specify="ad"/> - <MkInput v-model="ad.url" type="url" class="_formBlock"> - <template #label>URL</template> - </MkInput> - <MkInput v-model="ad.imageUrl" class="_formBlock"> - <template #label>{{ i18n.ts.imageUrl }}</template> - </MkInput> - <FormRadios v-model="ad.place" class="_formBlock"> - <template #label>Form</template> - <option value="square">square</option> - <option value="horizontal">horizontal</option> - <option value="horizontal-big">horizontal-big</option> - </FormRadios> - <!-- +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="uqshojas"> + <div v-for="ad in ads" class="_panel _formRoot ad"> + <MkAd v-if="ad.url" :specify="ad"/> + <MkInput v-model="ad.url" type="url" class="_formBlock"> + <template #label>URL</template> + </MkInput> + <MkInput v-model="ad.imageUrl" class="_formBlock"> + <template #label>{{ i18n.ts.imageUrl }}</template> + </MkInput> + <FormRadios v-model="ad.place" class="_formBlock"> + <template #label>Form</template> + <option value="square">square</option> + <option value="horizontal">horizontal</option> + <option value="horizontal-big">horizontal-big</option> + </FormRadios> + <!-- <div style="margin: 32px 0;"> {{ i18n.ts.priority }} <MkRadio v-model="ad.priority" value="high">{{ i18n.ts.high }}</MkRadio> @@ -23,36 +25,38 @@ <MkRadio v-model="ad.priority" value="low">{{ i18n.ts.low }}</MkRadio> </div> --> - <FormSplit> - <MkInput v-model="ad.ratio" type="number"> - <template #label>{{ i18n.ts.ratio }}</template> - </MkInput> - <MkInput v-model="ad.expiresAt" type="date"> - <template #label>{{ i18n.ts.expiration }}</template> - </MkInput> - </FormSplit> - <MkTextarea v-model="ad.memo" class="_formBlock"> - <template #label>{{ i18n.ts.memo }}</template> - </MkTextarea> - <div class="buttons _formBlock"> - <MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton> - <MkButton class="button" inline danger @click="remove(ad)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton> + <FormSplit> + <MkInput v-model="ad.ratio" type="number"> + <template #label>{{ i18n.ts.ratio }}</template> + </MkInput> + <MkInput v-model="ad.expiresAt" type="date"> + <template #label>{{ i18n.ts.expiration }}</template> + </MkInput> + </FormSplit> + <MkTextarea v-model="ad.memo" class="_formBlock"> + <template #label>{{ i18n.ts.memo }}</template> + </MkTextarea> + <div class="buttons _formBlock"> + <MkButton class="button" inline primary style="margin-right: 12px;" @click="save(ad)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton> + <MkButton class="button" inline danger @click="remove(ad)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton> + </div> </div> </div> - </div> -</MkSpacer> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { } from 'vue'; +import XHeader from './_header_.vue'; import MkButton from '@/components/ui/button.vue'; import MkInput from '@/components/form/input.vue'; import MkTextarea from '@/components/form/textarea.vue'; import FormRadios from '@/components/form/radios.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let ads: any[] = $ref([]); @@ -81,7 +85,7 @@ function remove(ad) { if (canceled) return; ads = ads.filter(x => x !== ad); os.apiWithDialog('admin/ad/delete', { - id: ad.id + id: ad.id, }); }); } @@ -90,28 +94,28 @@ function save(ad) { if (ad.id == null) { os.apiWithDialog('admin/ad/create', { ...ad, - expiresAt: new Date(ad.expiresAt).getTime() + expiresAt: new Date(ad.expiresAt).getTime(), }); } else { os.apiWithDialog('admin/ad/update', { ...ad, - expiresAt: new Date(ad.expiresAt).getTime() + expiresAt: new Date(ad.expiresAt).getTime(), }); } } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.ads, - icon: 'fas fa-audio-description', - bg: 'var(--bg)', - actions: [{ - asFullButton: true, - icon: 'fas fa-plus', - text: i18n.ts.add, - handler: add, - }], - } +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'fas fa-plus', + text: i18n.ts.add, + handler: add, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.ads, + icon: 'fas fa-audio-description', }); </script> diff --git a/packages/client/src/pages/admin/announcements.vue b/packages/client/src/pages/admin/announcements.vue index 97774975de..5107c2f302 100644 --- a/packages/client/src/pages/admin/announcements.vue +++ b/packages/client/src/pages/admin/announcements.vue @@ -1,34 +1,40 @@ <template> -<div class="ztgjmzrw"> - <section v-for="announcement in announcements" class="_card _gap announcements"> - <div class="_content announcement"> - <MkInput v-model="announcement.title"> - <template #label>{{ i18n.ts.title }}</template> - </MkInput> - <MkTextarea v-model="announcement.text"> - <template #label>{{ i18n.ts.text }}</template> - </MkTextarea> - <MkInput v-model="announcement.imageUrl"> - <template #label>{{ i18n.ts.imageUrl }}</template> - </MkInput> - <p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p> - <div class="buttons"> - <MkButton class="button" inline primary @click="save(announcement)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton> - <MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton> - </div> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="ztgjmzrw"> + <section v-for="announcement in announcements" class="_card _gap announcements"> + <div class="_content announcement"> + <MkInput v-model="announcement.title"> + <template #label>{{ i18n.ts.title }}</template> + </MkInput> + <MkTextarea v-model="announcement.text"> + <template #label>{{ i18n.ts.text }}</template> + </MkTextarea> + <MkInput v-model="announcement.imageUrl"> + <template #label>{{ i18n.ts.imageUrl }}</template> + </MkInput> + <p v-if="announcement.reads">{{ i18n.t('nUsersRead', { n: announcement.reads }) }}</p> + <div class="buttons"> + <MkButton class="button" inline primary @click="save(announcement)"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton> + <MkButton class="button" inline @click="remove(announcement)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton> + </div> + </div> + </section> </div> - </section> -</div> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { } from 'vue'; +import XHeader from './_header_.vue'; import MkButton from '@/components/ui/button.vue'; import MkInput from '@/components/form/input.vue'; import MkTextarea from '@/components/form/textarea.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let announcements: any[] = $ref([]); @@ -41,7 +47,7 @@ function add() { id: null, title: '', text: '', - imageUrl: null + imageUrl: null, }); } @@ -61,41 +67,41 @@ function save(announcement) { os.api('admin/announcements/create', announcement).then(() => { os.alert({ type: 'success', - text: i18n.ts.saved + text: i18n.ts.saved, }); }).catch(err => { os.alert({ type: 'error', - text: err + text: err, }); }); } else { os.api('admin/announcements/update', announcement).then(() => { os.alert({ type: 'success', - text: i18n.ts.saved + text: i18n.ts.saved, }); }).catch(err => { os.alert({ type: 'error', - text: err + text: err, }); }); } } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.announcements, - icon: 'fas fa-broadcast-tower', - bg: 'var(--bg)', - actions: [{ - asFullButton: true, - icon: 'fas fa-plus', - text: i18n.ts.add, - handler: add, - }], - } +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'fas fa-plus', + text: i18n.ts.add, + handler: add, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.announcements, + icon: 'fas fa-broadcast-tower', }); </script> diff --git a/packages/client/src/pages/admin/bot-protection.vue b/packages/client/src/pages/admin/bot-protection.vue index 30fee5015a..d316f973bc 100644 --- a/packages/client/src/pages/admin/bot-protection.vue +++ b/packages/client/src/pages/admin/bot-protection.vue @@ -51,7 +51,6 @@ import FormButton from '@/components/ui/button.vue'; import FormSuspense from '@/components/form/suspense.vue'; import FormSlot from '@/components/form/slot.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { fetchInstance } from '@/instance'; const MkCaptcha = defineAsyncComponent(() => import('@/components/captcha.vue')); @@ -62,27 +61,22 @@ let hcaptchaSecretKey: string | null = $ref(null); let recaptchaSiteKey: string | null = $ref(null); let recaptchaSecretKey: string | null = $ref(null); -const enableHcaptcha = $computed(() => provider === 'hcaptcha'); -const enableRecaptcha = $computed(() => provider === 'recaptcha'); - async function init() { const meta = await os.api('admin/meta'); - enableHcaptcha = meta.enableHcaptcha; hcaptchaSiteKey = meta.hcaptchaSiteKey; hcaptchaSecretKey = meta.hcaptchaSecretKey; - enableRecaptcha = meta.enableRecaptcha; recaptchaSiteKey = meta.recaptchaSiteKey; recaptchaSecretKey = meta.recaptchaSecretKey; - provider = enableHcaptcha ? 'hcaptcha' : enableRecaptcha ? 'recaptcha' : null; + provider = meta.enableHcaptcha ? 'hcaptcha' : meta.enableRecaptcha ? 'recaptcha' : null; } function save() { os.apiWithDialog('admin/update-meta', { - enableHcaptcha, + enableHcaptcha: provider === 'hcaptcha', hcaptchaSiteKey, hcaptchaSecretKey, - enableRecaptcha, + enableRecaptcha: provider === 'recaptcha', recaptchaSiteKey, recaptchaSecretKey, }).then(() => { diff --git a/packages/client/src/pages/admin/database.vue b/packages/client/src/pages/admin/database.vue index d3519922b1..ca8718ef63 100644 --- a/packages/client/src/pages/admin/database.vue +++ b/packages/client/src/pages/admin/database.vue @@ -1,12 +1,13 @@ -<template> -<MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> <FormSuspense v-slot="{ result: database }" :p="databasePromiseFactory"> <MkKeyValue v-for="table in database" :key="table[0]" oneline style="margin: 1em 0;"> <template #key>{{ table[0] }}</template> <template #value>{{ bytes(table[1].size) }} ({{ number(table[1].count) }} recs)</template> </MkKeyValue> </FormSuspense> -</MkSpacer> +</MkSpacer></MkStickyContainer> </template> <script lang="ts" setup> @@ -14,18 +15,19 @@ import { } from 'vue'; import FormSuspense from '@/components/form/suspense.vue'; import MkKeyValue from '@/components/key-value.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import bytes from '@/filters/bytes'; import number from '@/filters/number'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const databasePromiseFactory = () => os.api('admin/get-table-stats').then(res => Object.entries(res).sort((a, b) => b[1].size - a[1].size)); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.database, - icon: 'fas fa-database', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.database, + icon: 'fas fa-database', }); </script> diff --git a/packages/client/src/pages/admin/email-settings.vue b/packages/client/src/pages/admin/email-settings.vue index aa13043193..46cfd3db72 100644 --- a/packages/client/src/pages/admin/email-settings.vue +++ b/packages/client/src/pages/admin/email-settings.vue @@ -1,49 +1,53 @@ <template> -<MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> - <FormSuspense :p="init"> - <div class="_formRoot"> - <FormSwitch v-model="enableEmail" class="_formBlock"> - <template #label>{{ i18n.ts.enableEmail }}</template> - <template #caption>{{ i18n.ts.emailConfigInfo }}</template> - </FormSwitch> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="enableEmail" class="_formBlock"> + <template #label>{{ i18n.ts.enableEmail }} ({{ i18n.ts.recommended }})</template> + <template #caption>{{ i18n.ts.emailConfigInfo }}</template> + </FormSwitch> - <template v-if="enableEmail"> - <FormInput v-model="email" type="email" class="_formBlock"> - <template #label>{{ i18n.ts.emailAddress }}</template> - </FormInput> + <template v-if="enableEmail"> + <FormInput v-model="email" type="email" class="_formBlock"> + <template #label>{{ i18n.ts.emailAddress }}</template> + </FormInput> - <FormSection> - <template #label>{{ i18n.ts.smtpConfig }}</template> - <FormSplit :min-width="280"> - <FormInput v-model="smtpHost" class="_formBlock"> - <template #label>{{ i18n.ts.smtpHost }}</template> - </FormInput> - <FormInput v-model="smtpPort" type="number" class="_formBlock"> - <template #label>{{ i18n.ts.smtpPort }}</template> - </FormInput> - </FormSplit> - <FormSplit :min-width="280"> - <FormInput v-model="smtpUser" class="_formBlock"> - <template #label>{{ i18n.ts.smtpUser }}</template> - </FormInput> - <FormInput v-model="smtpPass" type="password" class="_formBlock"> - <template #label>{{ i18n.ts.smtpPass }}</template> - </FormInput> - </FormSplit> - <FormInfo class="_formBlock">{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo> - <FormSwitch v-model="smtpSecure" class="_formBlock"> - <template #label>{{ i18n.ts.smtpSecure }}</template> - <template #caption>{{ i18n.ts.smtpSecureInfo }}</template> - </FormSwitch> - </FormSection> - </template> - </div> - </FormSuspense> -</MkSpacer> + <FormSection> + <template #label>{{ i18n.ts.smtpConfig }}</template> + <FormSplit :min-width="280"> + <FormInput v-model="smtpHost" class="_formBlock"> + <template #label>{{ i18n.ts.smtpHost }}</template> + </FormInput> + <FormInput v-model="smtpPort" type="number" class="_formBlock"> + <template #label>{{ i18n.ts.smtpPort }}</template> + </FormInput> + </FormSplit> + <FormSplit :min-width="280"> + <FormInput v-model="smtpUser" class="_formBlock"> + <template #label>{{ i18n.ts.smtpUser }}</template> + </FormInput> + <FormInput v-model="smtpPass" type="password" class="_formBlock"> + <template #label>{{ i18n.ts.smtpPass }}</template> + </FormInput> + </FormSplit> + <FormInfo class="_formBlock">{{ i18n.ts.emptyToDisableSmtpAuth }}</FormInfo> + <FormSwitch v-model="smtpSecure" class="_formBlock"> + <template #label>{{ i18n.ts.smtpSecure }}</template> + <template #caption>{{ i18n.ts.smtpSecureInfo }}</template> + </FormSwitch> + </FormSection> + </template> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { } from 'vue'; +import XHeader from './_header_.vue'; import FormSwitch from '@/components/form/switch.vue'; import FormInput from '@/components/form/input.vue'; import FormInfo from '@/components/ui/info.vue'; @@ -51,9 +55,9 @@ import FormSuspense from '@/components/form/suspense.vue'; import FormSplit from '@/components/form/split.vue'; import FormSection from '@/components/form/section.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { fetchInstance, instance } from '@/instance'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let enableEmail: boolean = $ref(false); let email: any = $ref(null); @@ -78,13 +82,13 @@ async function testEmail() { const { canceled, result: destination } = await os.inputText({ title: i18n.ts.destination, type: 'email', - placeholder: instance.maintainerEmail + placeholder: instance.maintainerEmail, }); if (canceled) return; os.apiWithDialog('admin/send-email', { to: destination, subject: 'Test email', - text: 'Yo' + text: 'Yo', }); } @@ -102,21 +106,21 @@ function save() { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.emailServer, - icon: 'fas fa-envelope', - bg: 'var(--bg)', - actions: [{ - asFullButton: true, - text: i18n.ts.testEmail, - handler: testEmail, - }, { - asFullButton: true, - icon: 'fas fa-check', - text: i18n.ts.save, - handler: save, - }], - } +const headerActions = $computed(() => [{ + asFullButton: true, + text: i18n.ts.testEmail, + handler: testEmail, +}, { + asFullButton: true, + icon: 'fas fa-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.emailServer, + icon: 'fas fa-envelope', }); </script> diff --git a/packages/client/src/pages/admin/emojis.vue b/packages/client/src/pages/admin/emojis.vue index 8ca5b3d65c..5ed2b14789 100644 --- a/packages/client/src/pages/admin/emojis.vue +++ b/packages/client/src/pages/admin/emojis.vue @@ -1,69 +1,75 @@ <template> -<MkSpacer :content-max="900"> - <div class="ogwlenmc"> - <div v-if="tab === 'local'" class="local"> - <MkInput v-model="query" :debounce="true" type="search"> - <template #prefix><i class="fas fa-search"></i></template> - <template #label>{{ $ts.search }}</template> - </MkInput> - <MkSwitch v-model="selectMode" style="margin: 8px 0;"> - <template #label>Select mode</template> - </MkSwitch> - <div v-if="selectMode" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> - <MkButton inline @click="selectAll">Select all</MkButton> - <MkButton inline @click="setCategoryBulk">Set category</MkButton> - <MkButton inline @click="addTagBulk">Add tag</MkButton> - <MkButton inline @click="removeTagBulk">Remove tag</MkButton> - <MkButton inline @click="setTagBulk">Set tag</MkButton> - <MkButton inline danger @click="delBulk">Delete</MkButton> - </div> - <MkPagination ref="emojisPaginationComponent" :pagination="pagination"> - <template #empty><span>{{ $ts.noCustomEmojis }}</span></template> - <template v-slot="{items}"> - <div class="ldhfsamy"> - <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> - <img :src="emoji.url" class="img" :alt="emoji.name"/> - <div class="body"> - <div class="name _monospace">{{ emoji.name }}</div> - <div class="info">{{ emoji.category }}</div> - </div> - </button> +<div> + <MkStickyContainer> + <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="ogwlenmc"> + <div v-if="tab === 'local'" class="local"> + <MkInput v-model="query" :debounce="true" type="search"> + <template #prefix><i class="fas fa-search"></i></template> + <template #label>{{ $ts.search }}</template> + </MkInput> + <MkSwitch v-model="selectMode" style="margin: 8px 0;"> + <template #label>Select mode</template> + </MkSwitch> + <div v-if="selectMode" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <MkButton inline @click="selectAll">Select all</MkButton> + <MkButton inline @click="setCategoryBulk">Set category</MkButton> + <MkButton inline @click="addTagBulk">Add tag</MkButton> + <MkButton inline @click="removeTagBulk">Remove tag</MkButton> + <MkButton inline @click="setTagBulk">Set tag</MkButton> + <MkButton inline danger @click="delBulk">Delete</MkButton> </div> - </template> - </MkPagination> - </div> + <MkPagination ref="emojisPaginationComponent" :pagination="pagination"> + <template #empty><span>{{ $ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <button v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" :class="{ selected: selectedEmojis.includes(emoji.id) }" @click="selectMode ? toggleSelect(emoji) : edit(emoji)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.category }}</div> + </div> + </button> + </div> + </template> + </MkPagination> + </div> - <div v-else-if="tab === 'remote'" class="remote"> - <FormSplit> - <MkInput v-model="queryRemote" :debounce="true" type="search"> - <template #prefix><i class="fas fa-search"></i></template> - <template #label>{{ $ts.search }}</template> - </MkInput> - <MkInput v-model="host" :debounce="true"> - <template #label>{{ $ts.host }}</template> - </MkInput> - </FormSplit> - <MkPagination :pagination="remotePagination"> - <template #empty><span>{{ $ts.noCustomEmojis }}</span></template> - <template v-slot="{items}"> - <div class="ldhfsamy"> - <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> - <img :src="emoji.url" class="img" :alt="emoji.name"/> - <div class="body"> - <div class="name _monospace">{{ emoji.name }}</div> - <div class="info">{{ emoji.host }}</div> + <div v-else-if="tab === 'remote'" class="remote"> + <FormSplit> + <MkInput v-model="queryRemote" :debounce="true" type="search"> + <template #prefix><i class="fas fa-search"></i></template> + <template #label>{{ $ts.search }}</template> + </MkInput> + <MkInput v-model="host" :debounce="true"> + <template #label>{{ $ts.host }}</template> + </MkInput> + </FormSplit> + <MkPagination :pagination="remotePagination"> + <template #empty><span>{{ $ts.noCustomEmojis }}</span></template> + <template #default="{items}"> + <div class="ldhfsamy"> + <div v-for="emoji in items" :key="emoji.id" class="emoji _panel _button" @click="remoteMenu(emoji, $event)"> + <img :src="emoji.url" class="img" :alt="emoji.name"/> + <div class="body"> + <div class="name _monospace">{{ emoji.name }}</div> + <div class="info">{{ emoji.host }}</div> + </div> + </div> </div> - </div> - </div> - </template> - </MkPagination> - </div> - </div> -</MkSpacer> + </template> + </MkPagination> + </div> + </div> + </MkSpacer> + </MkStickyContainer> +</div> </template> <script lang="ts" setup> import { computed, defineAsyncComponent, defineComponent, ref, toRef } from 'vue'; +import XHeader from './_header_.vue'; import MkButton from '@/components/ui/button.vue'; import MkInput from '@/components/form/input.vue'; import MkPagination from '@/components/ui/pagination.vue'; @@ -72,8 +78,8 @@ import MkSwitch from '@/components/form/switch.vue'; import FormSplit from '@/components/form/split.vue'; import { selectFile, selectFiles } from '@/scripts/select-file'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const emojisPaginationComponent = ref<InstanceType<typeof MkPagination>>(); @@ -131,13 +137,13 @@ const add = async (ev: MouseEvent) => { const edit = (emoji) => { os.popup(defineAsyncComponent(() => import('./emoji-edit-dialog.vue')), { - emoji: emoji + emoji: emoji, }, { done: result => { if (result.updated) { emojisPaginationComponent.value.updateItem(result.updated.id, (oldEmoji: any) => ({ ...oldEmoji, - ...result.updated + ...result.updated, })); } else if (result.deleted) { emojisPaginationComponent.value.removeItem((item) => item.id === emoji.id); @@ -159,7 +165,7 @@ const remoteMenu = (emoji, ev: MouseEvent) => { }, { text: i18n.ts.import, icon: 'fas fa-plus', - action: () => { im(emoji); } + action: () => { im(emoji); }, }], ev.currentTarget ?? ev.target); }; @@ -181,7 +187,7 @@ const menu = (ev: MouseEvent) => { text: err.message, }); }); - } + }, }, { icon: 'fas fa-upload', text: i18n.ts.import, @@ -201,7 +207,7 @@ const menu = (ev: MouseEvent) => { text: err.message, }); }); - } + }, }], ev.currentTarget ?? ev.target); }; @@ -265,31 +271,28 @@ const delBulk = async () => { emojisPaginationComponent.value.reload(); }; -defineExpose({ - [symbols.PAGE_INFO]: computed(() => ({ - title: i18n.ts.customEmojis, - icon: 'fas fa-laugh', - bg: 'var(--bg)', - actions: [{ - asFullButton: true, - icon: 'fas fa-plus', - text: i18n.ts.addEmoji, - handler: add, - }, { - icon: 'fas fa-ellipsis-h', - handler: menu, - }], - tabs: [{ - active: tab.value === 'local', - title: i18n.ts.local, - onClick: () => { tab.value = 'local'; }, - }, { - active: tab.value === 'remote', - title: i18n.ts.remote, - onClick: () => { tab.value = 'remote'; }, - },] - })), -}); +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'fas fa-plus', + text: i18n.ts.addEmoji, + handler: add, +}, { + icon: 'fas fa-ellipsis-h', + handler: menu, +}]); + +const headerTabs = $computed(() => [{ + key: 'local', + title: i18n.ts.local, +}, { + key: 'remote', + title: i18n.ts.remote, +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.customEmojis, + icon: 'fas fa-laugh', +}))); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/admin/file-dialog.vue b/packages/client/src/pages/admin/file-dialog.vue deleted file mode 100644 index 0765548aab..0000000000 --- a/packages/client/src/pages/admin/file-dialog.vue +++ /dev/null @@ -1,103 +0,0 @@ -<template> -<XModalWindow ref="dialog" - :width="370" - @close="$refs.dialog.close()" - @closed="$emit('closed')" -> - <template v-if="file" #header>{{ file.name }}</template> - <div v-if="file" class="cxqhhsmd"> - <div class="_section"> - <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> - <div class="info"> - <span style="margin-right: 1em;">{{ file.type }}</span> - <span>{{ bytes(file.size) }}</span> - <MkTime :time="file.createdAt" mode="detail" style="display: block;"/> - </div> - </div> - <div class="_section"> - <div class="_content"> - <MkSwitch v-model="isSensitive" @update:modelValue="toggleIsSensitive">NSFW</MkSwitch> - </div> - </div> - <div class="_section"> - <div class="_content"> - <MkButton full @click="showUser"><i class="fas fa-external-link-square-alt"></i> {{ $ts.user }}</MkButton> - <MkButton full danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton> - </div> - </div> - <div v-if="info" class="_section"> - <details class="_content rawdata"> - <pre><code>{{ JSON.stringify(info, null, 2) }}</code></pre> - </details> - </div> - </div> -</XModalWindow> -</template> - -<script lang="ts" setup> -import { } from 'vue'; -import MkButton from '@/components/ui/button.vue'; -import MkSwitch from '@/components/form/switch.vue'; -import XModalWindow from '@/components/ui/modal-window.vue'; -import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; -import bytes from '@/filters/bytes'; -import * as os from '@/os'; -import { i18n } from '@/i18n'; - -let file: any = $ref(null); -let info: any = $ref(null); -let isSensitive: boolean = $ref(false); - -const props = defineProps<{ - fileId: string, -}>(); - -async function fetch() { - file = await os.api('drive/files/show', { fileId: props.fileId }); - info = await os.api('admin/drive/show-file', { fileId: props.fileId }); - isSensitive = file.isSensitive; -} - -fetch(); - -function showUser() { - os.pageWindow(`/user-info/${file.userId}`); -} - -async function del() { - const { canceled } = await os.confirm({ - type: 'warning', - text: i18n.t('removeAreYouSure', { x: file.name }), - }); - if (canceled) return; - - os.apiWithDialog('drive/files/delete', { - fileId: file.id - }); -} - -async function toggleIsSensitive(v) { - await os.api('drive/files/update', { fileId: props.fileId, isSensitive: v }); - isSensitive = v; -} -</script> - -<style lang="scss" scoped> -.cxqhhsmd { - > ._section { - > .thumbnail { - height: 150px; - max-width: 100%; - } - - > .info { - text-align: center; - margin-top: 8px; - } - - > .rawdata { - overflow: auto; - } - } -} -</style> diff --git a/packages/client/src/pages/admin/files.vue b/packages/client/src/pages/admin/files.vue index 3cda688698..dd309180a7 100644 --- a/packages/client/src/pages/admin/files.vue +++ b/packages/client/src/pages/admin/files.vue @@ -1,81 +1,61 @@ <template> -<div class="xrmjdkdw"> - <MkContainer :foldable="true" class="lookup"> - <template #header><i class="fas fa-search"></i> {{ $ts.lookup }}</template> - <div class="xrmjdkdw-lookup"> - <MkInput v-model="q" class="item" type="text" @enter="find()"> - <template #label>{{ $ts.fileIdOrUrl }}</template> - </MkInput> - <MkButton primary @click="find()"><i class="fas fa-search"></i> {{ $ts.lookup }}</MkButton> - </div> - </MkContainer> - - <div class="_section"> - <div class="_content"> - <div class="inputs" style="display: flex;"> - <MkSelect v-model="origin" style="margin: 0; flex: 1;"> - <template #label>{{ $ts.instance }}</template> - <option value="combined">{{ $ts.all }}</option> - <option value="local">{{ $ts.local }}</option> - <option value="remote">{{ $ts.remote }}</option> - </MkSelect> - <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'"> - <template #label>{{ $ts.host }}</template> - </MkInput> - </div> - <div class="inputs" style="display: flex; padding-top: 1.2em;"> - <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;"> - <template #label>MIME type</template> - </MkInput> - </div> - <MkPagination v-slot="{items}" :pagination="pagination" class="urempief"> - <button v-for="file in items" :key="file.id" class="file _panel _button _gap" @click="show(file, $event)"> - <MkDriveFileThumbnail class="thumbnail" :file="file" fit="contain"/> - <div class="body"> - <div> - <small style="opacity: 0.7;">{{ file.name }}</small> - </div> - <div> - <MkAcct v-if="file.user" :user="file.user"/> - <div v-else>{{ $ts.system }}</div> - </div> - <div> - <span style="margin-right: 1em;">{{ file.type }}</span> - <span>{{ bytes(file.size) }}</span> - </div> - <div> - <span>{{ $ts.registeredDate }}: <MkTime :time="file.createdAt" mode="detail"/></span> - </div> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions"/></template> + <MkSpacer :content-max="900"> + <div class="xrmjdkdw"> + <div> + <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <MkSelect v-model="origin" style="margin: 0; flex: 1;"> + <template #label>{{ $ts.instance }}</template> + <option value="combined">{{ $ts.all }}</option> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + </MkSelect> + <MkInput v-model="searchHost" :debounce="true" type="search" style="margin: 0; flex: 1;" :disabled="pagination.params.origin === 'local'"> + <template #label>{{ $ts.host }}</template> + </MkInput> </div> - </button> - </MkPagination> - </div> - </div> + <div class="inputs" style="display: flex; gap: var(--margin); flex-wrap: wrap; padding-top: 1.2em;"> + <MkInput v-model="userId" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <template #label>User ID</template> + </MkInput> + <MkInput v-model="type" :debounce="true" type="search" style="margin: 0; flex: 1;"> + <template #label>MIME type</template> + </MkInput> + </div> + <MkFileListForAdmin :pagination="pagination" :view-mode="viewMode"/> + </div> + </div> + </MkSpacer> + </MkStickyContainer> </div> </template> <script lang="ts" setup> import { computed, defineAsyncComponent } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import XHeader from './_header_.vue'; import MkButton from '@/components/ui/button.vue'; import MkInput from '@/components/form/input.vue'; import MkSelect from '@/components/form/select.vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkContainer from '@/components/ui/container.vue'; -import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; +import MkFileListForAdmin from '@/components/file-list-for-admin.vue'; import bytes from '@/filters/bytes'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; -let q = $ref(null); let origin = $ref('local'); let type = $ref(null); let searchHost = $ref(''); +let userId = $ref(''); +let viewMode = $ref('grid'); const pagination = { endpoint: 'admin/drive/files' as const, limit: 10, params: computed(() => ({ type: (type && type !== '') ? type : null, + userId: (userId && userId !== '') ? userId : null, origin: origin, hostname: (searchHost && searchHost !== '') ? searchHost : null, })), @@ -93,83 +73,48 @@ function clear() { } function show(file) { - os.popup(defineAsyncComponent(() => import('./file-dialog.vue')), { - fileId: file.id - }, {}, 'closed'); + os.pageWindow(`/admin/file/${file.id}`); } -function find() { +async function find() { + const { canceled, result: q } = await os.inputText({ + title: i18n.ts.fileIdOrUrl, + allowEmpty: false, + }); + if (canceled) return; + os.api('admin/drive/show-file', q.startsWith('http://') || q.startsWith('https://') ? { url: q.trim() } : { fileId: q.trim() }).then(file => { show(file); }).catch(err => { if (err.code === 'NO_SUCH_FILE') { os.alert({ type: 'error', - text: i18n.ts.notFound + text: i18n.ts.notFound, }); } }); } -defineExpose({ - [symbols.PAGE_INFO]: computed(() => ({ - title: i18n.ts.files, - icon: 'fas fa-cloud', - bg: 'var(--bg)', - actions: [{ - text: i18n.ts.clearCachedFiles, - icon: 'fas fa-trash-alt', - handler: clear, - }], - })), -}); +const headerActions = $computed(() => [{ + text: i18n.ts.lookup, + icon: 'fas fa-search', + handler: find, +}, { + text: i18n.ts.clearCachedFiles, + icon: 'fas fa-trash-alt', + handler: clear, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.files, + icon: 'fas fa-cloud', +}))); </script> <style lang="scss" scoped> .xrmjdkdw { margin: var(--margin); - - > .lookup { - margin-bottom: 16px; - } - - .urempief { - margin-top: var(--margin); - - > .file { - display: flex; - width: 100%; - box-sizing: border-box; - text-align: left; - align-items: center; - - &:hover { - color: var(--accent); - } - - > .thumbnail { - width: 128px; - height: 128px; - } - - > .body { - margin-left: 0.3em; - padding: 8px; - flex: 1; - - @media (max-width: 500px) { - font-size: 14px; - } - } - } - } -} - -.xrmjdkdw-lookup { - padding: 16px; - - > .item { - margin-bottom: 16px; - } } </style> diff --git a/packages/client/src/pages/admin/index.vue b/packages/client/src/pages/admin/index.vue index 9b7fa5678e..f0ac5b3fc9 100644 --- a/packages/client/src/pages/admin/index.vue +++ b/packages/client/src/pages/admin/index.vue @@ -1,50 +1,46 @@ <template> <div ref="el" class="hiyeyicy" :class="{ wide: !narrow }"> - <div v-if="!narrow || initialPage == null" class="nav"> - <MkHeader :info="header"></MkHeader> - + <div v-if="!narrow || initialPage == null" class="nav"> <MkSpacer :content-max="700" :margin-min="16"> <div class="lxpfedzu"> <div class="banner"> <img :src="$instance.iconUrl || '/favicon.ico'" alt="" class="icon"/> </div> + <MkInfo v-if="thereIsUnresolvedAbuseReport" warn class="info">{{ $ts.thereIsUnresolvedAbuseReportWarning }} <MkA to="/admin/abuses" class="_link">{{ $ts.check }}</MkA></MkInfo> <MkInfo v-if="noMaintainerInformation" warn class="info">{{ $ts.noMaintainerInformationWarning }} <MkA to="/admin/settings" class="_link">{{ $ts.configure }}</MkA></MkInfo> <MkInfo v-if="noBotProtection" warn class="info">{{ $ts.noBotProtectionWarning }} <MkA to="/admin/security" class="_link">{{ $ts.configure }}</MkA></MkInfo> + <MkInfo v-if="noEmailServer" warn class="info">{{ $ts.noEmailServerWarning }} <MkA to="/admin/email-settings" class="_link">{{ $ts.configure }}</MkA></MkInfo> <MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu> </div> </MkSpacer> </div> <div v-if="!(narrow && initialPage == null)" class="main"> - <MkStickyContainer> - <template #header><MkHeader v-if="childInfo && !childInfo.hideHeader" :info="childInfo"/></template> - <component :is="component" :ref="el => pageChanged(el)" :key="initialPage" v-bind="pageProps"/> - </MkStickyContainer> + <component :is="component" :key="initialPage" v-bind="pageProps"/> </div> </div> </template> <script lang="ts" setup> -import { defineAsyncComponent, nextTick, onMounted, onUnmounted, provide, watch } from 'vue'; +import { defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, watch } from 'vue'; import { i18n } from '@/i18n'; import MkSuperMenu from '@/components/ui/super-menu.vue'; import MkInfo from '@/components/ui/info.vue'; import { scroll } from '@/scripts/scroll'; import { instance } from '@/instance'; -import * as symbols from '@/symbols'; import * as os from '@/os'; import { lookupUser } from '@/scripts/lookup-user'; -import { MisskeyNavigator } from '@/scripts/navigate'; +import { useRouter } from '@/router'; +import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; const isEmpty = (x: string | null) => x == null || x === ''; -const nav = new MisskeyNavigator(); +const router = useRouter(); const indexInfo = { title: i18n.ts.controlPanel, icon: 'fas fa-cog', - bg: 'var(--bg)', hideHeader: true, }; @@ -63,6 +59,15 @@ let el = $ref(null); let pageProps = $ref({}); let noMaintainerInformation = isEmpty(instance.maintainerName) || isEmpty(instance.maintainerEmail); let noBotProtection = !instance.enableHcaptcha && !instance.enableRecaptcha; +let noEmailServer = !instance.enableEmail; +let thereIsUnresolvedAbuseReport = $ref(false); + +os.api('admin/abuse-user-reports', { + state: 'unresolved', + limit: 1, +}).then(reports => { + if (reports.length > 0) thereIsUnresolvedAbuseReport = true; +}); const NARROW_THRESHOLD = 600; const ro = new ResizeObserver((entries, observer) => { @@ -103,7 +108,7 @@ const menuDef = $computed(() => [{ }, { icon: 'fas fa-globe', text: i18n.ts.federation, - to: '/admin/federation', + to: '/about#federation', active: props.initialPage === 'federation', }, { icon: 'fas fa-clipboard-list', @@ -195,7 +200,7 @@ const component = $computed(() => { case 'overview': return defineAsyncComponent(() => import('./overview.vue')); case 'users': return defineAsyncComponent(() => import('./users.vue')); case 'emojis': return defineAsyncComponent(() => import('./emojis.vue')); - case 'federation': return defineAsyncComponent(() => import('../federation.vue')); + //case 'federation': return defineAsyncComponent(() => import('../federation.vue')); case 'queue': return defineAsyncComponent(() => import('./queue.vue')); case 'files': return defineAsyncComponent(() => import('./files.vue')); case 'announcements': return defineAsyncComponent(() => import('./announcements.vue')); @@ -224,7 +229,7 @@ watch(component, () => { watch(() => props.initialPage, () => { if (props.initialPage == null && !narrow) { - nav.push('/admin/overview'); + router.push('/admin/overview'); } else { if (props.initialPage == null) { INFO = indexInfo; @@ -234,7 +239,7 @@ watch(() => props.initialPage, () => { watch(narrow, () => { if (props.initialPage == null && !narrow) { - nav.push('/admin/overview'); + router.push('/admin/overview'); } }); @@ -243,7 +248,7 @@ onMounted(() => { narrow = el.offsetWidth < NARROW_THRESHOLD; if (props.initialPage == null && !narrow) { - nav.push('/admin/overview'); + router.push('/admin/overview'); } }); @@ -251,19 +256,19 @@ onUnmounted(() => { ro.disconnect(); }); -const pageChanged = (page) => { - if (page == null) { +provideMetadataReceiver((info) => { + if (info == null) { childInfo = null; } else { - childInfo = page[symbols.PAGE_INFO]; + childInfo = info; } -}; +}); const invite = () => { os.api('admin/invite').then(x => { os.alert({ type: 'info', - text: x.code + text: x.code, }); }).catch(err => { os.alert({ @@ -279,33 +284,38 @@ const lookup = (ev) => { icon: 'fas fa-user', action: () => { lookupUser(); - } + }, }, { text: i18n.ts.note, icon: 'fas fa-pencil-alt', action: () => { alert('TODO'); - } + }, }, { text: i18n.ts.file, icon: 'fas fa-cloud', action: () => { alert('TODO'); - } + }, }, { text: i18n.ts.instance, icon: 'fas fa-globe', action: () => { alert('TODO'); - } + }, }], ev.currentTarget ?? ev.target); }; +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(INFO); + defineExpose({ - [symbols.PAGE_INFO]: INFO, header: { title: i18n.ts.controlPanel, - } + }, }); </script> diff --git a/packages/client/src/pages/admin/instance-block.vue b/packages/client/src/pages/admin/instance-block.vue index 3347846a80..6d479e8f0d 100644 --- a/packages/client/src/pages/admin/instance-block.vue +++ b/packages/client/src/pages/admin/instance-block.vue @@ -1,25 +1,29 @@ <template> -<MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> - <FormSuspense :p="init"> - <FormTextarea v-model="blockedHosts" class="_formBlock"> - <span>{{ i18n.ts.blockedInstances }}</span> - <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template> - </FormTextarea> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <FormTextarea v-model="blockedHosts" class="_formBlock"> + <span>{{ i18n.ts.blockedInstances }}</span> + <template #caption>{{ i18n.ts.blockedInstancesDescription }}</template> + </FormTextarea> - <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton> - </FormSuspense> -</MkSpacer> + <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { } from 'vue'; +import XHeader from './_header_.vue'; import FormButton from '@/components/ui/button.vue'; import FormTextarea from '@/components/form/textarea.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { fetchInstance } from '@/instance'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let blockedHosts: string = $ref(''); @@ -36,11 +40,12 @@ function save() { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.instanceBlocking, - icon: 'fas fa-ban', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.instanceBlocking, + icon: 'fas fa-ban', }); </script> diff --git a/packages/client/src/pages/admin/integrations.vue b/packages/client/src/pages/admin/integrations.vue index d6061d0e51..9964426a68 100644 --- a/packages/client/src/pages/admin/integrations.vue +++ b/packages/client/src/pages/admin/integrations.vue @@ -1,5 +1,6 @@ -<template> -<MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> <FormSuspense :p="init"> <FormFolder class="_formBlock"> <template #icon><i class="fab fa-twitter"></i></template> @@ -20,19 +21,19 @@ <XDiscord/> </FormFolder> </FormSuspense> -</MkSpacer> +</MkSpacer></MkStickyContainer> </template> <script lang="ts" setup> import { } from 'vue'; -import FormFolder from '@/components/form/folder.vue'; -import FormSuspense from '@/components/form/suspense.vue'; import XTwitter from './integrations.twitter.vue'; import XGithub from './integrations.github.vue'; import XDiscord from './integrations.discord.vue'; +import FormSuspense from '@/components/form/suspense.vue'; +import FormFolder from '@/components/form/folder.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let enableTwitterIntegration: boolean = $ref(false); let enableGithubIntegration: boolean = $ref(false); @@ -45,11 +46,12 @@ async function init() { enableDiscordIntegration = meta.enableDiscordIntegration; } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.integration, - icon: 'fas fa-share-alt', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.integration, + icon: 'fas fa-share-alt', }); </script> diff --git a/packages/client/src/pages/admin/object-storage.vue b/packages/client/src/pages/admin/object-storage.vue index d109db9c38..5cc3018532 100644 --- a/packages/client/src/pages/admin/object-storage.vue +++ b/packages/client/src/pages/admin/object-storage.vue @@ -1,82 +1,85 @@ <template> -<MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> - <FormSuspense :p="init"> - <div class="_formRoot"> - <FormSwitch v-model="useObjectStorage" class="_formBlock">{{ i18n.ts.useObjectStorage }}</FormSwitch> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormSwitch v-model="useObjectStorage" class="_formBlock">{{ i18n.ts.useObjectStorage }}</FormSwitch> - <template v-if="useObjectStorage"> - <FormInput v-model="objectStorageBaseUrl" class="_formBlock"> - <template #label>{{ i18n.ts.objectStorageBaseUrl }}</template> - <template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template> - </FormInput> - - <FormInput v-model="objectStorageBucket" class="_formBlock"> - <template #label>{{ i18n.ts.objectStorageBucket }}</template> - <template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template> - </FormInput> - - <FormInput v-model="objectStoragePrefix" class="_formBlock"> - <template #label>{{ i18n.ts.objectStoragePrefix }}</template> - <template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template> - </FormInput> + <template v-if="useObjectStorage"> + <FormInput v-model="objectStorageBaseUrl" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageBaseUrl }}</template> + <template #caption>{{ i18n.ts.objectStorageBaseUrlDesc }}</template> + </FormInput> - <FormInput v-model="objectStorageEndpoint" class="_formBlock"> - <template #label>{{ i18n.ts.objectStorageEndpoint }}</template> - <template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template> - </FormInput> + <FormInput v-model="objectStorageBucket" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageBucket }}</template> + <template #caption>{{ i18n.ts.objectStorageBucketDesc }}</template> + </FormInput> - <FormInput v-model="objectStorageRegion" class="_formBlock"> - <template #label>{{ i18n.ts.objectStorageRegion }}</template> - <template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template> - </FormInput> + <FormInput v-model="objectStoragePrefix" class="_formBlock"> + <template #label>{{ i18n.ts.objectStoragePrefix }}</template> + <template #caption>{{ i18n.ts.objectStoragePrefixDesc }}</template> + </FormInput> - <FormSplit :min-width="280"> - <FormInput v-model="objectStorageAccessKey" class="_formBlock"> - <template #prefix><i class="fas fa-key"></i></template> - <template #label>Access key</template> + <FormInput v-model="objectStorageEndpoint" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageEndpoint }}</template> + <template #caption>{{ i18n.ts.objectStorageEndpointDesc }}</template> </FormInput> - <FormInput v-model="objectStorageSecretKey" class="_formBlock"> - <template #prefix><i class="fas fa-key"></i></template> - <template #label>Secret key</template> + <FormInput v-model="objectStorageRegion" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageRegion }}</template> + <template #caption>{{ i18n.ts.objectStorageRegionDesc }}</template> </FormInput> - </FormSplit> - <FormSwitch v-model="objectStorageUseSSL" class="_formBlock"> - <template #label>{{ i18n.ts.objectStorageUseSSL }}</template> - <template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template> - </FormSwitch> + <FormSplit :min-width="280"> + <FormInput v-model="objectStorageAccessKey" class="_formBlock"> + <template #prefix><i class="fas fa-key"></i></template> + <template #label>Access key</template> + </FormInput> - <FormSwitch v-model="objectStorageUseProxy" class="_formBlock"> - <template #label>{{ i18n.ts.objectStorageUseProxy }}</template> - <template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template> - </FormSwitch> + <FormInput v-model="objectStorageSecretKey" class="_formBlock"> + <template #prefix><i class="fas fa-key"></i></template> + <template #label>Secret key</template> + </FormInput> + </FormSplit> - <FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock"> - <template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template> - </FormSwitch> + <FormSwitch v-model="objectStorageUseSSL" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageUseSSL }}</template> + <template #caption>{{ i18n.ts.objectStorageUseSSLDesc }}</template> + </FormSwitch> - <FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock"> - <template #label>s3ForcePathStyle</template> - </FormSwitch> - </template> - </div> - </FormSuspense> -</MkSpacer> + <FormSwitch v-model="objectStorageUseProxy" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageUseProxy }}</template> + <template #caption>{{ i18n.ts.objectStorageUseProxyDesc }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageSetPublicRead" class="_formBlock"> + <template #label>{{ i18n.ts.objectStorageSetPublicRead }}</template> + </FormSwitch> + + <FormSwitch v-model="objectStorageS3ForcePathStyle" class="_formBlock"> + <template #label>s3ForcePathStyle</template> + </FormSwitch> + </template> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { } from 'vue'; +import XHeader from './_header_.vue'; import FormSwitch from '@/components/form/switch.vue'; import FormInput from '@/components/form/input.vue'; -import FormGroup from '@/components/form/group.vue'; import FormSuspense from '@/components/form/suspense.vue'; import FormSplit from '@/components/form/split.vue'; import FormSection from '@/components/form/section.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { fetchInstance } from '@/instance'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let useObjectStorage: boolean = $ref(false); let objectStorageBaseUrl: string | null = $ref(null); @@ -129,17 +132,17 @@ function save() { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.objectStorage, - icon: 'fas fa-cloud', - bg: 'var(--bg)', - actions: [{ - asFullButton: true, - icon: 'fas fa-check', - text: i18n.ts.save, - handler: save, - }], - } +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'fas fa-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.objectStorage, + icon: 'fas fa-cloud', }); </script> diff --git a/packages/client/src/pages/admin/other-settings.vue b/packages/client/src/pages/admin/other-settings.vue index 552b05f347..ee4e8edba0 100644 --- a/packages/client/src/pages/admin/other-settings.vue +++ b/packages/client/src/pages/admin/other-settings.vue @@ -1,18 +1,22 @@ <template> -<MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> - <FormSuspense :p="init"> - none - </FormSuspense> -</MkSpacer> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + none + </FormSuspense> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { } from 'vue'; +import XHeader from './_header_.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { fetchInstance } from '@/instance'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; async function init() { await os.api('admin/meta'); @@ -24,17 +28,17 @@ function save() { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.other, - icon: 'fas fa-cogs', - bg: 'var(--bg)', - actions: [{ - asFullButton: true, - icon: 'fas fa-check', - text: i18n.ts.save, - handler: save, - }], - } +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'fas fa-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.other, + icon: 'fas fa-cogs', }); </script> diff --git a/packages/client/src/pages/admin/overview.federation.vue b/packages/client/src/pages/admin/overview.federation.vue new file mode 100644 index 0000000000..6c99cad33c --- /dev/null +++ b/packages/client/src/pages/admin/overview.federation.vue @@ -0,0 +1,100 @@ +<template> +<div class="wbrkwale"> + <MkLoading v-if="fetching"/> + <transition-group v-else tag="div" :name="$store.state.animation ? 'chart' : ''" class="instances"> + <MkA v-for="(instance, i) in instances" :key="instance.id" :to="`/instance-info/${instance.host}`" class="instance"> + <img v-if="instance.iconUrl" :src="instance.iconUrl" alt=""/> + <div class="body"> + <div class="name">{{ instance.name ?? instance.host }}</div> + <div class="host">{{ instance.host }}</div> + </div> + <MkMiniChart class="chart" :src="charts[i].requests.received"/> + </MkA> + </transition-group> +</div> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import MkMiniChart from '@/components/mini-chart.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; + +const instances = ref([]); +const charts = ref([]); +const fetching = ref(true); + +const fetch = async () => { + const fetchedInstances = await os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 5, + }); + const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.apiGet('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); + instances.value = fetchedInstances; + charts.value = fetchedCharts; + fetching.value = false; +}; + +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.wbrkwale { + > .instances { + .chart-move { + transition: transform 1s ease; + } + + > .instance { + display: flex; + align-items: center; + padding: 16px 20px; + + &:not(:last-child) { + border-bottom: solid 0.5px var(--divider); + } + + > img { + display: block; + width: 34px; + height: 34px; + object-fit: cover; + border-radius: 4px; + margin-right: 12px; + } + + > .body { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; + + > .name { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > .host { + margin: 0; + font-size: 75%; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + > .chart { + height: 30px; + } + } + } +} +</style> diff --git a/packages/client/src/pages/admin/overview.pie.vue b/packages/client/src/pages/admin/overview.pie.vue new file mode 100644 index 0000000000..d3b2032876 --- /dev/null +++ b/packages/client/src/pages/admin/overview.pie.vue @@ -0,0 +1,108 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + DoughnutController, +} from 'chart.js'; +import number from '@/filters/number'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + DoughnutController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + data: { name: string; value: number; color: string; onClick?: () => void }[]; +}>(); + +const chartEl = ref<HTMLCanvasElement>(null); + +// フォントカラー +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +onMounted(() => { + chartInstance = new Chart(chartEl.value, { + type: 'doughnut', + data: { + labels: props.data.map(x => x.name), + datasets: [{ + backgroundColor: props.data.map(x => x.color), + borderColor: getComputedStyle(document.documentElement).getPropertyValue('--panel'), + borderWidth: 2, + hoverOffset: 0, + data: props.data.map(x => x.value), + }], + }, + options: { + layout: { + padding: { + left: 16, + right: 16, + top: 16, + bottom: 16, + }, + }, + onClick: (ev) => { + const hit = chartInstance.getElementsAtEventForMode(ev, 'nearest', { intersect: true }, false)[0]; + if (hit && props.data[hit.index].onClick) { + props.data[hit.index].onClick(); + } + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + }); +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/admin/overview.queue-chart.vue b/packages/client/src/pages/admin/overview.queue-chart.vue new file mode 100644 index 0000000000..a2b748ad38 --- /dev/null +++ b/packages/client/src/pages/admin/overview.queue-chart.vue @@ -0,0 +1,211 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { defineComponent, onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + domain: string; + connection: any; +}>(); + +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + +const chartEl = ref<HTMLCanvasElement>(null); + +const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + +// フォントカラー +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +const onStats = (stats) => { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); + chartInstance.data.datasets[1].data.push(stats[props.domain].active); + chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); + chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); + if (chartInstance.data.datasets[0].data.length > 100) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + chartInstance.data.datasets[1].data.shift(); + chartInstance.data.datasets[2].data.shift(); + chartInstance.data.datasets[3].data.shift(); + } + chartInstance.update(); +}; + +const onStatsLog = (statsLog) => { + for (const stats of [...statsLog].reverse()) { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(stats[props.domain].activeSincePrevTick); + chartInstance.data.datasets[1].data.push(stats[props.domain].active); + chartInstance.data.datasets[2].data.push(stats[props.domain].waiting); + chartInstance.data.datasets[3].data.push(stats[props.domain].delayed); + if (chartInstance.data.datasets[0].data.length > 100) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + chartInstance.data.datasets[1].data.shift(); + chartInstance.data.datasets[2].data.shift(); + chartInstance.data.datasets[3].data.shift(); + } + } + chartInstance.update(); +}; + +onMounted(() => { + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: 'Process', + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#00E396', + backgroundColor: alpha('#00E396', 0.1), + data: [], + }, { + label: 'Active', + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#00BCD4', + backgroundColor: alpha('#00BCD4', 0.1), + data: [], + }, { + label: 'Waiting', + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#FFB300', + backgroundColor: alpha('#FFB300', 0.1), + data: [], + }, { + label: 'Delayed', + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: '#E53935', + borderDash: [5, 5], + fill: false, + data: [], + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + display: false, + grid: { + display: false, + }, + ticks: { + display: false, + maxTicksLimit: 10, + }, + }, + y: { + display: false, + min: 0, + grid: { + display: false, + }, + ticks: { + display: false, + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + }); + + props.connection.on('stats', onStats); + props.connection.on('statsLog', onStatsLog); +}); + +onUnmounted(() => { + props.connection.off('stats', onStats); + props.connection.off('statsLog', onStatsLog); +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/admin/overview.user.vue b/packages/client/src/pages/admin/overview.user.vue new file mode 100644 index 0000000000..d70336f3c2 --- /dev/null +++ b/packages/client/src/pages/admin/overview.user.vue @@ -0,0 +1,76 @@ +<template> +<MkA :class="[$style.root]" :to="`/user-info/${user.id}`"> + <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/> + <div class="body"> + <span class="name"><MkUserName class="name" :user="user"/></span> + <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> + </div> + <MkMiniChart v-if="chart" class="chart" :src="chart.inc"/> +</MkA> +</template> + +<script lang="ts" setup> +import * as misskey from 'misskey-js'; +import MkMiniChart from '@/components/mini-chart.vue'; +import * as os from '@/os'; +import { acct } from '@/filters/user'; + +const props = defineProps<{ + user: misskey.entities.User; +}>(); + +let chart = $ref(null); + +os.apiGet('charts/user/notes', { userId: props.user.id, limit: 16, span: 'day' }).then(res => { + chart = res; +}); +</script> + +<style lang="scss" module> +.root { + $bodyTitleHieght: 18px; + $bodyInfoHieght: 16px; + + display: flex; + align-items: center; + + > :global(.avatar) { + display: block; + width: ($bodyTitleHieght + $bodyInfoHieght); + height: ($bodyTitleHieght + $bodyInfoHieght); + margin-right: 12px; + } + + > :global(.body) { + flex: 1; + overflow: hidden; + font-size: 0.9em; + color: var(--fg); + padding-right: 8px; + + > :global(.name) { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + line-height: $bodyTitleHieght; + } + + > :global(.sub) { + display: block; + width: 100%; + font-size: 95%; + opacity: 0.7; + line-height: $bodyInfoHieght; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + + > :global(.chart) { + height: 30px; + } +} +</style> diff --git a/packages/client/src/pages/admin/overview.vue b/packages/client/src/pages/admin/overview.vue index cc69424c3b..7e085106b9 100644 --- a/packages/client/src/pages/admin/overview.vue +++ b/packages/client/src/pages/admin/overview.vue @@ -1,112 +1,458 @@ <template> -<div v-size="{ max: [740] }" class="edbbcaef"> - <div v-if="stats" class="cfcdecdf" style="margin: var(--margin)"> - <div class="number _panel"> - <div class="label">Users</div> - <div class="value _monospace"> - {{ number(stats.originalUsersCount) }} - <MkNumberDiff v-if="usersComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff> +<MkSpacer :content-max="900"> + <div ref="rootEl" v-size="{ max: [740] }" class="edbbcaef"> + <div class="left"> + <div v-if="stats" class="container stats"> + <div class="title">Stats</div> + <div class="body"> + <div class="number _panel"> + <div class="label">Users</div> + <div class="value _monospace"> + {{ number(stats.originalUsersCount) }} + <MkNumberDiff v-if="usersComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="usersComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff> + </div> + </div> + <div class="number _panel"> + <div class="label">Notes</div> + <div class="value _monospace"> + {{ number(stats.originalNotesCount) }} + <MkNumberDiff v-if="notesComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff> + </div> + </div> + </div> </div> - </div> - <div class="number _panel"> - <div class="label">Notes</div> - <div class="value _monospace"> - {{ number(stats.originalNotesCount) }} - <MkNumberDiff v-if="notesComparedToThePrevDay != null" v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="notesComparedToThePrevDay"><template #before>(</template><template #after>)</template></MkNumberDiff> - </div> - </div> - </div> - <MkContainer :foldable="true" class="charts"> - <template #header><i class="fas fa-chart-bar"></i>{{ i18n.ts.charts }}</template> - <div style="padding: 12px;"> - <MkInstanceStats :chart-limit="500" :detailed="true"/> - </div> - </MkContainer> + <div class="container queue"> + <div class="title">Job queue</div> + <div class="body"> + <div class="chart deliver"> + <div class="title">Deliver</div> + <XQueueChart :connection="queueStatsConnection" domain="deliver"/> + </div> + <div class="chart inbox"> + <div class="title">Inbox</div> + <XQueueChart :connection="queueStatsConnection" domain="inbox"/> + </div> + </div> + </div> - <div class="queue"> - <MkContainer :foldable="true" :thin="true" class="deliver"> - <template #header>Queue: deliver</template> - <MkQueueChart :connection="queueStatsConnection" domain="deliver"/> - </MkContainer> - <MkContainer :foldable="true" :thin="true" class="inbox"> - <template #header>Queue: inbox</template> - <MkQueueChart :connection="queueStatsConnection" domain="inbox"/> - </MkContainer> - </div> + <div class="container users"> + <div class="title">New users</div> + <div v-if="newUsers" class="body"> + <XUser v-for="user in newUsers" :key="user.id" class="user" :user="user"/> + </div> + </div> - <!--<XMetrics/>--> + <div class="container files"> + <div class="title">Recent files</div> + <div class="body"> + <MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/> + </div> + </div> - <MkFolder style="margin: var(--margin)"> - <template #header><i class="fas fa-info-circle"></i> {{ i18n.ts.info }}</template> - <div class="cfcdecdf"> - <div class="number _panel"> - <div class="label">Misskey</div> - <div class="value _monospace">{{ version }}</div> + <div class="container env"> + <div class="title">Enviroment</div> + <div class="body"> + <div class="number _panel"> + <div class="label">Misskey</div> + <div class="value _monospace">{{ version }}</div> + </div> + <div v-if="serverInfo" class="number _panel"> + <div class="label">Node.js</div> + <div class="value _monospace">{{ serverInfo.node }}</div> + </div> + <div v-if="serverInfo" class="number _panel"> + <div class="label">PostgreSQL</div> + <div class="value _monospace">{{ serverInfo.psql }}</div> + </div> + <div v-if="serverInfo" class="number _panel"> + <div class="label">Redis</div> + <div class="value _monospace">{{ serverInfo.redis }}</div> + </div> + <div class="number _panel"> + <div class="label">Vue</div> + <div class="value _monospace">{{ vueVersion }}</div> + </div> + </div> + </div> + </div> + <div class="right"> + <div class="container charts"> + <div class="title">Active users</div> + <div class="body"> + <canvas ref="chartEl"></canvas> + </div> </div> - <div v-if="serverInfo" class="number _panel"> - <div class="label">Node.js</div> - <div class="value _monospace">{{ serverInfo.node }}</div> + <div class="container federation"> + <div class="title">Active instances</div> + <div class="body"> + <XFederation/> + </div> </div> - <div v-if="serverInfo" class="number _panel"> - <div class="label">PostgreSQL</div> - <div class="value _monospace">{{ serverInfo.psql }}</div> + <div v-if="stats" class="container federationStats"> + <div class="title">Federation</div> + <div class="body"> + <div class="number _panel"> + <div class="label">Sub</div> + <div class="value _monospace"> + {{ number(federationSubActive) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationSubActiveDiff"><template #before>(</template><template #after>)</template></MkNumberDiff> + </div> + </div> + <div class="number _panel"> + <div class="label">Pub</div> + <div class="value _monospace"> + {{ number(federationPubActive) }} + <MkNumberDiff v-tooltip="i18n.ts.dayOverDayChanges" class="diff" :value="federationPubActiveDiff"><template #before>(</template><template #after>)</template></MkNumberDiff> + </div> + </div> + </div> </div> - <div v-if="serverInfo" class="number _panel"> - <div class="label">Redis</div> - <div class="value _monospace">{{ serverInfo.redis }}</div> + <div class="container tagCloud"> + <div class="body"> + <MkTagCloud v-if="activeInstances"> + <li v-for="instance in activeInstances"> + <a @click.prevent="onInstanceClick(instance)"> + <img style="width: 32px;" :src="instance.iconUrl"> + </a> + </li> + </MkTagCloud> + </div> </div> - <div class="number _panel"> - <div class="label">Vue</div> - <div class="value _monospace">{{ vueVersion }}</div> + <div v-if="topSubInstancesForPie && topPubInstancesForPie" class="container federationPies"> + <div class="body"> + <div class="chart deliver"> + <div class="title">Sub</div> + <XPie :data="topSubInstancesForPie"/> + <div class="subTitle">Top 10</div> + </div> + <div class="chart inbox"> + <div class="title">Pub</div> + <XPie :data="topPubInstancesForPie"/> + <div class="subTitle">Top 10</div> + </div> + </div> </div> </div> - </MkFolder> -</div> + </div> +</MkSpacer> </template> <script lang="ts" setup> import { markRaw, version as vueVersion, onMounted, onBeforeUnmount, nextTick } from 'vue'; -import MkInstanceStats from '@/components/instance-stats.vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import { enUS } from 'date-fns/locale'; +import tinycolor from 'tinycolor2'; +import MagicGrid from 'magic-grid'; +import XMetrics from './metrics.vue'; +import XFederation from './overview.federation.vue'; +import XQueueChart from './overview.queue-chart.vue'; +import XUser from './overview.user.vue'; +import XPie from './overview.pie.vue'; import MkNumberDiff from '@/components/number-diff.vue'; -import MkContainer from '@/components/ui/container.vue'; -import MkFolder from '@/components/ui/folder.vue'; -import MkQueueChart from '@/components/queue-chart.vue'; +import MkTagCloud from '@/components/tag-cloud.vue'; import { version, url } from '@/config'; import number from '@/filters/number'; -import XMetrics from './metrics.vue'; import * as os from '@/os'; import { stream } from '@/stream'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import 'chartjs-adapter-date-fns'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; +import MkFileListForAdmin from '@/components/file-list-for-admin.vue'; +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, + //gradient, +); + +const rootEl = $ref<HTMLElement>(); +const chartEl = $ref<HTMLCanvasElement>(null); let stats: any = $ref(null); let serverInfo: any = $ref(null); +let topSubInstancesForPie: any = $ref(null); +let topPubInstancesForPie: any = $ref(null); let usersComparedToThePrevDay: any = $ref(null); let notesComparedToThePrevDay: any = $ref(null); +let federationPubActive = $ref<number | null>(null); +let federationPubActiveDiff = $ref<number | null>(null); +let federationSubActive = $ref<number | null>(null); +let federationSubActiveDiff = $ref<number | null>(null); +let newUsers = $ref(null); +let activeInstances = $shallowRef(null); const queueStatsConnection = markRaw(stream.useChannel('queueStats')); +const now = new Date(); +let chartInstance: Chart = null; +const chartLimit = 30; +const filesPagination = { + endpoint: 'admin/drive/files' as const, + limit: 9, + noPaging: true, +}; + +const { handler: externalTooltipHandler } = useChartTooltip(); + +async function renderChart() { + if (chartInstance) { + chartInstance.destroy(); + } + + const getDate = (ago: number) => { + const y = now.getFullYear(); + const m = now.getMonth(); + const d = now.getDate(); + + return new Date(y, m, d - ago); + }; + + const format = (arr) => { + return arr.map((v, i) => ({ + x: getDate(i).getTime(), + y: v, + })); + }; + + const raw = await os.api('charts/active-users', { limit: chartLimit, span: 'day' }); + + const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + const vLineColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.2)'; + + // フォントカラー + Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + + const color = tinycolor(getComputedStyle(document.documentElement).getPropertyValue('--accent')); + + chartInstance = new Chart(chartEl, { + type: 'bar', + data: { + //labels: new Array(props.limit).fill(0).map((_, i) => getDate(i).toLocaleString()).slice().reverse(), + datasets: [{ + parsing: false, + label: 'a', + data: format(raw.readWrite).slice().reverse(), + tension: 0.3, + pointRadius: 0, + borderWidth: 0, + borderJoinStyle: 'round', + borderRadius: 3, + backgroundColor: color, + /*gradient: props.bar ? undefined : { + backgroundColor: { + axis: 'y', + colors: { + 0: alpha(x.color ? x.color : getColor(i), 0), + [maxes[i]]: alpha(x.color ? x.color : getColor(i), 0.2), + }, + }, + },*/ + barPercentage: 0.9, + categoryPercentage: 0.9, + clip: 8, + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + type: 'time', + display: false, + stacked: true, + offset: false, + time: { + stepSize: 1, + unit: 'month', + }, + grid: { + display: false, + }, + ticks: { + display: false, + }, + adapters: { + date: { + locale: enUS, + }, + }, + min: getDate(chartLimit).getTime(), + }, + y: { + display: false, + position: 'left', + stacked: true, + grid: { + display: false, + }, + ticks: { + display: false, + //mirror: true, + }, + }, + }, + interaction: { + intersect: false, + mode: 'index', + }, + elements: { + point: { + hoverRadius: 5, + hoverBorderWidth: 2, + }, + }, + animation: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + //gradient, + }, + }, + plugins: [{ + id: 'vLine', + beforeDraw(chart, args, options) { + if (chart.tooltip._active && chart.tooltip._active.length) { + const activePoint = chart.tooltip._active[0]; + const ctx = chart.ctx; + const x = activePoint.element.x; + const topY = chart.scales.y.top; + const bottomY = chart.scales.y.bottom; + + ctx.save(); + ctx.beginPath(); + ctx.moveTo(x, bottomY); + ctx.lineTo(x, topY); + ctx.lineWidth = 1; + ctx.strokeStyle = vLineColor; + ctx.stroke(); + ctx.restore(); + } + }, + }], + }); +} + +function onInstanceClick(i) { + os.pageWindow(`/instance-info/${i.host}`); +} + +onMounted(async () => { + /* + const magicGrid = new MagicGrid({ + container: rootEl, + static: true, + animate: true, + }); + + magicGrid.listen(); + */ + + renderChart(); -onMounted(async () => { os.api('stats', {}).then(statsResponse => { stats = statsResponse; - os.api('charts/users', { limit: 2, span: 'day' }).then(chart => { + os.apiGet('charts/users', { limit: 2, span: 'day' }).then(chart => { usersComparedToThePrevDay = stats.originalUsersCount - chart.local.total[1]; }); - os.api('charts/notes', { limit: 2, span: 'day' }).then(chart => { + os.apiGet('charts/notes', { limit: 2, span: 'day' }).then(chart => { notesComparedToThePrevDay = stats.originalNotesCount - chart.local.total[1]; }); }); + os.apiGet('charts/federation', { limit: 2, span: 'day' }).then(chart => { + federationPubActive = chart.pubActive[0]; + federationPubActiveDiff = chart.pubActive[0] - chart.pubActive[1]; + federationSubActive = chart.subActive[0]; + federationSubActiveDiff = chart.subActive[0] - chart.subActive[1]; + }); + + os.apiGet('federation/stats', { limit: 10 }).then(res => { + topSubInstancesForPie = res.topSubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followersCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowersCount }]); + topPubInstancesForPie = res.topPubInstances.map(x => ({ + name: x.host, + color: x.themeColor, + value: x.followingCount, + onClick: () => { + os.pageWindow(`/instance-info/${x.host}`); + }, + })).concat([{ name: '(other)', color: '#80808080', value: res.otherFollowingCount }]); + }); + os.api('admin/server-info').then(serverInfoResponse => { serverInfo = serverInfoResponse; }); + os.api('admin/show-users', { + limit: 5, + sort: '+createdAt', + }).then(res => { + newUsers = res; + }); + + os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 25, + }).then(res => { + activeInstances = res; + }); + nextTick(() => { queueStatsConnection.send('requestLog', { id: Math.random().toString().substr(2, 8), - length: 200 + length: 100, }); }); }); @@ -115,74 +461,177 @@ onBeforeUnmount(() => { queueStatsConnection.dispose(); }); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.dashboard, - icon: 'fas fa-tachometer-alt', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.dashboard, + icon: 'fas fa-tachometer-alt', }); </script> <style lang="scss" scoped> .edbbcaef { - .cfcdecdf { - display: grid; - grid-gap: 8px; - grid-template-columns: repeat(auto-fill,minmax(150px,1fr)); + display: flex; - > .number { - padding: 12px 16px; + > .left, > .right { + box-sizing: border-box; + width: 50%; - > .label { - opacity: 0.7; - font-size: 0.8em; - } + > .container { + margin: 32px 0; - > .value { + > .title { font-weight: bold; - font-size: 1.2em; + margin-bottom: 16px; + } + + &.stats, &.federationStats { + > .body { + display: grid; + grid-gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + + > .number { + padding: 14px 20px; + + > .label { + opacity: 0.7; + font-size: 0.8em; + } + + > .value { + font-weight: bold; + font-size: 1.5em; - > .diff { - font-size: 0.8em; + > .diff { + font-size: 0.7em; + } + } + } } } - } - } - > .charts { - margin: var(--margin); - } + &.env { + > .body { + display: grid; + grid-gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); - > .queue { - margin: var(--margin); - display: flex; + > .number { + padding: 14px 20px; - > .deliver, - > .inbox { - flex: 1; - width: 50%; + > .label { + opacity: 0.7; + font-size: 0.8em; + } - &:not(:first-child) { - margin-left: var(--margin); + > .value { + font-size: 1.1em; + } + } + } } - } - } - &.max-width_740px { - > .queue { - display: block; + &.charts { + > .body { + padding: 32px; + background: var(--panel); + border-radius: var(--radius); + } + } - > .deliver, - > .inbox { - width: 100%; + &.users { + > .body { + background: var(--panel); + border-radius: var(--radius); - &:not(:first-child) { - margin-top: var(--margin); - margin-left: 0; + > .user { + padding: 16px 20px; + + &:not(:last-child) { + border-bottom: solid 0.5px var(--divider); + } + } + } + } + + &.federation { + > .body { + background: var(--panel); + border-radius: var(--radius); + overflow: hidden; overflow: clip; + } + } + + &.queue { + > .body { + display: grid; + grid-gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + + > .chart { + position: relative; + padding: 20px; + background: var(--panel); + border-radius: var(--radius); + + > .title { + position: absolute; + top: 20px; + left: 20px; + font-size: 90%; + } + } + } + } + + &.federationPies { + > .body { + display: grid; + grid-gap: 16px; + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + + > .chart { + position: relative; + padding: 20px; + background: var(--panel); + border-radius: var(--radius); + + > .title { + position: absolute; + top: 20px; + left: 20px; + font-size: 90%; + } + + > .subTitle { + position: absolute; + bottom: 20px; + right: 20px; + font-size: 85%; + } + } + } + } + + &.tagCloud { + > .body { + background: var(--panel); + border-radius: var(--radius); + overflow: hidden; overflow: clip; } } } } + + > .left { + padding-right: 16px; + } + + > .right { + padding-left: 16px; + } } </style> diff --git a/packages/client/src/pages/admin/proxy-account.vue b/packages/client/src/pages/admin/proxy-account.vue index 727e20e7e5..0951d26c24 100644 --- a/packages/client/src/pages/admin/proxy-account.vue +++ b/packages/client/src/pages/admin/proxy-account.vue @@ -1,5 +1,6 @@ -<template> -<MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> <FormSuspense :p="init"> <MkInfo class="_formBlock">{{ i18n.ts.proxyAccountDescription }}</MkInfo> <MkKeyValue class="_formBlock"> @@ -9,7 +10,7 @@ <FormButton primary class="_formBlock" @click="chooseProxyAccount">{{ i18n.ts.selectAccount }}</FormButton> </FormSuspense> -</MkSpacer> +</MkSpacer></MkStickyContainer> </template> <script lang="ts" setup> @@ -19,9 +20,9 @@ import FormButton from '@/components/ui/button.vue'; import MkInfo from '@/components/ui/info.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { fetchInstance } from '@/instance'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let proxyAccount: any = $ref(null); let proxyAccountId: any = $ref(null); @@ -50,11 +51,12 @@ function save() { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.proxyAccount, - icon: 'fas fa-ghost', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.proxyAccount, + icon: 'fas fa-ghost', }); </script> diff --git a/packages/client/src/pages/admin/queue.chart.chart.vue b/packages/client/src/pages/admin/queue.chart.chart.vue new file mode 100644 index 0000000000..96156f8e67 --- /dev/null +++ b/packages/client/src/pages/admin/queue.chart.chart.vue @@ -0,0 +1,181 @@ +<template> +<canvas ref="chartEl"></canvas> +</template> + +<script lang="ts" setup> +import { watch, onMounted, onUnmounted, ref } from 'vue'; +import { + Chart, + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +} from 'chart.js'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { useChartTooltip } from '@/scripts/use-chart-tooltip'; + +Chart.register( + ArcElement, + LineElement, + BarElement, + PointElement, + BarController, + LineController, + CategoryScale, + LinearScale, + TimeScale, + Legend, + Title, + Tooltip, + SubTitle, + Filler, +); + +const props = defineProps<{ + type: string; +}>(); + +const alpha = (hex, a) => { + const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)!; + const r = parseInt(result[1], 16); + const g = parseInt(result[2], 16); + const b = parseInt(result[3], 16); + return `rgba(${r}, ${g}, ${b}, ${a})`; +}; + +const chartEl = ref<HTMLCanvasElement>(null); + +const gridColor = defaultStore.state.darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'; + +// フォントカラー +Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--fg'); + +const { handler: externalTooltipHandler } = useChartTooltip(); + +let chartInstance: Chart; + +function setData(values) { + if (chartInstance == null) return; + for (const value of values) { + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + } + chartInstance.update(); +} + +function pushData(value) { + if (chartInstance == null) return; + chartInstance.data.labels.push(''); + chartInstance.data.datasets[0].data.push(value); + if (chartInstance.data.datasets[0].data.length > 200) { + chartInstance.data.labels.shift(); + chartInstance.data.datasets[0].data.shift(); + } + chartInstance.update(); +} + +const label = + props.type === 'process' ? 'Process' : + props.type === 'active' ? 'Active' : + props.type === 'delayed' ? 'Delayed' : + props.type === 'waiting' ? 'Waiting' : + '?' as never; + +const color = + props.type === 'process' ? '#00E396' : + props.type === 'active' ? '#00BCD4' : + props.type === 'delayed' ? '#E53935' : + props.type === 'waiting' ? '#FFB300' : + '?' as never; + +onMounted(() => { + chartInstance = new Chart(chartEl.value, { + type: 'line', + data: { + labels: [], + datasets: [{ + label: label, + pointRadius: 0, + tension: 0.3, + borderWidth: 2, + borderJoinStyle: 'round', + borderColor: color, + backgroundColor: alpha(color, 0.1), + data: [], + }], + }, + options: { + aspectRatio: 2.5, + layout: { + padding: { + left: 0, + right: 0, + top: 0, + bottom: 0, + }, + }, + scales: { + x: { + grid: { + display: true, + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + ticks: { + display: false, + maxTicksLimit: 10, + }, + }, + y: { + min: 0, + grid: { + color: gridColor, + borderColor: 'rgb(0, 0, 0, 0)', + }, + }, + }, + interaction: { + intersect: false, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + mode: 'index', + animation: { + duration: 0, + }, + external: externalTooltipHandler, + }, + }, + }, + }); +}); + +defineExpose({ + setData, + pushData, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/pages/admin/queue.chart.vue b/packages/client/src/pages/admin/queue.chart.vue index be63830bdd..c213037b65 100644 --- a/packages/client/src/pages/admin/queue.chart.vue +++ b/packages/client/src/pages/admin/queue.chart.vue @@ -1,80 +1,148 @@ <template> -<div class="_debobigegoItem"> - <div class="_debobigegoLabel"><slot name="title"></slot></div> - <div class="_debobigegoPanel pumxzjhg"> - <div class="_table status"> - <div class="_row"> - <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> - <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div> - <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div> - <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> - </div> +<div class="pumxzjhg"> + <div class="_table status"> + <div class="_row"> + <div class="_cell"><div class="_label">Process</div>{{ number(activeSincePrevTick) }}</div> + <div class="_cell"><div class="_label">Active</div>{{ number(active) }}</div> + <div class="_cell"><div class="_label">Waiting</div>{{ number(waiting) }}</div> + <div class="_cell"><div class="_label">Delayed</div>{{ number(delayed) }}</div> + </div> + </div> + <div class="charts"> + <div class="chart"> + <div class="title">Process</div> + <XChart ref="chartProcess" type="process"/> </div> - <div class=""> - <MkQueueChart :domain="domain" :connection="connection"/> + <div class="chart"> + <div class="title">Active</div> + <XChart ref="chartActive" type="active"/> </div> - <div class="jobs"> - <div v-if="jobs.length > 0"> - <div v-for="job in jobs" :key="job[0]"> - <span>{{ job[0] }}</span> - <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span> - </div> + <div class="chart"> + <div class="title">Delayed</div> + <XChart ref="chartDelayed" type="delayed"/> + </div> + <div class="chart"> + <div class="title">Waiting</div> + <XChart ref="chartWaiting" type="waiting"/> + </div> + </div> + <div class="jobs"> + <div v-if="jobs.length > 0"> + <div v-for="job in jobs" :key="job[0]"> + <span>{{ job[0] }}</span> + <span style="margin-left: 8px; opacity: 0.7;">({{ number(job[1]) }} jobs)</span> </div> - <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span> </div> + <span v-else style="opacity: 0.5;">{{ $ts.noJobs }}</span> </div> </div> </template> <script lang="ts" setup> -import { onMounted, onUnmounted, ref } from 'vue'; +import { markRaw, onMounted, onUnmounted, ref } from 'vue'; +import XChart from './queue.chart.chart.vue'; import number from '@/filters/number'; -import MkQueueChart from '@/components/queue-chart.vue'; import * as os from '@/os'; +import { stream } from '@/stream'; + +const connection = markRaw(stream.useChannel('queueStats')); const activeSincePrevTick = ref(0); const active = ref(0); -const waiting = ref(0); const delayed = ref(0); +const waiting = ref(0); const jobs = ref([]); +let chartProcess = $ref<InstanceType<typeof XChart>>(); +let chartActive = $ref<InstanceType<typeof XChart>>(); +let chartDelayed = $ref<InstanceType<typeof XChart>>(); +let chartWaiting = $ref<InstanceType<typeof XChart>>(); const props = defineProps<{ - domain: string, - connection: any, + domain: string; }>(); +const onStats = (stats) => { + activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; + active.value = stats[props.domain].active; + delayed.value = stats[props.domain].delayed; + waiting.value = stats[props.domain].waiting; + + chartProcess.pushData(stats[props.domain].activeSincePrevTick); + chartActive.pushData(stats[props.domain].active); + chartDelayed.pushData(stats[props.domain].delayed); + chartWaiting.pushData(stats[props.domain].waiting); +}; + +const onStatsLog = (statsLog) => { + const dataProcess = []; + const dataActive = []; + const dataDelayed = []; + const dataWaiting = []; + + for (const stats of [...statsLog].reverse()) { + dataProcess.push(stats[props.domain].activeSincePrevTick); + dataActive.push(stats[props.domain].active); + dataDelayed.push(stats[props.domain].delayed); + dataWaiting.push(stats[props.domain].waiting); + } + + chartProcess.setData(dataProcess); + chartActive.setData(dataActive); + chartDelayed.setData(dataDelayed); + chartWaiting.setData(dataWaiting); +}; + onMounted(() => { os.api(props.domain === 'inbox' ? 'admin/queue/inbox-delayed' : props.domain === 'deliver' ? 'admin/queue/deliver-delayed' : null, {}).then(result => { jobs.value = result; }); - const onStats = (stats) => { - activeSincePrevTick.value = stats[props.domain].activeSincePrevTick; - active.value = stats[props.domain].active; - waiting.value = stats[props.domain].waiting; - delayed.value = stats[props.domain].delayed; - }; - - props.connection.on('stats', onStats); - - onUnmounted(() => { - props.connection.off('stats', onStats); + connection.on('stats', onStats); + connection.on('statsLog', onStatsLog); + connection.send('requestLog', { + id: Math.random().toString().substr(2, 8), + length: 200, }); }); + +onUnmounted(() => { + connection.off('stats', onStats); + connection.off('statsLog', onStatsLog); + connection.dispose(); +}); </script> <style lang="scss" scoped> .pumxzjhg { > .status { padding: 16px; - border-bottom: solid 0.5px var(--divider); + } + + > .charts { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; + + > .chart { + min-width: 0; + padding: 16px; + background: var(--panel); + border-radius: var(--radius); + + > .title { + margin-bottom: 8px; + } + } } > .jobs { + margin-top: 16px; padding: 16px; - border-top: solid 0.5px var(--divider); max-height: 180px; overflow: auto; + background: var(--panel); + border-radius: var(--radius); } + } </style> diff --git a/packages/client/src/pages/admin/queue.vue b/packages/client/src/pages/admin/queue.vue index 656b18199f..6ccb464d17 100644 --- a/packages/client/src/pages/admin/queue.vue +++ b/packages/client/src/pages/admin/queue.vue @@ -1,26 +1,24 @@ <template> -<MkSpacer :content-max="800"> - <XQueue :connection="connection" domain="inbox"> - <template #title>In</template> - </XQueue> - <XQueue :connection="connection" domain="deliver"> - <template #title>Out</template> - </XQueue> - <MkButton danger @click="clear()"><i class="fas fa-trash-alt"></i> {{ i18n.ts.clearQueue }}</MkButton> -</MkSpacer> +<MkStickyContainer> + <template #header><XHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <XQueue v-if="tab === 'deliver'" domain="deliver"/> + <XQueue v-else-if="tab === 'inbox'" domain="inbox"/> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { markRaw, onMounted, onBeforeUnmount, nextTick } from 'vue'; -import MkButton from '@/components/ui/button.vue'; import XQueue from './queue.chart.vue'; +import XHeader from './_header_.vue'; +import MkButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import { stream } from '@/stream'; -import * as symbols from '@/symbols'; import * as config from '@/config'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; -const connection = markRaw(stream.useChannel('queueStats')); +let tab = $ref('deliver'); function clear() { os.confirm({ @@ -34,32 +32,25 @@ function clear() { }); } -onMounted(() => { - nextTick(() => { - connection.send('requestLog', { - id: Math.random().toString().substr(2, 8), - length: 200 - }); - }); -}); +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'fas fa-up-right-from-square', + text: i18n.ts.dashboard, + handler: () => { + window.open(config.url + '/queue', '_blank'); + }, +}]); -onBeforeUnmount(() => { - connection.dispose(); -}); +const headerTabs = $computed(() => [{ + key: 'deliver', + title: 'Deliver', +}, { + key: 'inbox', + title: 'Inbox', +}]); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.jobQueue, - icon: 'fas fa-clipboard-list', - bg: 'var(--bg)', - actions: [{ - asFullButton: true, - icon: 'fas fa-up-right-from-square', - text: i18n.ts.dashboard, - handler: () => { - window.open(config.url + '/queue', '_blank'); - }, - }], - } +definePageMetadata({ + title: i18n.ts.jobQueue, + icon: 'fas fa-clipboard-list', }); </script> diff --git a/packages/client/src/pages/admin/relays.vue b/packages/client/src/pages/admin/relays.vue index 1a36bb4753..42347c0e7d 100644 --- a/packages/client/src/pages/admin/relays.vue +++ b/packages/client/src/pages/admin/relays.vue @@ -1,24 +1,28 @@ <template> -<MkSpacer :content-max="800"> - <div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel _block" style="padding: 16px;"> - <div>{{ relay.inbox }}</div> - <div class="status"> - <i v-if="relay.status === 'accepted'" class="fas fa-check icon accepted"></i> - <i v-else-if="relay.status === 'rejected'" class="fas fa-ban icon rejected"></i> - <i v-else class="fas fa-clock icon requesting"></i> - <span>{{ $t(`_relayStatus.${relay.status}`) }}</span> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <div v-for="relay in relays" :key="relay.inbox" class="relaycxt _panel _block" style="padding: 16px;"> + <div>{{ relay.inbox }}</div> + <div class="status"> + <i v-if="relay.status === 'accepted'" class="fas fa-check icon accepted"></i> + <i v-else-if="relay.status === 'rejected'" class="fas fa-ban icon rejected"></i> + <i v-else class="fas fa-clock icon requesting"></i> + <span>{{ $t(`_relayStatus.${relay.status}`) }}</span> + </div> + <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton> </div> - <MkButton class="button" inline danger @click="remove(relay.inbox)"><i class="fas fa-trash-alt"></i> {{ i18n.ts.remove }}</MkButton> - </div> -</MkSpacer> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { } from 'vue'; +import XHeader from './_header_.vue'; import MkButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let relays: any[] = $ref([]); @@ -26,30 +30,30 @@ async function addRelay() { const { canceled, result: inbox } = await os.inputText({ title: i18n.ts.addRelay, type: 'url', - placeholder: i18n.ts.inboxUrl + placeholder: i18n.ts.inboxUrl, }); if (canceled) return; os.api('admin/relays/add', { - inbox + inbox, }).then((relay: any) => { refresh(); }).catch((err: any) => { os.alert({ type: 'error', - text: err.message || err + text: err.message || err, }); }); } function remove(inbox: string) { os.api('admin/relays/remove', { - inbox + inbox, }).then(() => { refresh(); }).catch((err: any) => { os.alert({ type: 'error', - text: err.message || err + text: err.message || err, }); }); } @@ -62,18 +66,18 @@ function refresh() { refresh(); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.relays, - icon: 'fas fa-globe', - bg: 'var(--bg)', - actions: [{ - asFullButton: true, - icon: 'fas fa-plus', - text: i18n.ts.addRelay, - handler: addRelay, - }], - } +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'fas fa-plus', + text: i18n.ts.addRelay, + handler: addRelay, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.relays, + icon: 'fas fa-globe', }); </script> diff --git a/packages/client/src/pages/admin/security.vue b/packages/client/src/pages/admin/security.vue index 6b8f70cca5..c4a4994bb8 100644 --- a/packages/client/src/pages/admin/security.vue +++ b/packages/client/src/pages/admin/security.vue @@ -1,73 +1,160 @@ <template> -<MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> - <FormSuspense :p="init"> - <div class="_formRoot"> - <FormFolder class="_formBlock"> - <template #icon><i class="fas fa-shield-alt"></i></template> - <template #label>{{ i18n.ts.botProtection }}</template> - <template v-if="enableHcaptcha" #suffix>hCaptcha</template> - <template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template> - <template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template> +<MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormFolder class="_formBlock"> + <template #icon><i class="fas fa-shield-alt"></i></template> + <template #label>{{ i18n.ts.botProtection }}</template> + <template v-if="enableHcaptcha" #suffix>hCaptcha</template> + <template v-else-if="enableRecaptcha" #suffix>reCAPTCHA</template> + <template v-else #suffix>{{ i18n.ts.none }} ({{ i18n.ts.notRecommended }})</template> - <XBotProtection/> - </FormFolder> + <XBotProtection/> + </FormFolder> - <FormFolder class="_formBlock"> - <template #label>Summaly Proxy</template> + <FormFolder class="_formBlock"> + <template #icon><i class="fas fa-eye-slash"></i></template> + <template #label>{{ i18n.ts.sensitiveMediaDetection }}</template> + <template v-if="sensitiveMediaDetection === 'all'" #suffix>{{ i18n.ts.all }}</template> + <template v-else-if="sensitiveMediaDetection === 'local'" #suffix>{{ i18n.ts.localOnly }}</template> + <template v-else-if="sensitiveMediaDetection === 'remote'" #suffix>{{ i18n.ts.remoteOnly }}</template> + <template v-else #suffix>{{ i18n.ts.none }}</template> - <div class="_formRoot"> - <FormInput v-model="summalyProxy" class="_formBlock"> - <template #prefix><i class="fas fa-link"></i></template> - <template #label>Summaly Proxy URL</template> - </FormInput> + <div class="_formRoot"> + <span class="_formBlock">{{ i18n.ts._sensitiveMediaDetection.description }}</span> - <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton> - </div> - </FormFolder> - </div> - </FormSuspense> -</MkSpacer> + <FormRadios v-model="sensitiveMediaDetection" class="_formBlock"> + <option value="none">{{ i18n.ts.none }}</option> + <option value="all">{{ i18n.ts.all }}</option> + <option value="local">{{ i18n.ts.localOnly }}</option> + <option value="remote">{{ i18n.ts.remoteOnly }}</option> + </FormRadios> + + <FormRange v-model="sensitiveMediaDetectionSensitivity" :min="0" :max="4" :step="1" :text-converter="(v) => `${v + 1}`" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.sensitivity }}</template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.sensitivityDescription }}</template> + </FormRange> + + <FormSwitch v-model="enableSensitiveMediaDetectionForVideos" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.analyzeVideos }}<span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.analyzeVideosDescription }}</template> + </FormSwitch> + + <FormSwitch v-model="setSensitiveFlagAutomatically" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomatically }} ({{ i18n.ts.notRecommended }})</template> + <template #caption>{{ i18n.ts._sensitiveMediaDetection.setSensitiveFlagAutomaticallyDescription }}</template> + </FormSwitch> + + <!-- 現状 false positive が多すぎて実用に耐えない + <FormSwitch v-model="disallowUploadWhenPredictedAsPorn" class="_formBlock"> + <template #label>{{ i18n.ts._sensitiveMediaDetection.disallowUploadWhenPredictedAsPorn }}</template> + </FormSwitch> + --> + + <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton> + </div> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #label>Log IP address</template> + <template v-if="enableIpLogging" #suffix>Enabled</template> + <template v-else #suffix>Disabled</template> + + <div class="_formRoot"> + <FormSwitch v-model="enableIpLogging" class="_formBlock" @update:modelValue="save"> + <template #label>Enable</template> + </FormSwitch> + </div> + </FormFolder> + + <FormFolder class="_formBlock"> + <template #label>Summaly Proxy</template> + + <div class="_formRoot"> + <FormInput v-model="summalyProxy" class="_formBlock"> + <template #prefix><i class="fas fa-link"></i></template> + <template #label>Summaly Proxy URL</template> + </FormInput> + + <FormButton primary class="_formBlock" @click="save"><i class="fas fa-save"></i> {{ i18n.ts.save }}</FormButton> + </div> + </FormFolder> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { } from 'vue'; +import XBotProtection from './bot-protection.vue'; +import XHeader from './_header_.vue'; import FormFolder from '@/components/form/folder.vue'; +import FormRadios from '@/components/form/radios.vue'; import FormSwitch from '@/components/form/switch.vue'; import FormInfo from '@/components/ui/info.vue'; import FormSuspense from '@/components/form/suspense.vue'; -import FormSection from '@/components/form/section.vue'; +import FormRange from '@/components/form/range.vue'; import FormInput from '@/components/form/input.vue'; import FormButton from '@/components/ui/button.vue'; -import XBotProtection from './bot-protection.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { fetchInstance } from '@/instance'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let summalyProxy: string = $ref(''); let enableHcaptcha: boolean = $ref(false); let enableRecaptcha: boolean = $ref(false); +let sensitiveMediaDetection: string = $ref('none'); +let sensitiveMediaDetectionSensitivity: number = $ref(0); +let setSensitiveFlagAutomatically: boolean = $ref(false); +let enableSensitiveMediaDetectionForVideos: boolean = $ref(false); +let enableIpLogging: boolean = $ref(false); async function init() { const meta = await os.api('admin/meta'); summalyProxy = meta.summalyProxy; enableHcaptcha = meta.enableHcaptcha; enableRecaptcha = meta.enableRecaptcha; + sensitiveMediaDetection = meta.sensitiveMediaDetection; + sensitiveMediaDetectionSensitivity = + meta.sensitiveMediaDetectionSensitivity === 'veryLow' ? 0 : + meta.sensitiveMediaDetectionSensitivity === 'low' ? 1 : + meta.sensitiveMediaDetectionSensitivity === 'medium' ? 2 : + meta.sensitiveMediaDetectionSensitivity === 'high' ? 3 : + meta.sensitiveMediaDetectionSensitivity === 'veryHigh' ? 4 : 0; + setSensitiveFlagAutomatically = meta.setSensitiveFlagAutomatically; + enableSensitiveMediaDetectionForVideos = meta.enableSensitiveMediaDetectionForVideos; + enableIpLogging = meta.enableIpLogging; } function save() { os.apiWithDialog('admin/update-meta', { summalyProxy, + sensitiveMediaDetection, + sensitiveMediaDetectionSensitivity: + sensitiveMediaDetectionSensitivity === 0 ? 'veryLow' : + sensitiveMediaDetectionSensitivity === 1 ? 'low' : + sensitiveMediaDetectionSensitivity === 2 ? 'medium' : + sensitiveMediaDetectionSensitivity === 3 ? 'high' : + sensitiveMediaDetectionSensitivity === 4 ? 'veryHigh' : + 0, + setSensitiveFlagAutomatically, + enableSensitiveMediaDetectionForVideos, + enableIpLogging, }).then(() => { fetchInstance(); }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.security, - icon: 'fas fa-lock', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.security, + icon: 'fas fa-lock', }); </script> diff --git a/packages/client/src/pages/admin/settings.vue b/packages/client/src/pages/admin/settings.vue index 6dc30fe50b..496eb46ea4 100644 --- a/packages/client/src/pages/admin/settings.vue +++ b/packages/client/src/pages/admin/settings.vue @@ -1,149 +1,155 @@ <template> -<MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> - <FormSuspense :p="init"> - <div class="_formRoot"> - <FormInput v-model="name" class="_formBlock"> - <template #label>{{ i18n.ts.instanceName }}</template> - </FormInput> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div class="_formRoot"> + <FormInput v-model="name" class="_formBlock"> + <template #label>{{ i18n.ts.instanceName }}</template> + </FormInput> - <FormTextarea v-model="description" class="_formBlock"> - <template #label>{{ i18n.ts.instanceDescription }}</template> - </FormTextarea> + <FormTextarea v-model="description" class="_formBlock"> + <template #label>{{ i18n.ts.instanceDescription }}</template> + </FormTextarea> - <FormInput v-model="tosUrl" class="_formBlock"> - <template #prefix><i class="fas fa-link"></i></template> - <template #label>{{ i18n.ts.tosUrl }}</template> - </FormInput> + <FormInput v-model="tosUrl" class="_formBlock"> + <template #prefix><i class="fas fa-link"></i></template> + <template #label>{{ i18n.ts.tosUrl }}</template> + </FormInput> - <FormSplit :min-width="300"> - <FormInput v-model="maintainerName" class="_formBlock"> - <template #label>{{ i18n.ts.maintainerName }}</template> - </FormInput> + <FormSplit :min-width="300"> + <FormInput v-model="maintainerName" class="_formBlock"> + <template #label>{{ i18n.ts.maintainerName }}</template> + </FormInput> - <FormInput v-model="maintainerEmail" type="email" class="_formBlock"> - <template #prefix><i class="fas fa-envelope"></i></template> - <template #label>{{ i18n.ts.maintainerEmail }}</template> - </FormInput> - </FormSplit> + <FormInput v-model="maintainerEmail" type="email" class="_formBlock"> + <template #prefix><i class="fas fa-envelope"></i></template> + <template #label>{{ i18n.ts.maintainerEmail }}</template> + </FormInput> + </FormSplit> - <FormTextarea v-model="pinnedUsers" class="_formBlock"> - <template #label>{{ i18n.ts.pinnedUsers }}</template> - <template #caption>{{ i18n.ts.pinnedUsersDescription }}</template> - </FormTextarea> + <FormTextarea v-model="pinnedUsers" class="_formBlock"> + <template #label>{{ i18n.ts.pinnedUsers }}</template> + <template #caption>{{ i18n.ts.pinnedUsersDescription }}</template> + </FormTextarea> - <FormSection> - <FormSwitch v-model="enableRegistration" class="_formBlock"> - <template #label>{{ i18n.ts.enableRegistration }}</template> - </FormSwitch> + <FormSection> + <FormSwitch v-model="enableRegistration" class="_formBlock"> + <template #label>{{ i18n.ts.enableRegistration }}</template> + </FormSwitch> - <FormSwitch v-model="emailRequiredForSignup" class="_formBlock"> - <template #label>{{ i18n.ts.emailRequiredForSignup }}</template> - </FormSwitch> - </FormSection> + <FormSwitch v-model="emailRequiredForSignup" class="_formBlock"> + <template #label>{{ i18n.ts.emailRequiredForSignup }}</template> + </FormSwitch> + </FormSection> - <FormSection> - <FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch> - <FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch> - <FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo> - </FormSection> + <FormSection> + <FormSwitch v-model="enableLocalTimeline" class="_formBlock">{{ i18n.ts.enableLocalTimeline }}</FormSwitch> + <FormSwitch v-model="enableGlobalTimeline" class="_formBlock">{{ i18n.ts.enableGlobalTimeline }}</FormSwitch> + <FormInfo class="_formBlock">{{ i18n.ts.disablingTimelinesInfo }}</FormInfo> + </FormSection> - <FormSection> - <template #label>{{ i18n.ts.theme }}</template> + <FormSection> + <template #label>{{ i18n.ts.theme }}</template> - <FormInput v-model="iconUrl" class="_formBlock"> - <template #prefix><i class="fas fa-link"></i></template> - <template #label>{{ i18n.ts.iconUrl }}</template> - </FormInput> + <FormInput v-model="iconUrl" class="_formBlock"> + <template #prefix><i class="fas fa-link"></i></template> + <template #label>{{ i18n.ts.iconUrl }}</template> + </FormInput> - <FormInput v-model="bannerUrl" class="_formBlock"> - <template #prefix><i class="fas fa-link"></i></template> - <template #label>{{ i18n.ts.bannerUrl }}</template> - </FormInput> + <FormInput v-model="bannerUrl" class="_formBlock"> + <template #prefix><i class="fas fa-link"></i></template> + <template #label>{{ i18n.ts.bannerUrl }}</template> + </FormInput> - <FormInput v-model="backgroundImageUrl" class="_formBlock"> - <template #prefix><i class="fas fa-link"></i></template> - <template #label>{{ i18n.ts.backgroundImageUrl }}</template> - </FormInput> + <FormInput v-model="backgroundImageUrl" class="_formBlock"> + <template #prefix><i class="fas fa-link"></i></template> + <template #label>{{ i18n.ts.backgroundImageUrl }}</template> + </FormInput> - <FormInput v-model="themeColor" class="_formBlock"> - <template #prefix><i class="fas fa-palette"></i></template> - <template #label>{{ i18n.ts.themeColor }}</template> - <template #caption>#RRGGBB</template> - </FormInput> + <FormInput v-model="themeColor" class="_formBlock"> + <template #prefix><i class="fas fa-palette"></i></template> + <template #label>{{ i18n.ts.themeColor }}</template> + <template #caption>#RRGGBB</template> + </FormInput> - <FormTextarea v-model="defaultLightTheme" class="_formBlock"> - <template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template> - <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> - </FormTextarea> + <FormTextarea v-model="defaultLightTheme" class="_formBlock"> + <template #label>{{ i18n.ts.instanceDefaultLightTheme }}</template> + <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> + </FormTextarea> - <FormTextarea v-model="defaultDarkTheme" class="_formBlock"> - <template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template> - <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> - </FormTextarea> - </FormSection> + <FormTextarea v-model="defaultDarkTheme" class="_formBlock"> + <template #label>{{ i18n.ts.instanceDefaultDarkTheme }}</template> + <template #caption>{{ i18n.ts.instanceDefaultThemeDescription }}</template> + </FormTextarea> + </FormSection> - <FormSection> - <template #label>{{ i18n.ts.files }}</template> + <FormSection> + <template #label>{{ i18n.ts.files }}</template> - <FormSwitch v-model="cacheRemoteFiles" class="_formBlock"> - <template #label>{{ i18n.ts.cacheRemoteFiles }}</template> - <template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template> - </FormSwitch> + <FormSwitch v-model="cacheRemoteFiles" class="_formBlock"> + <template #label>{{ i18n.ts.cacheRemoteFiles }}</template> + <template #caption>{{ i18n.ts.cacheRemoteFilesDescription }}</template> + </FormSwitch> - <FormSplit :min-width="280"> - <FormInput v-model="localDriveCapacityMb" type="number" class="_formBlock"> - <template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template> - <template #suffix>MB</template> - <template #caption>{{ i18n.ts.inMb }}</template> - </FormInput> + <FormSplit :min-width="280"> + <FormInput v-model="localDriveCapacityMb" type="number" class="_formBlock"> + <template #label>{{ i18n.ts.driveCapacityPerLocalAccount }}</template> + <template #suffix>MB</template> + <template #caption>{{ i18n.ts.inMb }}</template> + </FormInput> - <FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" class="_formBlock"> - <template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template> - <template #suffix>MB</template> - <template #caption>{{ i18n.ts.inMb }}</template> - </FormInput> - </FormSplit> - </FormSection> + <FormInput v-model="remoteDriveCapacityMb" type="number" :disabled="!cacheRemoteFiles" class="_formBlock"> + <template #label>{{ i18n.ts.driveCapacityPerRemoteAccount }}</template> + <template #suffix>MB</template> + <template #caption>{{ i18n.ts.inMb }}</template> + </FormInput> + </FormSplit> + </FormSection> - <FormSection> - <template #label>ServiceWorker</template> + <FormSection> + <template #label>ServiceWorker</template> - <FormSwitch v-model="enableServiceWorker" class="_formBlock"> - <template #label>{{ i18n.ts.enableServiceworker }}</template> - <template #caption>{{ i18n.ts.serviceworkerInfo }}</template> - </FormSwitch> + <FormSwitch v-model="enableServiceWorker" class="_formBlock"> + <template #label>{{ i18n.ts.enableServiceworker }}</template> + <template #caption>{{ i18n.ts.serviceworkerInfo }}</template> + </FormSwitch> - <template v-if="enableServiceWorker"> - <FormInput v-model="swPublicKey" class="_formBlock"> - <template #prefix><i class="fas fa-key"></i></template> - <template #label>Public key</template> - </FormInput> + <template v-if="enableServiceWorker"> + <FormInput v-model="swPublicKey" class="_formBlock"> + <template #prefix><i class="fas fa-key"></i></template> + <template #label>Public key</template> + </FormInput> - <FormInput v-model="swPrivateKey" class="_formBlock"> - <template #prefix><i class="fas fa-key"></i></template> - <template #label>Private key</template> - </FormInput> - </template> - </FormSection> + <FormInput v-model="swPrivateKey" class="_formBlock"> + <template #prefix><i class="fas fa-key"></i></template> + <template #label>Private key</template> + </FormInput> + </template> + </FormSection> - <FormSection> - <template #label>DeepL Translation</template> + <FormSection> + <template #label>DeepL Translation</template> - <FormInput v-model="deeplAuthKey" class="_formBlock"> - <template #prefix><i class="fas fa-key"></i></template> - <template #label>DeepL Auth Key</template> - </FormInput> - <FormSwitch v-model="deeplIsPro" class="_formBlock"> - <template #label>Pro account</template> - </FormSwitch> - </FormSection> - </div> - </FormSuspense> -</MkSpacer> + <FormInput v-model="deeplAuthKey" class="_formBlock"> + <template #prefix><i class="fas fa-key"></i></template> + <template #label>DeepL Auth Key</template> + </FormInput> + <FormSwitch v-model="deeplIsPro" class="_formBlock"> + <template #label>Pro account</template> + </FormSwitch> + </FormSection> + </div> + </FormSuspense> + </MkSpacer> + </MkStickyContainer> +</div> </template> <script lang="ts" setup> import { } from 'vue'; +import XHeader from './_header_.vue'; import FormSwitch from '@/components/form/switch.vue'; import FormInput from '@/components/form/input.vue'; import FormTextarea from '@/components/form/textarea.vue'; @@ -152,9 +158,9 @@ import FormSection from '@/components/form/section.vue'; import FormSplit from '@/components/form/split.vue'; import FormSuspense from '@/components/form/suspense.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { fetchInstance } from '@/instance'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let name: string | null = $ref(null); let description: string | null = $ref(null); @@ -240,17 +246,17 @@ function save() { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.general, - icon: 'fas fa-cog', - bg: 'var(--bg)', - actions: [{ - asFullButton: true, - icon: 'fas fa-check', - text: i18n.ts.save, - handler: save, - }], - } +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'fas fa-check', + text: i18n.ts.save, + handler: save, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.general, + icon: 'fas fa-cog', }); </script> diff --git a/packages/client/src/pages/admin/users.vue b/packages/client/src/pages/admin/users.vue index f05aa5ff45..c6755672f7 100644 --- a/packages/client/src/pages/admin/users.vue +++ b/packages/client/src/pages/admin/users.vue @@ -1,76 +1,68 @@ <template> -<div class="lknzcolw"> - <div class="users"> - <div class="inputs"> - <MkSelect v-model="sort" style="flex: 1;"> - <template #label>{{ $ts.sort }}</template> - <option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option> - <option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option> - <option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option> - <option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option> - </MkSelect> - <MkSelect v-model="state" style="flex: 1;"> - <template #label>{{ $ts.state }}</template> - <option value="all">{{ $ts.all }}</option> - <option value="available">{{ $ts.normal }}</option> - <option value="admin">{{ $ts.administrator }}</option> - <option value="moderator">{{ $ts.moderator }}</option> - <option value="silenced">{{ $ts.silence }}</option> - <option value="suspended">{{ $ts.suspend }}</option> - </MkSelect> - <MkSelect v-model="origin" style="flex: 1;"> - <template #label>{{ $ts.instance }}</template> - <option value="combined">{{ $ts.all }}</option> - <option value="local">{{ $ts.local }}</option> - <option value="remote">{{ $ts.remote }}</option> - </MkSelect> - </div> - <div class="inputs"> - <MkInput v-model="searchUsername" style="flex: 1;" type="text" spellcheck="false" @update:modelValue="$refs.users.reload()"> - <template #prefix>@</template> - <template #label>{{ $ts.username }}</template> - </MkInput> - <MkInput v-model="searchHost" style="flex: 1;" type="text" spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()"> - <template #prefix>@</template> - <template #label>{{ $ts.host }}</template> - </MkInput> - </div> - - <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users"> - <button v-for="user in items" :key="user.id" class="user _panel _button _gap" @click="show(user)"> - <MkAvatar class="avatar" :user="user" :disable-link="true" :show-indicator="true"/> - <div class="body"> - <header> - <MkUserName class="name" :user="user"/> - <span class="acct">@{{ acct(user) }}</span> - <span v-if="user.isAdmin" class="staff"><i class="fas fa-bookmark"></i></span> - <span v-if="user.isModerator" class="staff"><i class="far fa-bookmark"></i></span> - <span v-if="user.isSilenced" class="punished"><i class="fas fa-microphone-slash"></i></span> - <span v-if="user.isSuspended" class="punished"><i class="fas fa-snowflake"></i></span> - </header> - <div> - <span>{{ $ts.lastUsed }}: <MkTime v-if="user.updatedAt" :time="user.updatedAt" mode="detail"/></span> +<div> + <MkStickyContainer> + <template #header><XHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900"> + <div class="lknzcolw"> + <div class="users"> + <div class="inputs"> + <MkSelect v-model="sort" style="flex: 1;"> + <template #label>{{ $ts.sort }}</template> + <option value="-createdAt">{{ $ts.registeredDate }} ({{ $ts.ascendingOrder }})</option> + <option value="+createdAt">{{ $ts.registeredDate }} ({{ $ts.descendingOrder }})</option> + <option value="-updatedAt">{{ $ts.lastUsed }} ({{ $ts.ascendingOrder }})</option> + <option value="+updatedAt">{{ $ts.lastUsed }} ({{ $ts.descendingOrder }})</option> + </MkSelect> + <MkSelect v-model="state" style="flex: 1;"> + <template #label>{{ $ts.state }}</template> + <option value="all">{{ $ts.all }}</option> + <option value="available">{{ $ts.normal }}</option> + <option value="admin">{{ $ts.administrator }}</option> + <option value="moderator">{{ $ts.moderator }}</option> + <option value="silenced">{{ $ts.silence }}</option> + <option value="suspended">{{ $ts.suspend }}</option> + </MkSelect> + <MkSelect v-model="origin" style="flex: 1;"> + <template #label>{{ $ts.instance }}</template> + <option value="combined">{{ $ts.all }}</option> + <option value="local">{{ $ts.local }}</option> + <option value="remote">{{ $ts.remote }}</option> + </MkSelect> </div> - <div> - <span>{{ $ts.registeredDate }}: <MkTime :time="user.createdAt" mode="detail"/></span> + <div class="inputs"> + <MkInput v-model="searchUsername" style="flex: 1;" type="text" :spellcheck="false" @update:modelValue="$refs.users.reload()"> + <template #prefix>@</template> + <template #label>{{ $ts.username }}</template> + </MkInput> + <MkInput v-model="searchHost" style="flex: 1;" type="text" :spellcheck="false" :disabled="pagination.params.origin === 'local'" @update:modelValue="$refs.users.reload()"> + <template #prefix>@</template> + <template #label>{{ $ts.host }}</template> + </MkInput> </div> + + <MkPagination v-slot="{items}" ref="paginationComponent" :pagination="pagination" class="users"> + <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`"> + <MkUserCardMini :user="user"/> + </MkA> + </MkPagination> </div> - </button> - </MkPagination> - </div> + </div> + </MkSpacer> + </MkStickyContainer> </div> </template> <script lang="ts" setup> import { computed } from 'vue'; +import XHeader from './_header_.vue'; import MkInput from '@/components/form/input.vue'; import MkSelect from '@/components/form/select.vue'; import MkPagination from '@/components/ui/pagination.vue'; -import { acct } from '@/filters/user'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { lookupUser } from '@/scripts/lookup-user'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import MkUserCardMini from '@/components/user-card-mini.vue'; let paginationComponent = $ref<InstanceType<typeof MkPagination>>(); @@ -89,7 +81,7 @@ const pagination = { username: searchUsername, hostname: searchHost, })), - offsetMode: true + offsetMode: true, }; function searchUser() { @@ -106,7 +98,7 @@ async function addUser() { const { canceled: canceled2, result: password } = await os.inputText({ title: i18n.ts.password, - type: 'password' + type: 'password', }); if (canceled2) return; @@ -122,34 +114,33 @@ function show(user) { os.pageWindow(`/user-info/${user.id}`); } -defineExpose({ - [symbols.PAGE_INFO]: computed(() => ({ - title: i18n.ts.users, - icon: 'fas fa-users', - bg: 'var(--bg)', - actions: [{ - icon: 'fas fa-search', - text: i18n.ts.search, - handler: searchUser - }, { - asFullButton: true, - icon: 'fas fa-plus', - text: i18n.ts.addUser, - handler: addUser - }, { - asFullButton: true, - icon: 'fas fa-search', - text: i18n.ts.lookup, - handler: lookupUser - }], - })), -}); +const headerActions = $computed(() => [{ + icon: 'fas fa-search', + text: i18n.ts.search, + handler: searchUser, +}, { + asFullButton: true, + icon: 'fas fa-plus', + text: i18n.ts.addUser, + handler: addUser, +}, { + asFullButton: true, + icon: 'fas fa-search', + text: i18n.ts.lookup, + handler: lookupUser, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.users, + icon: 'fas fa-users', +}))); </script> <style lang="scss" scoped> .lknzcolw { > .users { - margin: var(--margin); > .inputs { display: flex; @@ -166,54 +157,12 @@ defineExpose({ > .users { margin-top: var(--margin); + display: grid; + grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); + grid-gap: 12px; - > .user { - display: flex; - width: 100%; - box-sizing: border-box; - text-align: left; - align-items: center; - padding: 16px; - - &:hover { - color: var(--accent); - } - - > .avatar { - width: 60px; - height: 60px; - } - - > .body { - margin-left: 0.3em; - padding: 0 8px; - flex: 1; - - @media (max-width: 500px) { - font-size: 14px; - } - - > header { - > .name { - font-weight: bold; - } - - > .acct { - margin-left: 8px; - opacity: 0.7; - } - - > .staff { - margin-left: 0.5em; - color: var(--badge); - } - - > .punished { - margin-left: 0.5em; - color: #4dabf7; - } - } - } + > .user:hover { + text-decoration: none; } } } diff --git a/packages/client/src/pages/announcements.vue b/packages/client/src/pages/announcements.vue index 53727823a4..aeb85b6557 100644 --- a/packages/client/src/pages/announcements.vue +++ b/packages/client/src/pages/announcements.vue @@ -1,57 +1,52 @@ <template> -<MkSpacer :content-max="800"> - <MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _content"> - <section v-for="(announcement, i) in items" :key="announcement.id" class="_card announcement"> - <div class="_title"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div> - <div class="_content"> - <Mfm :text="announcement.text"/> - <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> - </div> - <div v-if="$i && !announcement.isRead" class="_footer"> - <MkButton primary @click="read(items, announcement, i)"><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton> - </div> - </section> - </MkPagination> -</MkSpacer> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <MkPagination v-slot="{items}" :pagination="pagination" class="ruryvtyk _content"> + <section v-for="(announcement, i) in items" :key="announcement.id" class="_card announcement"> + <div class="_title"><span v-if="$i && !announcement.isRead">🆕 </span>{{ announcement.title }}</div> + <div class="_content"> + <Mfm :text="announcement.text"/> + <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> + </div> + <div v-if="$i && !announcement.isRead" class="_footer"> + <MkButton primary @click="read(items, announcement, i)"><i class="fas fa-check"></i> {{ $ts.gotIt }}</MkButton> + </div> + </section> + </MkPagination> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import MkPagination from '@/components/ui/pagination.vue'; import MkButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; -export default defineComponent({ - components: { - MkPagination, - MkButton - }, +const pagination = { + endpoint: 'announcements' as const, + limit: 10, +}; - data() { - return { - [symbols.PAGE_INFO]: { - title: this.$ts.announcements, - icon: 'fas fa-broadcast-tower', - bg: 'var(--bg)', - }, - pagination: { - endpoint: 'announcements' as const, - limit: 10, - }, - }; - }, +// TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい +function read(items, announcement, i) { + items[i] = { + ...announcement, + isRead: true, + }; + os.api('i/read-announcement', { announcementId: announcement.id }); +} - methods: { - // TODO: これは実質的に親コンポーネントから子コンポーネントのプロパティを変更してるのでなんとかしたい - read(items, announcement, i) { - items[i] = { - ...announcement, - isRead: true, - }; - os.api('i/read-announcement', { announcementId: announcement.id }); - }, - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.announcements, + icon: 'fas fa-broadcast-tower', }); </script> diff --git a/packages/client/src/pages/antenna-timeline.vue b/packages/client/src/pages/antenna-timeline.vue index c38f285725..309f94f9f5 100644 --- a/packages/client/src/pages/antenna-timeline.vue +++ b/packages/client/src/pages/antenna-timeline.vue @@ -1,104 +1,92 @@ <template> -<div v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks"> - <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> - <div class="tl _block"> - <XTimeline ref="tl" :key="antennaId" - class="tl" - src="antenna" - :antenna="antennaId" - :sound="true" - @queue="queueUpdated" - /> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <div ref="rootEl" v-hotkey.global="keymap" v-size="{ min: [800] }" class="tqmomfks"> + <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline + ref="tlEl" :key="antennaId" + class="tl" + src="antenna" + :antenna="antennaId" + :sound="true" + @queue="queueUpdated" + /> + </div> </div> -</div> +</MkStickyContainer> </template> -<script lang="ts"> -import { defineComponent, defineAsyncComponent, computed } from 'vue'; +<script lang="ts" setup> +import { computed, inject, watch } from 'vue'; import XTimeline from '@/components/timeline.vue'; import { scroll } from '@/scripts/scroll'; import * as os from '@/os'; -import * as symbols from '@/symbols'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - XTimeline, - }, +const router = useRouter(); - props: { - antennaId: { - type: String, - required: true - } - }, +const props = defineProps<{ + antennaId: string; +}>(); - data() { - return { - antenna: null, - queue: 0, - [symbols.PAGE_INFO]: computed(() => this.antenna ? { - title: this.antenna.name, - icon: 'fas fa-satellite', - bg: 'var(--bg)', - actions: [{ - icon: 'fas fa-calendar-alt', - text: this.$ts.jumpToSpecifiedDate, - handler: this.timetravel - }, { - icon: 'fas fa-cog', - text: this.$ts.settings, - handler: this.settings - }], - } : null), - }; - }, +let antenna = $ref(null); +let queue = $ref(0); +let rootEl = $ref<HTMLElement>(); +let tlEl = $ref<InstanceType<typeof XTimeline>>(); +const keymap = $computed(() => ({ + 't': focus, +})); - computed: { - keymap(): any { - return { - 't': this.focus - }; - }, - }, +function queueUpdated(q) { + queue = q; +} - watch: { - antennaId: { - async handler() { - this.antenna = await os.api('antennas/show', { - antennaId: this.antennaId - }); - }, - immediate: true - } - }, +function top() { + scroll(rootEl, { top: 0 }); +} - methods: { - queueUpdated(q) { - this.queue = q; - }, +async function timetravel() { + const { canceled, result: date } = await os.inputDate({ + title: i18n.ts.date, + }); + if (canceled) return; - top() { - scroll(this.$el, { top: 0 }); - }, + tlEl.timetravel(date); +} - async timetravel() { - const { canceled, result: date } = await os.inputDate({ - title: this.$ts.date, - }); - if (canceled) return; +function settings() { + router.push(`/my/antennas/${props.antennaId}`); +} - this.$refs.tl.timetravel(date); - }, +function focus() { + tlEl.focus(); +} - settings() { - this.$router.push(`/my/antennas/${this.antennaId}`); - }, +watch(() => props.antennaId, async () => { + antenna = await os.api('antennas/show', { + antennaId: props.antennaId, + }); +}, { immediate: true }); - focus() { - (this.$refs.tl as any).focus(); - } - } -}); +const headerActions = $computed(() => antenna ? [{ + icon: 'fas fa-calendar-alt', + text: i18n.ts.jumpToSpecifiedDate, + handler: timetravel, +}, { + icon: 'fas fa-cog', + text: i18n.ts.settings, + handler: settings, +}] : []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => antenna ? { + title: antenna.name, + icon: 'fas fa-satellite', +} : null)); </script> <style lang="scss" scoped> @@ -122,7 +110,7 @@ export default defineComponent({ > .tl { background: var(--bg); border-radius: var(--radius); - overflow: clip; + overflow: hidden; overflow: clip; } &.min-width_800px { diff --git a/packages/client/src/pages/api-console.vue b/packages/client/src/pages/api-console.vue index 88acbcd3a3..2f8eeadff1 100644 --- a/packages/client/src/pages/api-console.vue +++ b/packages/client/src/pages/api-console.vue @@ -1,40 +1,43 @@ <template> -<MkSpacer :content-max="700"> - <div class="_formRoot"> - <div class="_formBlock"> - <MkInput v-model="endpoint" :datalist="endpoints" class="_formBlock" @update:modelValue="onEndpointChange()"> - <template #label>Endpoint</template> - </MkInput> - <MkTextarea v-model="body" class="_formBlock" code> - <template #label>Params (JSON or JSON5)</template> - </MkTextarea> - <MkSwitch v-model="withCredential" class="_formBlock"> - With credential - </MkSwitch> - <MkButton class="_formBlock" primary :disabled="sending" @click="send"> - <template v-if="sending"><MkEllipsis/></template> - <template v-else><i class="fas fa-paper-plane"></i> Send</template> - </MkButton> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="_formRoot"> + <div class="_formBlock"> + <MkInput v-model="endpoint" :datalist="endpoints" class="_formBlock" @update:modelValue="onEndpointChange()"> + <template #label>Endpoint</template> + </MkInput> + <MkTextarea v-model="body" class="_formBlock" code> + <template #label>Params (JSON or JSON5)</template> + </MkTextarea> + <MkSwitch v-model="withCredential" class="_formBlock"> + With credential + </MkSwitch> + <MkButton class="_formBlock" primary :disabled="sending" @click="send"> + <template v-if="sending"><MkEllipsis/></template> + <template v-else><i class="fas fa-paper-plane"></i> Send</template> + </MkButton> + </div> + <div v-if="res" class="_formBlock"> + <MkTextarea v-model="res" code readonly tall> + <template #label>Response</template> + </MkTextarea> + </div> </div> - <div v-if="res" class="_formBlock"> - <MkTextarea v-model="res" code readonly tall> - <template #label>Response</template> - </MkTextarea> - </div> - </div> -</MkSpacer> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { ref } from 'vue'; import JSON5 from 'json5'; +import { Endpoints } from 'misskey-js'; import MkButton from '@/components/ui/button.vue'; import MkInput from '@/components/form/input.vue'; import MkTextarea from '@/components/form/textarea.vue'; import MkSwitch from '@/components/form/switch.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; -import { Endpoints } from 'misskey-js'; +import { definePageMetadata } from '@/scripts/page-metadata'; const body = ref('{}'); const endpoint = ref(''); @@ -75,10 +78,12 @@ function onEndpointChange() { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: 'API console', - icon: 'fas fa-terminal' - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'API console', + icon: 'fas fa-terminal', }); </script> diff --git a/packages/client/src/pages/auth.vue b/packages/client/src/pages/auth.vue index e65161dd2b..9457cd6b2f 100644 --- a/packages/client/src/pages/auth.vue +++ b/packages/client/src/pages/auth.vue @@ -15,7 +15,7 @@ <h1>{{ $ts._auth.denied }}</h1> </div> <div v-if="state == 'accepted'" class="accepted"> - <h1>{{ session.app.isAuthorized ? this.$t('already-authorized') : this.$ts.allowed }}</h1> + <h1>{{ session.app.isAuthorized ? $t('already-authorized') : $ts.allowed }}</h1> <p v-if="session.app.callbackUrl">{{ $ts._auth.callback }}<MkEllipsis/></p> <p v-if="!session.app.callbackUrl">{{ $ts._auth.pleaseGoBack }}</p> </div> @@ -40,24 +40,20 @@ export default defineComponent({ XForm, MkSignin, }, + props: ['token'], data() { return { state: null, session: null, - fetching: true + fetching: true, }; }, - computed: { - token(): string { - return this.$route.params.token; - } - }, mounted() { if (!this.$i) return; // Fetch session os.api('auth/session/show', { - token: this.token + token: this.token, }).then(session => { this.session = session; this.fetching = false; @@ -65,7 +61,7 @@ export default defineComponent({ // 既に連携していた場合 if (this.session.app.isAuthorized) { os.api('auth/accept', { - token: this.session.token + token: this.session.token, }).then(() => { this.accepted(); }); @@ -85,8 +81,8 @@ export default defineComponent({ } }, onLogin(res) { login(res.i); - } - } + }, + }, }); </script> diff --git a/packages/client/src/pages/channel-editor.vue b/packages/client/src/pages/channel-editor.vue index ea3a5dab76..0fa1f69518 100644 --- a/packages/client/src/pages/channel-editor.vue +++ b/packages/client/src/pages/channel-editor.vue @@ -1,127 +1,120 @@ <template> -<MkSpacer :content-max="700"> - <div class="_formRoot"> - <MkInput v-model="name" class="_formBlock"> - <template #label>{{ $ts.name }}</template> - </MkInput> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="_formRoot"> + <MkInput v-model="name" class="_formBlock"> + <template #label>{{ $ts.name }}</template> + </MkInput> - <MkTextarea v-model="description" class="_formBlock"> - <template #label>{{ $ts.description }}</template> - </MkTextarea> + <MkTextarea v-model="description" class="_formBlock"> + <template #label>{{ $ts.description }}</template> + </MkTextarea> - <div class="banner"> - <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="fas fa-plus"></i> {{ $ts._channel.setBanner }}</MkButton> - <div v-else-if="bannerUrl"> - <img :src="bannerUrl" style="width: 100%;"/> - <MkButton @click="removeBannerImage()"><i class="fas fa-trash-alt"></i> {{ $ts._channel.removeBanner }}</MkButton> + <div class="banner"> + <MkButton v-if="bannerId == null" @click="setBannerImage"><i class="fas fa-plus"></i> {{ $ts._channel.setBanner }}</MkButton> + <div v-else-if="bannerUrl"> + <img :src="bannerUrl" style="width: 100%;"/> + <MkButton @click="removeBannerImage()"><i class="fas fa-trash-alt"></i> {{ $ts._channel.removeBanner }}</MkButton> + </div> + </div> + <div class="_formBlock"> + <MkButton primary @click="save()"><i class="fas fa-save"></i> {{ channelId ? $ts.save : $ts.create }}</MkButton> </div> </div> - <div class="_formBlock"> - <MkButton primary @click="save()"><i class="fas fa-save"></i> {{ channelId ? $ts.save : $ts.create }}</MkButton> - </div> - </div> -</MkSpacer> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed, inject, watch } from 'vue'; import MkTextarea from '@/components/form/textarea.vue'; import MkButton from '@/components/ui/button.vue'; import MkInput from '@/components/form/input.vue'; import { selectFile } from '@/scripts/select-file'; import * as os from '@/os'; -import * as symbols from '@/symbols'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - MkTextarea, MkButton, MkInput, - }, +const router = useRouter(); - props: { - channelId: { - type: String, - required: false - }, - }, +const props = defineProps<{ + channelId?: string; +}>(); - data() { - return { - [symbols.PAGE_INFO]: computed(() => this.channelId ? { - title: this.$ts._channel.edit, - icon: 'fas fa-satellite-dish', - bg: 'var(--bg)', - } : { - title: this.$ts._channel.create, - icon: 'fas fa-satellite-dish', - bg: 'var(--bg)', - }), - channel: null, - name: null, - description: null, - bannerUrl: null, - bannerId: null, - }; - }, +let channel = $ref(null); +let name = $ref(null); +let description = $ref(null); +let bannerUrl = $ref<string | null>(null); +let bannerId = $ref<string | null>(null); - watch: { - async bannerId() { - if (this.bannerId == null) { - this.bannerUrl = null; - } else { - this.bannerUrl = (await os.api('drive/files/show', { - fileId: this.bannerId, - })).url; - } - }, - }, +watch(() => bannerId, async () => { + if (bannerId == null) { + bannerUrl = null; + } else { + bannerUrl = (await os.api('drive/files/show', { + fileId: bannerId, + })).url; + } +}); - async created() { - if (this.channelId) { - this.channel = await os.api('channels/show', { - channelId: this.channelId, - }); +async function fetchChannel() { + if (props.channelId == null) return; - this.name = this.channel.name; - this.description = this.channel.description; - this.bannerId = this.channel.bannerId; - this.bannerUrl = this.channel.bannerUrl; - } - }, + channel = await os.api('channels/show', { + channelId: props.channelId, + }); - methods: { - save() { - const params = { - name: this.name, - description: this.description, - bannerId: this.bannerId, - }; + name = channel.name; + description = channel.description; + bannerId = channel.bannerId; + bannerUrl = channel.bannerUrl; +} - if (this.channelId) { - params.channelId = this.channelId; - os.api('channels/update', params) - .then(channel => { - os.success(); - }); - } else { - os.api('channels/create', params) - .then(channel => { - os.success(); - this.$router.push(`/channels/${channel.id}`); - }); - } - }, +fetchChannel(); - setBannerImage(evt) { - selectFile(evt.currentTarget ?? evt.target, null).then(file => { - this.bannerId = file.id; - }); - }, +function save() { + const params = { + name: name, + description: description, + bannerId: bannerId, + }; - removeBannerImage() { - this.bannerId = null; - } + if (props.channelId) { + params.channelId = props.channelId; + os.api('channels/update', params).then(() => { + os.success(); + }); + } else { + os.api('channels/create', params).then(created => { + os.success(); + router.push(`/channels/${created.id}`); + }); } -}); +} + +function setBannerImage(evt) { + selectFile(evt.currentTarget ?? evt.target, null).then(file => { + bannerId = file.id; + }); +} + +function removeBannerImage() { + bannerId = null; +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => props.channelId ? { + title: i18n.ts._channel.edit, + icon: 'fas fa-satellite-dish', +} : { + title: i18n.ts._channel.create, + icon: 'fas fa-satellite-dish', +})); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/channel.vue b/packages/client/src/pages/channel.vue index c9a8f36844..1443a9b644 100644 --- a/packages/client/src/pages/channel.vue +++ b/packages/client/src/pages/channel.vue @@ -1,98 +1,86 @@ <template> -<MkSpacer :content-max="700"> - <div v-if="channel"> - <div class="wpgynlbz _panel _gap" :class="{ hide: !showBanner }"> - <XChannelFollowButton :channel="channel" :full="true" class="subscribe"/> - <button class="_button toggle" @click="() => showBanner = !showBanner"> - <template v-if="showBanner"><i class="fas fa-angle-up"></i></template> - <template v-else><i class="fas fa-angle-down"></i></template> - </button> - <div v-if="!showBanner" class="hideOverlay"> - </div> - <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner"> - <div class="status"> - <div><i class="fas fa-users fa-fw"></i><I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div> - <div><i class="fas fa-pencil-alt fa-fw"></i><I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div v-if="channel"> + <div class="wpgynlbz _panel _gap" :class="{ hide: !showBanner }"> + <XChannelFollowButton :channel="channel" :full="true" class="subscribe"/> + <button class="_button toggle" @click="() => showBanner = !showBanner"> + <template v-if="showBanner"><i class="fas fa-angle-up"></i></template> + <template v-else><i class="fas fa-angle-down"></i></template> + </button> + <div v-if="!showBanner" class="hideOverlay"> + </div> + <div :style="{ backgroundImage: channel.bannerUrl ? `url(${channel.bannerUrl})` : null }" class="banner"> + <div class="status"> + <div><i class="fas fa-users fa-fw"></i><I18n :src="$ts._channel.usersCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.usersCount }}</b></template></I18n></div> + <div><i class="fas fa-pencil-alt fa-fw"></i><I18n :src="$ts._channel.notesCount" tag="span" style="margin-left: 4px;"><template #n><b>{{ channel.notesCount }}</b></template></I18n></div> + </div> + <div class="fade"></div> + </div> + <div v-if="channel.description" class="description"> + <Mfm :text="channel.description" :is-note="false" :i="$i"/> </div> - <div class="fade"></div> - </div> - <div v-if="channel.description" class="description"> - <Mfm :text="channel.description" :is-note="false" :i="$i"/> </div> - </div> - <XPostForm v-if="$i" :channel="channel" class="post-form _panel _gap" fixed/> + <XPostForm v-if="$i" :channel="channel" class="post-form _panel _gap" fixed/> - <XTimeline :key="channelId" class="_gap" src="channel" :channel="channelId" @before="before" @after="after"/> - </div> -</MkSpacer> + <XTimeline :key="channelId" class="_gap" src="channel" :channel="channelId" @before="before" @after="after"/> + </div> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed, inject, watch } from 'vue'; import MkContainer from '@/components/ui/container.vue'; import XPostForm from '@/components/post-form.vue'; import XTimeline from '@/components/timeline.vue'; import XChannelFollowButton from '@/components/channel-follow-button.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; +import { useRouter } from '@/router'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; -export default defineComponent({ - components: { - MkContainer, - XPostForm, - XTimeline, - XChannelFollowButton - }, +const router = useRouter(); - props: { - channelId: { - type: String, - required: true - } - }, +const props = defineProps<{ + channelId: string; +}>(); - data() { - return { - [symbols.PAGE_INFO]: computed(() => this.channel ? { - title: this.channel.name, - icon: 'fas fa-satellite-dish', - bg: 'var(--bg)', - actions: [...(this.$i && this.$i.id === this.channel.userId ? [{ - icon: 'fas fa-cog', - text: this.$ts.edit, - handler: this.edit, - }] : [])], - } : null), - channel: null, - showBanner: true, - pagination: { - endpoint: 'channels/timeline' as const, - limit: 10, - params: computed(() => ({ - channelId: this.channelId, - })) - }, - }; - }, +let channel = $ref(null); +let showBanner = $ref(true); +const pagination = { + endpoint: 'channels/timeline' as const, + limit: 10, + params: computed(() => ({ + channelId: props.channelId, + })), +}; - watch: { - channelId: { - async handler() { - this.channel = await os.api('channels/show', { - channelId: this.channelId, - }); - }, - immediate: true - } - }, +watch(() => props.channelId, async () => { + channel = await os.api('channels/show', { + channelId: props.channelId, + }); +}, { immediate: true }); - methods: { - edit() { - this.$router.push(`/channels/${this.channel.id}/edit`); - } - }, -}); +function edit() { + router.push(`/channels/${channel.id}/edit`); +} + +const headerActions = $computed(() => channel && channel.userId ? [{ + icon: 'fas fa-cog', + text: i18n.ts.edit, + handler: edit, +}] : null); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => channel ? { + title: channel.name, + icon: 'fas fa-satellite-dish', +} : null)); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/channels.vue b/packages/client/src/pages/channels.vue index 4e538a6da3..63612bc57f 100644 --- a/packages/client/src/pages/channels.vue +++ b/packages/client/src/pages/channels.vue @@ -1,82 +1,79 @@ <template> -<MkSpacer :content-max="700"> - <div v-if="tab === 'featured'" class="_content grwlizim featured"> - <MkPagination v-slot="{items}" :pagination="featuredPagination"> - <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/> - </MkPagination> - </div> - <div v-else-if="tab === 'following'" class="_content grwlizim following"> - <MkPagination v-slot="{items}" :pagination="followingPagination"> - <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/> - </MkPagination> - </div> - <div v-else-if="tab === 'owned'" class="_content grwlizim owned"> - <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton> - <MkPagination v-slot="{items}" :pagination="ownedPagination"> - <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/> - </MkPagination> - </div> -</MkSpacer> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div v-if="tab === 'featured'" class="_content grwlizim featured"> + <MkPagination v-slot="{items}" :pagination="featuredPagination"> + <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/> + </MkPagination> + </div> + <div v-else-if="tab === 'following'" class="_content grwlizim following"> + <MkPagination v-slot="{items}" :pagination="followingPagination"> + <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/> + </MkPagination> + </div> + <div v-else-if="tab === 'owned'" class="_content grwlizim owned"> + <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton> + <MkPagination v-slot="{items}" :pagination="ownedPagination"> + <MkChannelPreview v-for="channel in items" :key="channel.id" class="_gap" :channel="channel"/> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed, defineComponent, inject } from 'vue'; import MkChannelPreview from '@/components/channel-preview.vue'; import MkPagination from '@/components/ui/pagination.vue'; import MkButton from '@/components/ui/button.vue'; -import * as symbols from '@/symbols'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - MkChannelPreview, MkPagination, MkButton, - }, - data() { - return { - [symbols.PAGE_INFO]: computed(() => ({ - title: this.$ts.channel, - icon: 'fas fa-satellite-dish', - bg: 'var(--bg)', - actions: [{ - icon: 'fas fa-plus', - text: this.$ts.create, - handler: this.create, - }], - tabs: [{ - active: this.tab === 'featured', - title: this.$ts._channel.featured, - icon: 'fas fa-fire-alt', - onClick: () => { this.tab = 'featured'; }, - }, { - active: this.tab === 'following', - title: this.$ts._channel.following, - icon: 'fas fa-heart', - onClick: () => { this.tab = 'following'; }, - }, { - active: this.tab === 'owned', - title: this.$ts._channel.owned, - icon: 'fas fa-edit', - onClick: () => { this.tab = 'owned'; }, - },] - })), - tab: 'featured', - featuredPagination: { - endpoint: 'channels/featured' as const, - noPaging: true, - }, - followingPagination: { - endpoint: 'channels/followed' as const, - limit: 5, - }, - ownedPagination: { - endpoint: 'channels/owned' as const, - limit: 5, - }, - }; - }, - methods: { - create() { - this.$router.push(`/channels/new`); - } - } -}); +const router = useRouter(); + +let tab = $ref('featured'); + +const featuredPagination = { + endpoint: 'channels/featured' as const, + noPaging: true, +}; +const followingPagination = { + endpoint: 'channels/followed' as const, + limit: 5, +}; +const ownedPagination = { + endpoint: 'channels/owned' as const, + limit: 5, +}; + +function create() { + router.push('/channels/new'); +} + +const headerActions = $computed(() => [{ + icon: 'fas fa-plus', + text: i18n.ts.create, + handler: create, +}]); + +const headerTabs = $computed(() => [{ + key: 'featured', + title: i18n.ts._channel.featured, + icon: 'fas fa-fire-alt', +}, { + key: 'following', + title: i18n.ts._channel.following, + icon: 'fas fa-heart', +}, { + key: 'owned', + title: i18n.ts._channel.owned, + icon: 'fas fa-edit', +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.channel, + icon: 'fas fa-satellite-dish', +}))); </script> diff --git a/packages/client/src/pages/clip.vue b/packages/client/src/pages/clip.vue index c999f1bfc9..608e4ba7ee 100644 --- a/packages/client/src/pages/clip.vue +++ b/packages/client/src/pages/clip.vue @@ -1,136 +1,108 @@ <template> -<MkSpacer :content-max="800"> - <div v-if="clip"> - <div class="okzinsic _panel"> - <div v-if="clip.description" class="description"> - <Mfm :text="clip.description" :is-note="false" :i="$i"/> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions"/></template> + <MkSpacer :content-max="800"> + <div v-if="clip"> + <div class="okzinsic _panel"> + <div v-if="clip.description" class="description"> + <Mfm :text="clip.description" :is-note="false" :i="$i"/> + </div> + <div class="user"> + <MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/> + </div> </div> - <div class="user"> - <MkAvatar :user="clip.user" class="avatar" :show-indicator="true"/> <MkUserName :user="clip.user" :nowrap="false"/> - </div> - </div> - <XNotes :pagination="pagination" :detail="true"/> - </div> -</MkSpacer> + <XNotes :pagination="pagination" :detail="true"/> + </div> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; -import MkContainer from '@/components/ui/container.vue'; -import XPostForm from '@/components/post-form.vue'; +<script lang="ts" setup> +import { computed, watch, provide } from 'vue'; +import * as misskey from 'misskey-js'; import XNotes from '@/components/notes.vue'; +import { $i } from '@/account'; +import { i18n } from '@/i18n'; import * as os from '@/os'; -import * as symbols from '@/symbols'; +import { definePageMetadata } from '@/scripts/page-metadata'; -export default defineComponent({ - components: { - MkContainer, - XPostForm, - XNotes, - }, +const props = defineProps<{ + clipId: string, +}>(); - props: { - clipId: { - type: String, - required: true - } - }, +let clip: misskey.entities.Clip = $ref<misskey.entities.Clip>(); +const pagination = { + endpoint: 'clips/notes' as const, + limit: 10, + params: computed(() => ({ + clipId: props.clipId, + })), +}; - data() { - return { - [symbols.PAGE_INFO]: computed(() => this.clip ? { - title: this.clip.name, - icon: 'fas fa-paperclip', - bg: 'var(--bg)', - actions: [{ - icon: 'fas fa-ellipsis-h', - handler: this.menu - }], - } : null), - clip: null, - pagination: { - endpoint: 'clips/notes' as const, - limit: 10, - params: computed(() => ({ - clipId: this.clipId, - })) - }, - }; - }, +const isOwned: boolean | null = $computed<boolean | null>(() => $i && clip && ($i.id === clip.userId)); - computed: { - isOwned(): boolean { - return this.$i && this.clip && (this.$i.id === this.clip.userId); - } - }, +watch(() => props.clipId, async () => { + clip = await os.api('clips/show', { + clipId: props.clipId, + }); +}, { + immediate: true, +}); - watch: { - clipId: { - async handler() { - this.clip = await os.api('clips/show', { - clipId: this.clipId, - }); - }, - immediate: true - } - }, +provide('currentClipPage', $$(clip)); - created() { +const headerActions = $computed(() => clip && isOwned ? [{ + icon: 'fas fa-pencil-alt', + text: i18n.ts.edit, + handler: async (): Promise<void> => { + const { canceled, result } = await os.form(clip.name, { + name: { + type: 'string', + label: i18n.ts.name, + default: clip.name, + }, + description: { + type: 'string', + required: false, + multiline: true, + label: i18n.ts.description, + default: clip.description, + }, + isPublic: { + type: 'boolean', + label: i18n.ts.public, + default: clip.isPublic, + }, + }); + if (canceled) return; + os.apiWithDialog('clips/update', { + clipId: clip.id, + ...result, + }); }, +}, { + icon: 'fas fa-trash-alt', + text: i18n.ts.delete, + danger: true, + handler: async (): Promise<void> => { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('deleteAreYouSure', { x: clip.name }), + }); + if (canceled) return; - methods: { - menu(ev) { - os.popupMenu([this.isOwned ? { - icon: 'fas fa-pencil-alt', - text: this.$ts.edit, - action: async () => { - const { canceled, result } = await os.form(this.clip.name, { - name: { - type: 'string', - label: this.$ts.name, - default: this.clip.name - }, - description: { - type: 'string', - required: false, - multiline: true, - label: this.$ts.description, - default: this.clip.description - }, - isPublic: { - type: 'boolean', - label: this.$ts.public, - default: this.clip.isPublic - } - }); - if (canceled) return; - - os.apiWithDialog('clips/update', { - clipId: this.clip.id, - ...result - }); - } - } : undefined, this.isOwned ? { - icon: 'fas fa-trash-alt', - text: this.$ts.delete, - danger: true, - action: async () => { - const { canceled } = await os.confirm({ - type: 'warning', - text: this.$t('deleteAreYouSure', { x: this.clip.name }), - }); - if (canceled) return; + await os.apiWithDialog('clips/delete', { + clipId: clip.id, + }); + }, +}] : null); - await os.apiWithDialog('clips/delete', { - clipId: this.clip.id, - }); - } - } : undefined], ev.currentTarget ?? ev.target); - } - } -}); +definePageMetadata(computed(() => clip ? { + title: clip.name, + icon: 'fas fa-paperclip', +} : null)); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/drive.vue b/packages/client/src/pages/drive.vue index 68777bb083..988a1bf3df 100644 --- a/packages/client/src/pages/drive.vue +++ b/packages/client/src/pages/drive.vue @@ -8,17 +8,18 @@ import { computed } from 'vue'; import XDrive from '@/components/drive.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let folder = $ref(null); -defineExpose({ - [symbols.PAGE_INFO]: computed(() => ({ - title: folder ? folder.name : i18n.ts.drive, - icon: 'fas fa-cloud', - bg: 'var(--bg)', - hideHeader: true, - })), -}); +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: folder ? folder.name : i18n.ts.drive, + icon: 'fas fa-cloud', + hideHeader: true, +}))); </script> diff --git a/packages/client/src/pages/emojis.vue b/packages/client/src/pages/emojis.vue deleted file mode 100644 index f44b29df04..0000000000 --- a/packages/client/src/pages/emojis.vue +++ /dev/null @@ -1,56 +0,0 @@ -<template> -<div :class="$style.root"> - <XCategory v-if="tab === 'category'"/> -</div> -</template> - -<script lang="ts" setup> -import { ref, computed } from 'vue'; -import * as os from '@/os'; -import * as symbols from '@/symbols'; -import XCategory from './emojis.category.vue'; -import { i18n } from '@/i18n'; - -const tab = ref('category'); - -function menu(ev) { - os.popupMenu([{ - icon: 'fas fa-download', - text: i18n.ts.export, - action: async () => { - os.api('export-custom-emojis', { - }) - .then(() => { - os.alert({ - type: 'info', - text: i18n.ts.exportRequested, - }); - }).catch((err) => { - os.alert({ - type: 'error', - text: err.message, - }); - }); - } - }], ev.currentTarget ?? ev.target); -} - -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.customEmojis, - icon: 'fas fa-laugh', - bg: 'var(--bg)', - actions: [{ - icon: 'fas fa-ellipsis-h', - handler: menu, - }], - }, -}); -</script> - -<style lang="scss" module> -.root { - max-width: 1000px; - margin: 0 auto; -} -</style> diff --git a/packages/client/src/pages/explore.featured.vue b/packages/client/src/pages/explore.featured.vue new file mode 100644 index 0000000000..0f32804b72 --- /dev/null +++ b/packages/client/src/pages/explore.featured.vue @@ -0,0 +1,30 @@ +<template> +<MkSpacer :content-max="800"> + <MkTab v-model="tab" style="margin-bottom: var(--margin);"> + <option value="notes">{{ i18n.ts.notes }}</option> + <option value="polls">{{ i18n.ts.poll }}</option> + </MkTab> + <XNotes v-if="tab === 'notes'" :pagination="paginationForNotes"/> + <XNotes v-else-if="tab === 'polls'" :pagination="paginationForPolls"/> +</MkSpacer> +</template> + +<script lang="ts" setup> +import XNotes from '@/components/notes.vue'; +import MkTab from '@/components/tab.vue'; +import { i18n } from '@/i18n'; + +const paginationForNotes = { + endpoint: 'notes/featured' as const, + limit: 10, + offsetMode: true, +}; + +const paginationForPolls = { + endpoint: 'notes/polls/recommendation' as const, + limit: 10, + offsetMode: true, +}; + +let tab = $ref('notes'); +</script> diff --git a/packages/client/src/pages/explore.users.vue b/packages/client/src/pages/explore.users.vue new file mode 100644 index 0000000000..bdc96b33a3 --- /dev/null +++ b/packages/client/src/pages/explore.users.vue @@ -0,0 +1,143 @@ +<template> +<MkSpacer :content-max="1200"> + <div v-if="origin === 'local'"> + <template v-if="tag == null"> + <MkFolder class="_gap" persist-key="explore-pinned-users"> + <template #header><i class="fas fa-bookmark fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.pinnedUsers }}</template> + <XUserList :pagination="pinnedUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-popular-users"> + <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template> + <XUserList :pagination="popularUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-recently-updated-users"> + <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template> + <XUserList :pagination="recentlyUpdatedUsers"/> + </MkFolder> + <MkFolder class="_gap" persist-key="explore-recently-registered-users"> + <template #header><i class="fas fa-plus fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyRegisteredUsers }}</template> + <XUserList :pagination="recentlyRegisteredUsers"/> + </MkFolder> + </template> + </div> + <div v-else> + <MkFolder ref="tagsEl" :foldable="true" :expanded="false" class="_gap"> + <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularTags }}</template> + + <div class="vxjfqztj"> + <MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA> + <MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ tag.tag }}</MkA> + </div> + </MkFolder> + + <MkFolder v-if="tag != null" :key="`${tag}`" class="_gap"> + <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template> + <XUserList :pagination="tagUsers"/> + </MkFolder> + + <template v-if="tag == null"> + <MkFolder class="_gap"> + <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template> + <XUserList :pagination="popularUsersF"/> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template> + <XUserList :pagination="recentlyUpdatedUsersF"/> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-rocket fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyDiscoveredUsers }}</template> + <XUserList :pagination="recentlyRegisteredUsersF"/> + </MkFolder> + </template> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import XUserList from '@/components/user-list.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import number from '@/filters/number'; +import * as os from '@/os'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; + +const props = defineProps<{ + origin: 'local' | 'remote'; + tag?: string; +}>(); + +let tagsEl = $ref<InstanceType<typeof MkFolder>>(); +let tagsLocal = $ref([]); +let tagsRemote = $ref([]); + +watch(() => props.tag, () => { + if (tagsEl) tagsEl.toggleContent(props.tag == null); +}); + +const tagUsers = $computed(() => ({ + endpoint: 'hashtags/users' as const, + limit: 30, + params: { + tag: props.tag, + origin: 'combined', + sort: '+follower', + }, +})); + +const pinnedUsers = { endpoint: 'pinned-users' }; +const popularUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'local', + sort: '+follower', +} }; +const recentlyUpdatedUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'local', + sort: '+updatedAt', +} }; +const recentlyRegisteredUsers = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'local', + state: 'alive', + sort: '+createdAt', +} }; +const popularUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { + state: 'alive', + origin: 'remote', + sort: '+follower', +} }; +const recentlyUpdatedUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'combined', + sort: '+updatedAt', +} }; +const recentlyRegisteredUsersF = { endpoint: 'users', limit: 10, noPaging: true, params: { + origin: 'combined', + sort: '+createdAt', +} }; + +os.api('hashtags/list', { + sort: '+attachedLocalUsers', + attachedToLocalUserOnly: true, + limit: 30, +}).then(tags => { + tagsLocal = tags; +}); +os.api('hashtags/list', { + sort: '+attachedRemoteUsers', + attachedToRemoteUserOnly: true, + limit: 30, +}).then(tags => { + tagsRemote = tags; +}); +</script> + +<style lang="scss" scoped> +.vxjfqztj { + > * { + margin-right: 16px; + + &.local { + font-weight: bold; + } + } +} +</style> diff --git a/packages/client/src/pages/explore.vue b/packages/client/src/pages/explore.vue index 04cc3662a7..c0b9438a50 100644 --- a/packages/client/src/pages/explore.vue +++ b/packages/client/src/pages/explore.vue @@ -1,261 +1,94 @@ <template> -<div> - <MkSpacer :content-max="1200"> - <div class="lznhrdub"> - <div v-if="tab === 'local'"> - <div v-if="meta && stats && tag == null" class="localfedi7 _block _isolated" :style="{ backgroundImage: meta.bannerUrl ? `url(${meta.bannerUrl})` : null }"> - <header><span>{{ $t('explore', { host: meta.name || 'Misskey' }) }}</span></header> - <div><span>{{ $t('exploreUsersCount', { count: num(stats.originalUsersCount) }) }}</span></div> - </div> - - <template v-if="tag == null"> - <MkFolder class="_gap" persist-key="explore-pinned-users"> - <template #header><i class="fas fa-bookmark fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.pinnedUsers }}</template> - <XUserList :pagination="pinnedUsers"/> - </MkFolder> - <MkFolder class="_gap" persist-key="explore-popular-users"> - <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template> - <XUserList :pagination="popularUsers"/> - </MkFolder> - <MkFolder class="_gap" persist-key="explore-recently-updated-users"> - <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template> - <XUserList :pagination="recentlyUpdatedUsers"/> - </MkFolder> - <MkFolder class="_gap" persist-key="explore-recently-registered-users"> - <template #header><i class="fas fa-plus fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyRegisteredUsers }}</template> - <XUserList :pagination="recentlyRegisteredUsers"/> - </MkFolder> - </template> - </div> - <div v-else-if="tab === 'remote'"> - <div v-if="tag == null" class="localfedi7 _block _isolated" :style="{ backgroundImage: `url(/client-assets/fedi.jpg)` }"> - <header><span>{{ $ts.exploreFediverse }}</span></header> - </div> - - <MkFolder ref="tags" :foldable="true" :expanded="false" class="_gap"> - <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularTags }}</template> - - <div class="vxjfqztj"> - <MkA v-for="tag in tagsLocal" :key="'local:' + tag.tag" :to="`/explore/tags/${tag.tag}`" class="local">{{ tag.tag }}</MkA> - <MkA v-for="tag in tagsRemote" :key="'remote:' + tag.tag" :to="`/explore/tags/${tag.tag}`">{{ tag.tag }}</MkA> - </div> - </MkFolder> - - <MkFolder v-if="tag != null" :key="`${tag}`" class="_gap"> - <template #header><i class="fas fa-hashtag fa-fw" style="margin-right: 0.5em;"></i>{{ tag }}</template> - <XUserList :pagination="tagUsers"/> - </MkFolder> - - <template v-if="tag == null"> - <MkFolder class="_gap"> - <template #header><i class="fas fa-chart-line fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.popularUsers }}</template> - <XUserList :pagination="popularUsersF"/> - </MkFolder> - <MkFolder class="_gap"> - <template #header><i class="fas fa-comment-alt fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyUpdatedUsers }}</template> - <XUserList :pagination="recentlyUpdatedUsersF"/> - </MkFolder> - <MkFolder class="_gap"> - <template #header><i class="fas fa-rocket fa-fw" style="margin-right: 0.5em;"></i>{{ $ts.recentlyDiscoveredUsers }}</template> - <XUserList :pagination="recentlyRegisteredUsersF"/> - </MkFolder> - </template> - </div> - <div v-else-if="tab === 'search'"> - <div class="_isolated"> - <MkInput v-model="searchQuery" :debounce="true" type="search"> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <div class="lznhrdub"> + <div v-if="tab === 'featured'"> + <XFeatured/> + </div> + <div v-else-if="tab === 'localUsers'"> + <XUsers origin="local"/> + </div> + <div v-else-if="tab === 'remoteUsers'"> + <XUsers origin="remote"/> + </div> + <div v-else-if="tab === 'search'"> + <MkSpacer :content-max="1200"> + <div> + <MkInput v-model="searchQuery" :debounce="true" type="search" class="_formBlock"> <template #prefix><i class="fas fa-search"></i></template> <template #label>{{ $ts.searchUser }}</template> </MkInput> - <MkRadios v-model="searchOrigin"> + <MkRadios v-model="searchOrigin" class="_formBlock"> <option value="combined">{{ $ts.all }}</option> <option value="local">{{ $ts.local }}</option> <option value="remote">{{ $ts.remote }}</option> </MkRadios> </div> - <XUserList v-if="searchQuery" ref="search" class="_gap" :pagination="searchPagination"/> - </div> + <XUserList v-if="searchQuery" ref="searchEl" class="_gap" :pagination="searchPagination"/> + </MkSpacer> </div> - </MkSpacer> -</div> + </div> +</MkStickyContainer> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; -import XUserList from '@/components/user-list.vue'; +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import XFeatured from './explore.featured.vue'; +import XUsers from './explore.users.vue'; import MkFolder from '@/components/ui/folder.vue'; import MkInput from '@/components/form/input.vue'; import MkRadios from '@/components/form/radios.vue'; import number from '@/filters/number'; import * as os from '@/os'; -import * as symbols from '@/symbols'; - -export default defineComponent({ - components: { - XUserList, - MkFolder, - MkInput, - MkRadios, - }, - - props: { - tag: { - type: String, - required: false - } - }, - - data() { - return { - [symbols.PAGE_INFO]: computed(() => ({ - title: this.$ts.explore, - icon: 'fas fa-hashtag', - bg: 'var(--bg)', - tabs: [{ - active: this.tab === 'local', - title: this.$ts.local, - onClick: () => { this.tab = 'local'; }, - }, { - active: this.tab === 'remote', - title: this.$ts.remote, - onClick: () => { this.tab = 'remote'; }, - }, { - active: this.tab === 'search', - title: this.$ts.search, - onClick: () => { this.tab = 'search'; }, - },] - })), - tab: 'local', - pinnedUsers: { endpoint: 'pinned-users' }, - popularUsers: { endpoint: 'users', limit: 10, noPaging: true, params: { - state: 'alive', - origin: 'local', - sort: '+follower', - } }, - recentlyUpdatedUsers: { endpoint: 'users', limit: 10, noPaging: true, params: { - origin: 'local', - sort: '+updatedAt', - } }, - recentlyRegisteredUsers: { endpoint: 'users', limit: 10, noPaging: true, params: { - origin: 'local', - state: 'alive', - sort: '+createdAt', - } }, - popularUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: { - state: 'alive', - origin: 'remote', - sort: '+follower', - } }, - recentlyUpdatedUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: { - origin: 'combined', - sort: '+updatedAt', - } }, - recentlyRegisteredUsersF: { endpoint: 'users', limit: 10, noPaging: true, params: { - origin: 'combined', - sort: '+createdAt', - } }, - searchPagination: { - endpoint: 'users/search' as const, - limit: 10, - params: computed(() => (this.searchQuery && this.searchQuery !== '') ? { - query: this.searchQuery, - origin: this.searchOrigin, - } : null) - }, - tagsLocal: [], - tagsRemote: [], - stats: null, - searchQuery: null, - searchOrigin: 'combined', - num: number, - }; - }, +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; +import XUserList from '@/components/user-list.vue'; - computed: { - meta() { - return this.$instance; - }, - tagUsers(): any { - return { - endpoint: 'hashtags/users' as const, - limit: 30, - params: { - tag: this.tag, - origin: 'combined', - sort: '+follower', - } - }; - }, - }, +const props = defineProps<{ + tag?: string; +}>(); - watch: { - tag() { - if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null); - }, - }, +let tab = $ref('featured'); +let tagsEl = $ref<InstanceType<typeof MkFolder>>(); +let searchQuery = $ref(null); +let searchOrigin = $ref('combined'); - created() { - os.api('hashtags/list', { - sort: '+attachedLocalUsers', - attachedToLocalUserOnly: true, - limit: 30 - }).then(tags => { - this.tagsLocal = tags; - }); - os.api('hashtags/list', { - sort: '+attachedRemoteUsers', - attachedToRemoteUserOnly: true, - limit: 30 - }).then(tags => { - this.tagsRemote = tags; - }); - os.api('stats').then(stats => { - this.stats = stats; - }); - }, +watch(() => props.tag, () => { + if (tagsEl) tagsEl.toggleContent(props.tag == null); }); -</script> - -<style lang="scss" scoped> -.localfedi7 { - color: #fff; - padding: 16px; - height: 80px; - background-position: 50%; - background-size: cover; - margin-bottom: var(--margin); - - > * { - &:not(:last-child) { - margin-bottom: 8px; - } - > span { - display: inline-block; - padding: 6px 8px; - background: rgba(0, 0, 0, 0.7); - } - } +const searchPagination = { + endpoint: 'users/search' as const, + limit: 10, + params: computed(() => (searchQuery && searchQuery !== '') ? { + query: searchQuery, + origin: searchOrigin, + } : null), +}; - > header { - font-size: 20px; - font-weight: bold; - } +const headerActions = $computed(() => []); - > div { - font-size: 14px; - opacity: 0.8; - } -} +const headerTabs = $computed(() => [{ + key: 'featured', + icon: 'fas fa-bolt', + title: i18n.ts.featured, +}, { + key: 'localUsers', + icon: 'fas fa-users', + title: i18n.ts.users, +}, { + key: 'remoteUsers', + icon: 'fas fa-users', + title: i18n.ts.remote, +}, { + key: 'search', + title: i18n.ts.search, +}]); -.vxjfqztj { - > * { - margin-right: 16px; - - &.local { - font-weight: bold; - } - } -} -</style> +definePageMetadata(computed(() => ({ + title: i18n.ts.explore, + icon: 'fas fa-hashtag', +}))); +</script> diff --git a/packages/client/src/pages/favorites.vue b/packages/client/src/pages/favorites.vue index b4f6ff35bc..6f75d68def 100644 --- a/packages/client/src/pages/favorites.vue +++ b/packages/client/src/pages/favorites.vue @@ -1,20 +1,23 @@ <template> -<MkSpacer :content-max="800"> - <MkPagination ref="pagingComponent" :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> - <div>{{ $ts.noNotes }}</div> - </div> - </template> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :content-max="800"> + <MkPagination ref="pagingComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noNotes }}</div> + </div> + </template> - <template #default="{ items }"> - <XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false"> - <XNote :key="item.id" :note="item.note" :class="$style.note"/> - </XList> - </template> - </MkPagination> -</MkSpacer> + <template #default="{ items }"> + <XList v-slot="{ item }" :items="items" :direction="'down'" :no-gap="false" :ad="false"> + <XNote :key="item.id" :note="item.note" :class="$style.note"/> + </XList> + </template> + </MkPagination> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> @@ -22,8 +25,8 @@ import { ref } from 'vue'; import MkPagination from '@/components/ui/pagination.vue'; import XNote from '@/components/note.vue'; import XList from '@/components/date-separated-list.vue'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const pagination = { endpoint: 'i/favorites' as const, @@ -32,12 +35,9 @@ const pagination = { const pagingComponent = ref<InstanceType<typeof MkPagination>>(); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.favorites, - icon: 'fas fa-star', - bg: 'var(--bg)', - }, +definePageMetadata({ + title: i18n.ts.favorites, + icon: 'fas fa-star', }); </script> diff --git a/packages/client/src/pages/featured.vue b/packages/client/src/pages/featured.vue deleted file mode 100644 index 14fe0cb740..0000000000 --- a/packages/client/src/pages/featured.vue +++ /dev/null @@ -1,25 +0,0 @@ -<template> -<MkSpacer :content-max="800"> - <XNotes ref="notes" :pagination="pagination"/> -</MkSpacer> -</template> - -<script lang="ts" setup> -import XNotes from '@/components/notes.vue'; -import * as symbols from '@/symbols'; -import { i18n } from '@/i18n'; - -const pagination = { - endpoint: 'notes/featured' as const, - limit: 10, - offsetMode: true, -}; - -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.featured, - icon: 'fas fa-fire-alt', - bg: 'var(--bg)', - }, -}); -</script> diff --git a/packages/client/src/pages/federation.vue b/packages/client/src/pages/federation.vue deleted file mode 100644 index 447918905b..0000000000 --- a/packages/client/src/pages/federation.vue +++ /dev/null @@ -1,236 +0,0 @@ -<template> -<MkSpacer :content-max="1000"> - <div class="taeiyria"> - <div class="query"> - <MkInput v-model="host" :debounce="true" class=""> - <template #prefix><i class="fas fa-search"></i></template> - <template #label>{{ $ts.host }}</template> - </MkInput> - <FormSplit style="margin-top: var(--margin);"> - <MkSelect v-model="state"> - <template #label>{{ $ts.state }}</template> - <option value="all">{{ $ts.all }}</option> - <option value="federating">{{ $ts.federating }}</option> - <option value="subscribing">{{ $ts.subscribing }}</option> - <option value="publishing">{{ $ts.publishing }}</option> - <option value="suspended">{{ $ts.suspended }}</option> - <option value="blocked">{{ $ts.blocked }}</option> - <option value="notResponding">{{ $ts.notResponding }}</option> - </MkSelect> - <MkSelect v-model="sort"> - <template #label>{{ $ts.sort }}</template> - <option value="+pubSub">{{ $ts.pubSub }} ({{ $ts.descendingOrder }})</option> - <option value="-pubSub">{{ $ts.pubSub }} ({{ $ts.ascendingOrder }})</option> - <option value="+notes">{{ $ts.notes }} ({{ $ts.descendingOrder }})</option> - <option value="-notes">{{ $ts.notes }} ({{ $ts.ascendingOrder }})</option> - <option value="+users">{{ $ts.users }} ({{ $ts.descendingOrder }})</option> - <option value="-users">{{ $ts.users }} ({{ $ts.ascendingOrder }})</option> - <option value="+following">{{ $ts.following }} ({{ $ts.descendingOrder }})</option> - <option value="-following">{{ $ts.following }} ({{ $ts.ascendingOrder }})</option> - <option value="+followers">{{ $ts.followers }} ({{ $ts.descendingOrder }})</option> - <option value="-followers">{{ $ts.followers }} ({{ $ts.ascendingOrder }})</option> - <option value="+caughtAt">{{ $ts.registeredAt }} ({{ $ts.descendingOrder }})</option> - <option value="-caughtAt">{{ $ts.registeredAt }} ({{ $ts.ascendingOrder }})</option> - <option value="+lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.descendingOrder }})</option> - <option value="-lastCommunicatedAt">{{ $ts.lastCommunication }} ({{ $ts.ascendingOrder }})</option> - </MkSelect> - </FormSplit> - </div> - - <MkPagination v-slot="{items}" ref="instances" :key="host + state" :pagination="pagination"> - <div class="dqokceoi"> - <MkA v-for="instance in items" :key="instance.id" class="instance" :to="`/instance-info/${instance.host}`"> - <div class="host"><img :src="instance.faviconUrl">{{ instance.host }}</div> - <div class="table"> - <div class="cell"> - <div class="key">{{ $ts.registeredAt }}</div> - <div class="value"><MkTime :time="instance.caughtAt"/></div> - </div> - <div class="cell"> - <div class="key">{{ $ts.software }}</div> - <div class="value">{{ instance.softwareName || `(${$ts.unknown})` }}</div> - </div> - <div class="cell"> - <div class="key">{{ $ts.version }}</div> - <div class="value">{{ instance.softwareVersion || `(${$ts.unknown})` }}</div> - </div> - <div class="cell"> - <div class="key">{{ $ts.users }}</div> - <div class="value">{{ instance.usersCount }}</div> - </div> - <div class="cell"> - <div class="key">{{ $ts.notes }}</div> - <div class="value">{{ instance.notesCount }}</div> - </div> - <div class="cell"> - <div class="key">{{ $ts.sent }}</div> - <div class="value"><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></div> - </div> - <div class="cell"> - <div class="key">{{ $ts.received }}</div> - <div class="value"><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></div> - </div> - </div> - <div class="footer"> - <span class="status" :class="getStatus(instance)">{{ getStatus(instance) }}</span> - <span class="pubSub"> - <span v-if="instance.followersCount > 0" class="sub"><i class="fas fa-caret-down icon"></i>Sub</span> - <span v-else class="sub"><i class="fas fa-caret-down icon"></i>-</span> - <span v-if="instance.followingCount > 0" class="pub"><i class="fas fa-caret-up icon"></i>Pub</span> - <span v-else class="pub"><i class="fas fa-caret-up icon"></i>-</span> - </span> - <span class="right"> - <span class="latestStatus">{{ instance.latestStatus || '-' }}</span> - <span class="lastCommunicatedAt"><MkTime :time="instance.lastCommunicatedAt"/></span> - </span> - </div> - </MkA> - </div> - </MkPagination> - </div> -</MkSpacer> -</template> - -<script lang="ts" setup> -import { computed } from 'vue'; -import MkButton from '@/components/ui/button.vue'; -import MkInput from '@/components/form/input.vue'; -import MkSelect from '@/components/form/select.vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import FormSplit from '@/components/form/split.vue'; -import * as os from '@/os'; -import * as symbols from '@/symbols'; -import { i18n } from '@/i18n'; - -let host = $ref(''); -let state = $ref('federating'); -let sort = $ref('+pubSub'); -const pagination = { - endpoint: 'federation/instances' as const, - limit: 10, - offsetMode: true, - params: computed(() => ({ - sort: sort, - host: host !== '' ? host : null, - ...( - state === 'federating' ? { federating: true } : - state === 'subscribing' ? { subscribing: true } : - state === 'publishing' ? { publishing: true } : - state === 'suspended' ? { suspended: true } : - state === 'blocked' ? { blocked: true } : - state === 'notResponding' ? { notResponding: true } : - {}) - })) -}; - -function getStatus(instance) { - if (instance.isSuspended) return 'suspended'; - if (instance.isNotResponding) return 'error'; - return 'alive'; -} - -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.federation, - icon: 'fas fa-globe', - bg: 'var(--bg)', - }, -}); -</script> - -<style lang="scss" scoped> -.taeiyria { - > .query { - background: var(--bg); - margin-bottom: 16px; - } -} - -.dqokceoi { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(270px, 1fr)); - grid-gap: 12px; - - > .instance { - padding: 16px; - background: var(--panel); - border-radius: 8px; - - &:hover { - text-decoration: none; - } - - > .host { - font-weight: bold; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - - > img { - width: 18px; - height: 18px; - margin-right: 6px; - vertical-align: middle; - } - } - - > .table { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(60px, 1fr)); - grid-gap: 6px; - margin: 6px 0; - font-size: 70%; - - > .cell { - > .key, > .value { - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - > .key { - opacity: 0.7; - } - - > .value { - } - } - } - - > .footer { - display: flex; - align-items: center; - font-size: 0.9em; - - > .status { - &.suspended { - opacity: 0.5; - } - - &.error { - color: var(--error); - } - - &.alive { - color: var(--success); - } - } - - > .pubSub { - margin-left: 8px; - } - - > .right { - margin-left: auto; - - > .latestStatus { - border: solid 1px var(--divider); - border-radius: 4px; - margin: 0 8px; - padding: 0 4px; - } - } - } - } -} -</style> diff --git a/packages/client/src/pages/follow-requests.vue b/packages/client/src/pages/follow-requests.vue index 6adc1a404b..1f4dc9e938 100644 --- a/packages/client/src/pages/follow-requests.vue +++ b/packages/client/src/pages/follow-requests.vue @@ -1,34 +1,37 @@ <template> -<div> - <MkPagination ref="paginationComponent" :pagination="pagination"> - <template #empty> - <div class="_fullinfo"> - <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> - <div>{{ $ts.noFollowRequests }}</div> - </div> - </template> - <template v-slot="{items}"> - <div class="mk-follow-requests"> - <div v-for="req in items" :key="req.id" class="user _panel"> - <MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/> - <div class="body"> - <div class="name"> - <MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA> - <p class="acct">@{{ acct(req.follower) }}</p> - </div> - <div v-if="req.follower.description" class="description" :title="req.follower.description"> - <Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/> - </div> - <div class="actions"> - <button class="_button" @click="accept(req.follower)"><i class="fas fa-check"></i></button> - <button class="_button" @click="reject(req.follower)"><i class="fas fa-times"></i></button> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <MkSpacer :content-max="800"> + <MkPagination ref="paginationComponent" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noFollowRequests }}</div> + </div> + </template> + <template #default="{items}"> + <div class="mk-follow-requests"> + <div v-for="req in items" :key="req.id" class="user _panel"> + <MkAvatar class="avatar" :user="req.follower" :show-indicator="true"/> + <div class="body"> + <div class="name"> + <MkA v-user-preview="req.follower.id" class="name" :to="userPage(req.follower)"><MkUserName :user="req.follower"/></MkA> + <p class="acct">@{{ acct(req.follower) }}</p> + </div> + <div v-if="req.follower.description" class="description" :title="req.follower.description"> + <Mfm :text="req.follower.description" :is-note="false" :author="req.follower" :i="$i" :custom-emojis="req.follower.emojis" :plain="true" :nowrap="true"/> + </div> + <div class="actions"> + <button class="_button" @click="accept(req.follower)"><i class="fas fa-check"></i></button> + <button class="_button" @click="reject(req.follower)"><i class="fas fa-times"></i></button> + </div> </div> </div> </div> - </div> - </template> - </MkPagination> -</div> + </template> + </MkPagination> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> @@ -36,8 +39,8 @@ import { ref, computed } from 'vue'; import MkPagination from '@/components/ui/pagination.vue'; import { userPage, acct } from '@/filters/user'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const paginationComponent = ref<InstanceType<typeof MkPagination>>(); @@ -58,13 +61,14 @@ function reject(user) { }); } -defineExpose({ - [symbols.PAGE_INFO]: computed(() => ({ - title: i18n.ts.followRequests, - icon: 'fas fa-user-clock', - bg: 'var(--bg)', - })), -}); +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.followRequests, + icon: 'fas fa-user-clock', +}))); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/follow.vue b/packages/client/src/pages/follow.vue index e69e0481e0..0c1cb7733b 100644 --- a/packages/client/src/pages/follow.vue +++ b/packages/client/src/pages/follow.vue @@ -5,8 +5,9 @@ <script lang="ts"> import { defineComponent } from 'vue'; -import * as os from '@/os'; import * as Acct from 'misskey-js/built/acct'; +import * as os from '@/os'; +import { mainRouter } from '@/router'; export default defineComponent({ created() { @@ -17,17 +18,17 @@ export default defineComponent({ if (acct.startsWith('https://')) { promise = os.api('ap/show', { - uri: acct + uri: acct, }); promise.then(res => { if (res.type === 'User') { this.follow(res.object); } else if (res.type === 'Note') { - this.$router.push(`/notes/${res.object.id}`); + mainRouter.push(`/notes/${res.object.id}`); } else { os.alert({ type: 'error', - text: 'Not a user' + text: 'Not a user', }).then(() => { window.close(); }); @@ -56,9 +57,9 @@ export default defineComponent({ } os.apiWithDialog('following/create', { - userId: user.id + userId: user.id, }); - } - } + }, + }, }); </script> diff --git a/packages/client/src/pages/gallery/edit.vue b/packages/client/src/pages/gallery/edit.vue index bc87160c44..f8a5d54f71 100644 --- a/packages/client/src/pages/gallery/edit.vue +++ b/packages/client/src/pages/gallery/edit.vue @@ -1,140 +1,125 @@ <template> -<div> - <FormSuspense :p="init"> - <FormInput v-model="title"> - <template #label>{{ $ts.title }}</template> - </FormInput> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <FormInput v-model="title"> + <template #label>{{ $ts.title }}</template> + </FormInput> - <FormTextarea v-model="description" :max="500"> - <template #label>{{ $ts.description }}</template> - </FormTextarea> + <FormTextarea v-model="description" :max="500"> + <template #label>{{ $ts.description }}</template> + </FormTextarea> - <FormGroup> - <div v-for="file in files" :key="file.id" class="_formGroup wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }"> - <div class="name">{{ file.name }}</div> - <button v-tooltip="$ts.remove" class="remove _button" @click="remove(file)"><i class="fas fa-times"></i></button> + <div class=""> + <div v-for="file in files" :key="file.id" class="wqugxsfx" :style="{ backgroundImage: file ? `url(${ file.thumbnailUrl })` : null }"> + <div class="name">{{ file.name }}</div> + <button v-tooltip="$ts.remove" class="remove _button" @click="remove(file)"><i class="fas fa-times"></i></button> + </div> + <FormButton primary @click="selectFile"><i class="fas fa-plus"></i> {{ $ts.attachFile }}</FormButton> </div> - <FormButton primary @click="selectFile"><i class="fas fa-plus"></i> {{ $ts.attachFile }}</FormButton> - </FormGroup> - <FormSwitch v-model="isSensitive">{{ $ts.markAsSensitive }}</FormSwitch> + <FormSwitch v-model="isSensitive">{{ $ts.markAsSensitive }}</FormSwitch> - <FormButton v-if="postId" primary @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> - <FormButton v-else primary @click="save"><i class="fas fa-save"></i> {{ $ts.publish }}</FormButton> + <FormButton v-if="postId" primary @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton> + <FormButton v-else primary @click="save"><i class="fas fa-save"></i> {{ $ts.publish }}</FormButton> - <FormButton v-if="postId" danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</FormButton> - </FormSuspense> -</div> + <FormButton v-if="postId" danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</FormButton> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed, inject, watch } from 'vue'; import FormButton from '@/components/ui/button.vue'; import FormInput from '@/components/form/input.vue'; import FormTextarea from '@/components/form/textarea.vue'; import FormSwitch from '@/components/form/switch.vue'; -import FormGroup from '@/components/form/group.vue'; import FormSuspense from '@/components/form/suspense.vue'; import { selectFiles } from '@/scripts/select-file'; import * as os from '@/os'; -import * as symbols from '@/symbols'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - FormButton, - FormInput, - FormTextarea, - FormSwitch, - FormGroup, - FormSuspense, - }, +const router = useRouter(); - props: { - postId: { - type: String, - required: false, - default: null, - } - }, - - data() { - return { - [symbols.PAGE_INFO]: computed(() => this.postId ? { - title: this.$ts.edit, - icon: 'fas fa-pencil-alt' - } : { - title: this.$ts.postToGallery, - icon: 'fas fa-pencil-alt' - }), - init: null, - files: [], - description: null, - title: null, - isSensitive: false, - }; - }, +const props = defineProps<{ + postId?: string; +}>(); - watch: { - postId: { - handler() { - this.init = () => this.postId ? os.api('gallery/posts/show', { - postId: this.postId - }).then(post => { - this.files = post.files; - this.title = post.title; - this.description = post.description; - this.isSensitive = post.isSensitive; - }) : Promise.resolve(null); - }, - immediate: true, - } - }, +let init = $ref(null); +let files = $ref([]); +let description = $ref(null); +let title = $ref(null); +let isSensitive = $ref(false); - methods: { - selectFile(evt) { - selectFiles(evt.currentTarget ?? evt.target, null).then(files => { - this.files = this.files.concat(files); - }); - }, - - remove(file) { - this.files = this.files.filter(f => f.id !== file.id); - }, +function selectFile(evt) { + selectFiles(evt.currentTarget ?? evt.target, null).then(selected => { + files = files.concat(selected); + }); +} - async save() { - if (this.postId) { - await os.apiWithDialog('gallery/posts/update', { - postId: this.postId, - title: this.title, - description: this.description, - fileIds: this.files.map(file => file.id), - isSensitive: this.isSensitive, - }); - this.$router.push(`/gallery/${this.postId}`); - } else { - const post = await os.apiWithDialog('gallery/posts/create', { - title: this.title, - description: this.description, - fileIds: this.files.map(file => file.id), - isSensitive: this.isSensitive, - }); - this.$router.push(`/gallery/${post.id}`); - } - }, +function remove(file) { + files = files.filter(f => f.id !== file.id); +} - async del() { - const { canceled } = await os.confirm({ - type: 'warning', - text: this.$ts.deleteConfirm, - }); - if (canceled) return; - await os.apiWithDialog('gallery/posts/delete', { - postId: this.postId, - }); - this.$router.push(`/gallery`); - } +async function save() { + if (props.postId) { + await os.apiWithDialog('gallery/posts/update', { + postId: props.postId, + title: title, + description: description, + fileIds: files.map(file => file.id), + isSensitive: isSensitive, + }); + router.push(`/gallery/${props.postId}`); + } else { + const created = await os.apiWithDialog('gallery/posts/create', { + title: title, + description: description, + fileIds: files.map(file => file.id), + isSensitive: isSensitive, + }); + router.push(`/gallery/${created.id}`); } -}); +} + +async function del() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteConfirm, + }); + if (canceled) return; + await os.apiWithDialog('gallery/posts/delete', { + postId: props.postId, + }); + router.push('/gallery'); +} + +watch(() => props.postId, () => { + init = () => props.postId ? os.api('gallery/posts/show', { + postId: props.postId, + }).then(post => { + files = post.files; + title = post.title; + description = post.description; + isSensitive = post.isSensitive; + }) : Promise.resolve(null); +}, { immediate: true }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => props.postId ? { + title: i18n.ts.edit, + icon: 'fas fa-pencil-alt', +} : { + title: i18n.ts.postToGallery, + icon: 'fas fa-pencil-alt', +})); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/gallery/index.vue b/packages/client/src/pages/gallery/index.vue index a19d69d5c2..6b406af742 100644 --- a/packages/client/src/pages/gallery/index.vue +++ b/packages/client/src/pages/gallery/index.vue @@ -1,49 +1,48 @@ <template> -<div class="xprsixdl _root"> - <MkTab v-if="$i" v-model="tab"> - <option value="explore"><i class="fas fa-icons"></i> {{ $ts.gallery }}</option> - <option value="liked"><i class="fas fa-heart"></i> {{ $ts._gallery.liked }}</option> - <option value="my"><i class="fas fa-edit"></i> {{ $ts._gallery.my }}</option> - </MkTab> - - <div v-if="tab === 'explore'"> - <MkFolder class="_gap"> - <template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template> - <MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true"> - <div class="vfpdbgtk"> - <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> - </div> - </MkPagination> - </MkFolder> - <MkFolder class="_gap"> - <template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template> - <MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true"> - <div class="vfpdbgtk"> - <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> - </div> - </MkPagination> - </MkFolder> - </div> - <div v-else-if="tab === 'liked'"> - <MkPagination v-slot="{items}" :pagination="likedPostsPagination"> - <div class="vfpdbgtk"> - <MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1400"> + <div class="_root"> + <div v-if="tab === 'explore'"> + <MkFolder class="_gap"> + <template #header><i class="fas fa-clock"></i>{{ $ts.recentPosts }}</template> + <MkPagination v-slot="{items}" :pagination="recentPostsPagination" :disable-auto-load="true"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> + </MkFolder> + <MkFolder class="_gap"> + <template #header><i class="fas fa-fire-alt"></i>{{ $ts.popularPosts }}</template> + <MkPagination v-slot="{items}" :pagination="popularPostsPagination" :disable-auto-load="true"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> + </MkFolder> + </div> + <div v-else-if="tab === 'liked'"> + <MkPagination v-slot="{items}" :pagination="likedPostsPagination"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="like in items" :key="like.id" :post="like.post" class="post"/> + </div> + </MkPagination> </div> - </MkPagination> - </div> - <div v-else-if="tab === 'my'"> - <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA> - <MkPagination v-slot="{items}" :pagination="myPostsPagination"> - <div class="vfpdbgtk"> - <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + <div v-else-if="tab === 'my'"> + <MkA to="/gallery/new" class="_link" style="margin: 16px;"><i class="fas fa-plus"></i> {{ $ts.postToGallery }}</MkA> + <MkPagination v-slot="{items}" :pagination="myPostsPagination"> + <div class="vfpdbgtk"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> </div> - </MkPagination> - </div> -</div> + </div> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed, defineComponent, watch } from 'vue'; import XUserList from '@/components/user-list.vue'; import MkFolder from '@/components/ui/folder.vue'; import MkInput from '@/components/form/input.vue'; @@ -53,92 +52,80 @@ import MkPagination from '@/components/ui/pagination.vue'; import MkGalleryPostPreview from '@/components/gallery-post-preview.vue'; import number from '@/filters/number'; import * as os from '@/os'; -import * as symbols from '@/symbols'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { useRouter } from '@/router'; -export default defineComponent({ - components: { - XUserList, - MkFolder, - MkInput, - MkButton, - MkTab, - MkPagination, - MkGalleryPostPreview, - }, +const router = useRouter(); - props: { - tag: { - type: String, - required: false - } - }, +const props = defineProps<{ + tag?: string; +}>(); - data() { - return { - [symbols.PAGE_INFO]: { - title: this.$ts.gallery, - icon: 'fas fa-icons' - }, - tab: 'explore', - recentPostsPagination: { - endpoint: 'gallery/posts' as const, - limit: 6, - }, - popularPostsPagination: { - endpoint: 'gallery/featured' as const, - limit: 5, - }, - myPostsPagination: { - endpoint: 'i/gallery/posts' as const, - limit: 5, - }, - likedPostsPagination: { - endpoint: 'i/gallery/likes' as const, - limit: 5, - }, - tags: [], - }; - }, +let tab = $ref('explore'); +let tags = $ref([]); +let tagsRef = $ref(); - computed: { - meta() { - return this.$instance; - }, - tagUsers(): any { - return { - endpoint: 'hashtags/users' as const, - limit: 30, - params: { - tag: this.tag, - origin: 'combined', - sort: '+follower', - } - }; - }, - }, +const recentPostsPagination = { + endpoint: 'gallery/posts' as const, + limit: 6, +}; +const popularPostsPagination = { + endpoint: 'gallery/featured' as const, + limit: 5, +}; +const myPostsPagination = { + endpoint: 'i/gallery/posts' as const, + limit: 5, +}; +const likedPostsPagination = { + endpoint: 'i/gallery/likes' as const, + limit: 5, +}; - watch: { - tag() { - if (this.$refs.tags) this.$refs.tags.toggleContent(this.tag == null); - }, +const tagUsersPagination = $computed(() => ({ + endpoint: 'hashtags/users' as const, + limit: 30, + params: { + tag: this.tag, + origin: 'combined', + sort: '+follower', }, +})); - created() { +watch(() => props.tag, () => { + if (tagsRef) tagsRef.tags.toggleContent(props.tag == null); +}); +const headerActions = $computed(() => [{ + icon: 'fas fa-plus', + text: i18n.ts.create, + handler: () => { + router.push('/gallery/new'); }, +}]); - methods: { +const headerTabs = $computed(() => [{ + key: 'explore', + title: i18n.ts.gallery, + icon: 'fas fa-icons', +}, { + key: 'liked', + title: i18n.ts._gallery.liked, + icon: 'fas fa-heart', +}, { + key: 'my', + title: i18n.ts._gallery.my, + icon: 'fas fa-edit', +}]); - } +definePageMetadata({ + title: i18n.ts.gallery, + icon: 'fas fa-icons', }); </script> <style lang="scss" scoped> -.xprsixdl { - max-width: 1400px; - margin: 0 auto; -} - .vfpdbgtk { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); diff --git a/packages/client/src/pages/gallery/post.vue b/packages/client/src/pages/gallery/post.vue index 1ca3443e56..e87a541e98 100644 --- a/packages/client/src/pages/gallery/post.vue +++ b/packages/client/src/pages/gallery/post.vue @@ -1,171 +1,155 @@ <template> -<div class="_root"> - <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> - <div v-if="post" class="rkxwuolj"> - <div class="files"> - <div v-for="file in post.files" :key="file.id" class="file"> - <img :src="file.url"/> - </div> - </div> - <div class="body _block"> - <div class="title">{{ post.title }}</div> - <div class="description"><Mfm :text="post.description"/></div> - <div class="info"> - <i class="fas fa-clock"></i> <MkTime :time="post.createdAt" mode="detail"/> - </div> - <div class="actions"> - <div class="like"> - <MkButton v-if="post.isLiked" v-tooltip="$ts._gallery.unlike" class="button" primary @click="unlike()"><i class="fas fa-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton> - <MkButton v-else v-tooltip="$ts._gallery.like" class="button" @click="like()"><i class="far fa-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1000" :margin-min="16" :margin-max="32"> + <div class="_root"> + <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <div v-if="post" class="rkxwuolj"> + <div class="files"> + <div v-for="file in post.files" :key="file.id" class="file"> + <img :src="file.url"/> + </div> </div> - <div class="other"> - <button v-if="$i && $i.id === post.user.id" v-tooltip="$ts.edit" v-click-anime class="_button" @click="edit"><i class="fas fa-pencil-alt fa-fw"></i></button> - <button v-tooltip="$ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="fas fa-retweet fa-fw"></i></button> - <button v-tooltip="$ts.share" v-click-anime class="_button" @click="share"><i class="fas fa-share-alt fa-fw"></i></button> + <div class="body _block"> + <div class="title">{{ post.title }}</div> + <div class="description"><Mfm :text="post.description"/></div> + <div class="info"> + <i class="fas fa-clock"></i> <MkTime :time="post.createdAt" mode="detail"/> + </div> + <div class="actions"> + <div class="like"> + <MkButton v-if="post.isLiked" v-tooltip="$ts._gallery.unlike" class="button" primary @click="unlike()"><i class="fas fa-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton> + <MkButton v-else v-tooltip="$ts._gallery.like" class="button" @click="like()"><i class="far fa-heart"></i><span v-if="post.likedCount > 0" class="count">{{ post.likedCount }}</span></MkButton> + </div> + <div class="other"> + <button v-if="$i && $i.id === post.user.id" v-tooltip="$ts.edit" v-click-anime class="_button" @click="edit"><i class="fas fa-pencil-alt fa-fw"></i></button> + <button v-tooltip="$ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="fas fa-retweet fa-fw"></i></button> + <button v-tooltip="$ts.share" v-click-anime class="_button" @click="share"><i class="fas fa-share-alt fa-fw"></i></button> + </div> + </div> + <div class="user"> + <MkAvatar :user="post.user" class="avatar"/> + <div class="name"> + <MkUserName :user="post.user" style="display: block;"/> + <MkAcct :user="post.user"/> + </div> + <MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + </div> </div> + <MkAd :prefer="['horizontal', 'horizontal-big']"/> + <MkContainer :max-height="300" :foldable="true" class="other"> + <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template> + <MkPagination v-slot="{items}" :pagination="otherPostsPagination"> + <div class="sdrarzaf"> + <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> + </div> + </MkPagination> + </MkContainer> </div> - <div class="user"> - <MkAvatar :user="post.user" class="avatar"/> - <div class="name"> - <MkUserName :user="post.user" style="display: block;"/> - <MkAcct :user="post.user"/> - </div> - <MkFollowButton v-if="!$i || $i.id != post.user.id" :user="post.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> - </div> - </div> - <MkAd :prefer="['horizontal', 'horizontal-big']"/> - <MkContainer :max-height="300" :foldable="true" class="other"> - <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template> - <MkPagination v-slot="{items}" :pagination="otherPostsPagination"> - <div class="sdrarzaf"> - <MkGalleryPostPreview v-for="post in items" :key="post.id" :post="post" class="post"/> - </div> - </MkPagination> - </MkContainer> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> </div> - <MkError v-else-if="error" @retry="fetch()"/> - <MkLoading v-else/> - </transition> -</div> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed, defineComponent, inject, watch } from 'vue'; import MkButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import MkContainer from '@/components/ui/container.vue'; import ImgWithBlurhash from '@/components/img-with-blurhash.vue'; import MkPagination from '@/components/ui/pagination.vue'; import MkGalleryPostPreview from '@/components/gallery-post-preview.vue'; import MkFollowButton from '@/components/follow-button.vue'; import { url } from '@/config'; +import { useRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; -export default defineComponent({ - components: { - MkContainer, - ImgWithBlurhash, - MkPagination, - MkGalleryPostPreview, - MkButton, - MkFollowButton, - }, - props: { - postId: { - type: String, - required: true - } - }, - data() { - return { - [symbols.PAGE_INFO]: computed(() => this.post ? { - title: this.post.title, - avatar: this.post.user, - path: `/gallery/${this.post.id}`, - share: { - title: this.post.title, - text: this.post.description, - }, - actions: [{ - icon: 'fas fa-pencil-alt', - text: this.$ts.edit, - handler: this.edit - }] - } : null), - otherPostsPagination: { - endpoint: 'users/gallery/posts' as const, - limit: 6, - params: computed(() => ({ - userId: this.post.user.id - })), - }, - post: null, - error: null, - }; - }, +const router = useRouter(); + +const props = defineProps<{ + postId: string; +}>(); + +let post = $ref(null); +let error = $ref(null); +const otherPostsPagination = { + endpoint: 'users/gallery/posts' as const, + limit: 6, + params: computed(() => ({ + userId: post.user.id, + })), +}; + +function fetchPost() { + post = null; + os.api('gallery/posts/show', { + postId: props.postId, + }).then(_post => { + post = _post; + }).catch(_error => { + error = _error; + }); +} - watch: { - postId: 'fetch' - }, +function share() { + navigator.share({ + title: post.title, + text: post.description, + url: `${url}/gallery/${post.id}`, + }); +} - created() { - this.fetch(); - }, +function shareWithNote() { + os.post({ + initialText: `${post.title} ${url}/gallery/${post.id}`, + }); +} - methods: { - fetch() { - this.post = null; - os.api('gallery/posts/show', { - postId: this.postId - }).then(post => { - this.post = post; - }).catch(err => { - this.error = err; - }); - }, +function like() { + os.apiWithDialog('gallery/posts/like', { + postId: props.postId, + }).then(() => { + post.isLiked = true; + post.likedCount++; + }); +} - share() { - navigator.share({ - title: this.post.title, - text: this.post.description, - url: `${url}/gallery/${this.post.id}` - }); - }, +async function unlike() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.unlikeConfirm, + }); + if (confirm.canceled) return; + os.apiWithDialog('gallery/posts/unlike', { + postId: props.postId, + }).then(() => { + post.isLiked = false; + post.likedCount--; + }); +} - shareWithNote() { - os.post({ - initialText: `${this.post.title} ${url}/gallery/${this.post.id}` - }); - }, +function edit() { + router.push(`/gallery/${post.id}/edit`); +} - like() { - os.apiWithDialog('gallery/posts/like', { - postId: this.postId, - }).then(() => { - this.post.isLiked = true; - this.post.likedCount++; - }); - }, +watch(() => props.postId, fetchPost, { immediate: true }); - async unlike() { - const confirm = await os.confirm({ - type: 'warning', - text: this.$ts.unlikeConfirm, - }); - if (confirm.canceled) return; - os.apiWithDialog('gallery/posts/unlike', { - postId: this.postId, - }).then(() => { - this.post.isLiked = false; - this.post.likedCount--; - }); - }, +const headerActions = $computed(() => [{ + icon: 'fas fa-pencil-alt', + text: i18n.ts.edit, + handler: edit, +}]); - edit() { - this.$router.push(`/gallery/${this.post.id}/edit`); - } - } -}); +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => post ? { + title: post.title, + avatar: post.user, +} : null)); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/instance-info.vue b/packages/client/src/pages/instance-info.vue index f19cb9d1a2..d4d338b125 100644 --- a/packages/client/src/pages/instance-info.vue +++ b/packages/client/src/pages/instance-info.vue @@ -1,69 +1,80 @@ <template> -<MkSpacer :content-max="600" :margin-min="16" :margin-max="32"> - <div v-if="instance" class="_formRoot"> - <div class="fnfelxur"> - <img :src="instance.iconUrl || instance.faviconUrl" alt="" class="icon"/> - </div> - <MkKeyValue :copy="host" oneline style="margin: 1em 0;"> - <template #key>Host</template> - <template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template> - </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>Name</template> - <template #value>{{ instance.name || `(${$ts.unknown})` }}</template> - </MkKeyValue> - <MkKeyValue> - <template #key>{{ $ts.description }}</template> - <template #value>{{ instance.description }}</template> - </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.software }}</template> - <template #value><span class="_monospace">{{ instance.softwareName || `(${$ts.unknown})` }} / {{ instance.softwareVersion || `(${$ts.unknown})` }}</span></template> - </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.administrator }}</template> - <template #value>{{ instance.maintainerName || `(${$ts.unknown})` }} ({{ instance.maintainerEmail || `(${$ts.unknown})` }})</template> - </MkKeyValue> - - <FormSection v-if="iAmModerator"> - <template #label>Moderation</template> - <FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.stopActivityDelivery }}</FormSwitch> - <FormSwitch v-model="isBlocked" class="_formBlock" @update:modelValue="toggleBlock">{{ $ts.blockThisInstance }}</FormSwitch> - <MkButton @click="refreshMetadata">Refresh metadata</MkButton> - </FormSection> - - <FormSection> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.registeredAt }}</template> - <template #value><MkTime mode="detail" :time="instance.caughtAt"/></template> - </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.updatedAt }}</template> - <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer v-if="instance" :content-max="600" :margin-min="16" :margin-max="32"> + <div v-if="tab === 'overview'" class="_formRoot"> + <div class="fnfelxur"> + <img :src="instance.iconUrl || instance.faviconUrl" alt="" class="icon"/> + <span class="name">{{ instance.name || `(${$ts.unknown})` }}</span> + </div> + <MkKeyValue :copy="host" oneline style="margin: 1em 0;"> + <template #key>Host</template> + <template #value><span class="_monospace"><MkLink :url="`https://${host}`">{{ host }}</MkLink></span></template> </MkKeyValue> <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.latestRequestSentAt }}</template> - <template #value><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></template> + <template #key>{{ $ts.software }}</template> + <template #value><span class="_monospace">{{ instance.softwareName || `(${$ts.unknown})` }} / {{ instance.softwareVersion || `(${$ts.unknown})` }}</span></template> </MkKeyValue> <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.latestStatus }}</template> - <template #value>{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</template> + <template #key>{{ $ts.administrator }}</template> + <template #value>{{ instance.maintainerName || `(${$ts.unknown})` }} ({{ instance.maintainerEmail || `(${$ts.unknown})` }})</template> </MkKeyValue> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.latestRequestReceivedAt }}</template> - <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template> + <MkKeyValue> + <template #key>{{ $ts.description }}</template> + <template #value>{{ instance.description }}</template> </MkKeyValue> - </FormSection> + + <FormSection v-if="iAmModerator"> + <template #label>Moderation</template> + <FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.stopActivityDelivery }}</FormSwitch> + <FormSwitch v-model="isBlocked" class="_formBlock" @update:modelValue="toggleBlock">{{ $ts.blockThisInstance }}</FormSwitch> + <MkButton @click="refreshMetadata"><i class="fas fa-refresh"></i> Refresh metadata</MkButton> + </FormSection> + + <FormSection> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ $ts.registeredAt }}</template> + <template #value><MkTime mode="detail" :time="instance.caughtAt"/></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ $ts.updatedAt }}</template> + <template #value><MkTime mode="detail" :time="instance.infoUpdatedAt"/></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ $ts.latestRequestSentAt }}</template> + <template #value><MkTime v-if="instance.latestRequestSentAt" :time="instance.latestRequestSentAt"/><span v-else>N/A</span></template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ $ts.latestStatus }}</template> + <template #value>{{ instance.latestStatus ? instance.latestStatus : 'N/A' }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ $ts.latestRequestReceivedAt }}</template> + <template #value><MkTime v-if="instance.latestRequestReceivedAt" :time="instance.latestRequestReceivedAt"/><span v-else>N/A</span></template> + </MkKeyValue> + </FormSection> - <FormSection> - <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>Open Registrations</template> - <template #value>{{ instance.openRegistrations ? $ts.yes : $ts.no }}</template> - </MkKeyValue> - </FormSection> + <FormSection> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>Following (Pub)</template> + <template #value>{{ number(instance.followingCount) }}</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>Followers (Sub)</template> + <template #value>{{ number(instance.followersCount) }}</template> + </MkKeyValue> + </FormSection> - <FormSection> - <template #label>{{ $ts.statistics }}</template> + <FormSection> + <template #label>Well-known resources</template> + <FormLink :to="`https://${host}/.well-known/host-meta`" external style="margin-bottom: 8px;">host-meta</FormLink> + <FormLink :to="`https://${host}/.well-known/host-meta.json`" external style="margin-bottom: 8px;">host-meta.json</FormLink> + <FormLink :to="`https://${host}/.well-known/nodeinfo`" external style="margin-bottom: 8px;">nodeinfo</FormLink> + <FormLink :to="`https://${host}/robots.txt`" external style="margin-bottom: 8px;">robots.txt</FormLink> + <FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink> + </FormSection> + </div> + <div v-else-if="tab === 'chart'" class="_formRoot"> <div class="cmhjzshl"> <div class="selects"> <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> @@ -79,30 +90,28 @@ <option value="instance-drive-files">{{ $ts._instanceCharts.files }}</option> <option value="instance-drive-files-total">{{ $ts._instanceCharts.filesTotal }}</option> </MkSelect> - <MkSelect v-model="chartSpan" style="margin: 0;"> - <option value="hour">{{ $ts.perHour }}</option> - <option value="day">{{ $ts.perDay }}</option> - </MkSelect> </div> - <div class="chart"> - <MkChart :src="chartSrc" :span="chartSpan" :limit="90" :args="{ host: host }" :detailed="true"></MkChart> + <div class="charts"> + <div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ host: host }" :detailed="true"></MkChart> + <div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ host: host }" :detailed="true"></MkChart> </div> </div> - </FormSection> - - <MkObjectView tall :value="instance"> - </MkObjectView> - - <FormSection> - <template #label>Well-known resources</template> - <FormLink :to="`https://${host}/.well-known/host-meta`" external style="margin-bottom: 8px;">host-meta</FormLink> - <FormLink :to="`https://${host}/.well-known/host-meta.json`" external style="margin-bottom: 8px;">host-meta.json</FormLink> - <FormLink :to="`https://${host}/.well-known/nodeinfo`" external style="margin-bottom: 8px;">nodeinfo</FormLink> - <FormLink :to="`https://${host}/robots.txt`" external style="margin-bottom: 8px;">robots.txt</FormLink> - <FormLink :to="`https://${host}/manifest.json`" external style="margin-bottom: 8px;">manifest.json</FormLink> - </FormSection> - </div> -</MkSpacer> + </div> + <div v-else-if="tab === 'users'" class="_formRoot"> + <MkPagination v-slot="{items}" :pagination="usersPagination" style="display: grid; grid-template-columns: repeat(auto-fill,minmax(270px,1fr)); grid-gap: 12px;"> + <MkA v-for="user in items" :key="user.id" v-tooltip.mfm="`Last posted: ${new Date(user.updatedAt).toLocaleString()}`" class="user" :to="`/user-info/${user.id}`"> + <MkUserCardMini :user="user"/> + </MkA> + </MkPagination> + </div> + <div v-else-if="tab === 'raw'" class="_formRoot"> + <MkObjectView tall :value="instance"> + </MkObjectView> + </div> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> @@ -120,33 +129,46 @@ import FormSwitch from '@/components/form/switch.vue'; import * as os from '@/os'; import number from '@/filters/number'; import bytes from '@/filters/bytes'; -import * as symbols from '@/symbols'; import { iAmModerator } from '@/account'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import MkUserCardMini from '@/components/user-card-mini.vue'; +import MkPagination from '@/components/ui/pagination.vue'; const props = defineProps<{ host: string; }>(); +let tab = $ref('overview'); +let chartSrc = $ref('instance-requests'); let meta = $ref<misskey.entities.DetailedInstanceMetadata | null>(null); let instance = $ref<misskey.entities.Instance | null>(null); let suspended = $ref(false); let isBlocked = $ref(false); -let chartSrc = $ref('instance-requests'); -let chartSpan = $ref('hour'); + +const usersPagination = { + endpoint: iAmModerator ? 'admin/show-users' : 'users' as const, + limit: 10, + params: { + sort: '+updatedAt', + state: 'all', + hostname: props.host, + }, + offsetMode: true, +}; async function fetch() { - meta = await os.api('meta', { detail: true }); instance = await os.api('federation/show-instance', { host: props.host, }); suspended = instance.isSuspended; - isBlocked = meta.blockedHosts.includes(instance.host); + isBlocked = instance.isBlocked; } async function toggleBlock(ev) { if (meta == null) return; await os.api('admin/update-meta', { - blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host) + blockedHosts: isBlocked ? meta.blockedHosts.concat([instance.host]) : meta.blockedHosts.filter(x => x !== instance.host), }); } @@ -168,30 +190,53 @@ function refreshMetadata() { fetch(); -defineExpose({ - [symbols.PAGE_INFO]: { - title: props.host, - icon: 'fas fa-info-circle', - bg: 'var(--bg)', - actions: [{ - text: `https://${props.host}`, - icon: 'fas fa-external-link-alt', - handler: () => { - window.open(`https://${props.host}`, '_blank'); - } - }], +const headerActions = $computed(() => [{ + text: `https://${props.host}`, + icon: 'fas fa-external-link-alt', + handler: () => { + window.open(`https://${props.host}`, '_blank'); }, +}]); + +const headerTabs = $computed(() => [{ + key: 'overview', + title: i18n.ts.overview, + icon: 'fas fa-info-circle', +}, { + key: 'chart', + title: i18n.ts.charts, + icon: 'fas fa-chart-simple', +}, { + key: 'users', + title: i18n.ts.users, + icon: 'fas fa-users', +}, { + key: 'raw', + title: 'Raw', + icon: 'fas fa-code', +}]); + +definePageMetadata({ + title: props.host, + icon: 'fas fa-server', }); </script> <style lang="scss" scoped> .fnfelxur { + display: flex; + align-items: center; + > .icon { display: block; - margin: 0; + margin: 0 16px 0 0; height: 64px; border-radius: 8px; } + + > .name { + word-break: break-all; + } } .cmhjzshl { @@ -199,5 +244,12 @@ defineExpose({ display: flex; margin: 0 0 16px 0; } + + > .charts { + > .label { + margin-bottom: 12px; + font-weight: bold; + } + } } </style> diff --git a/packages/client/src/pages/mentions.vue b/packages/client/src/pages/mentions.vue deleted file mode 100644 index 9b57c956bf..0000000000 --- a/packages/client/src/pages/mentions.vue +++ /dev/null @@ -1,24 +0,0 @@ -<template> -<MkSpacer :content-max="800"> - <XNotes :pagination="pagination"/> -</MkSpacer> -</template> - -<script lang="ts" setup> -import XNotes from '@/components/notes.vue'; -import * as symbols from '@/symbols'; -import { i18n } from '@/i18n'; - -const pagination = { - endpoint: 'notes/mentions' as const, - limit: 10, -}; - -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.mentions, - icon: 'fas fa-at', - bg: 'var(--bg)', - }, -}); -</script> diff --git a/packages/client/src/pages/messages.vue b/packages/client/src/pages/messages.vue deleted file mode 100644 index 9c5fb9b341..0000000000 --- a/packages/client/src/pages/messages.vue +++ /dev/null @@ -1,27 +0,0 @@ -<template> -<MkSpacer :content-max="800"> - <XNotes :pagination="pagination"/> -</MkSpacer> -</template> - -<script lang="ts" setup> -import XNotes from '@/components/notes.vue'; -import * as symbols from '@/symbols'; -import { i18n } from '@/i18n'; - -const pagination = { - endpoint: 'notes/mentions' as const, - limit: 10, - params: { - visibility: 'specified' - }, -}; - -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.directNotes, - icon: 'fas fa-envelope', - bg: 'var(--bg)', - }, -}); -</script> diff --git a/packages/client/src/pages/messaging/index.vue b/packages/client/src/pages/messaging/index.vue index 7c1d3e3cbe..7df4c846fb 100644 --- a/packages/client/src/pages/messaging/index.vue +++ b/packages/client/src/pages/messaging/index.vue @@ -1,165 +1,164 @@ <template> -<MkSpacer :content-max="800"> - <div v-size="{ max: [400] }" class="yweeujhr"> - <MkButton primary class="start" @click="start"><i class="fas fa-plus"></i> {{ $ts.startMessaging }}</MkButton> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <div v-size="{ max: [400] }" class="yweeujhr"> + <MkButton primary class="start" @click="start"><i class="fas fa-plus"></i> {{ $ts.startMessaging }}</MkButton> - <div v-if="messages.length > 0" class="history"> - <MkA v-for="(message, i) in messages" - :key="message.id" - v-anim="i" - class="message _block" - :class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }" - :to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" - :data-index="i" - > - <div> - <MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/> - <header v-if="message.groupId"> - <span class="name">{{ message.group.name }}</span> - <MkTime :time="message.createdAt" class="time"/> - </header> - <header v-else> - <span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span> - <span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span> - <MkTime :time="message.createdAt" class="time"/> - </header> - <div class="body"> - <p class="text"><span v-if="isMe(message)" class="me">{{ $ts.you }}:</span>{{ message.text }}</p> + <div v-if="messages.length > 0" class="history"> + <MkA + v-for="(message, i) in messages" + :key="message.id" + v-anim="i" + class="message _block" + :class="{ isMe: isMe(message), isRead: message.groupId ? message.reads.includes($i.id) : message.isRead }" + :to="message.groupId ? `/my/messaging/group/${message.groupId}` : `/my/messaging/${getAcct(isMe(message) ? message.recipient : message.user)}`" + :data-index="i" + > + <div> + <MkAvatar class="avatar" :user="message.groupId ? message.user : isMe(message) ? message.recipient : message.user" :show-indicator="true"/> + <header v-if="message.groupId"> + <span class="name">{{ message.group.name }}</span> + <MkTime :time="message.createdAt" class="time"/> + </header> + <header v-else> + <span class="name"><MkUserName :user="isMe(message) ? message.recipient : message.user"/></span> + <span class="username">@{{ acct(isMe(message) ? message.recipient : message.user) }}</span> + <MkTime :time="message.createdAt" class="time"/> + </header> + <div class="body"> + <p class="text"><span v-if="isMe(message)" class="me">{{ $ts.you }}:</span>{{ message.text }}</p> + </div> </div> - </div> - </MkA> + </MkA> + </div> + <div v-if="!fetching && messages.length == 0" class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ $ts.noHistory }}</div> + </div> + <MkLoading v-if="fetching"/> </div> - <div v-if="!fetching && messages.length == 0" class="_fullinfo"> - <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> - <div>{{ $ts.noHistory }}</div> - </div> - <MkLoading v-if="fetching"/> - </div> -</MkSpacer> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -import { defineAsyncComponent, defineComponent, markRaw } from 'vue'; +<script lang="ts" setup> +import { defineAsyncComponent, defineComponent, inject, markRaw, onMounted, onUnmounted } from 'vue'; import * as Acct from 'misskey-js/built/acct'; import MkButton from '@/components/ui/button.vue'; import { acct } from '@/filters/user'; import * as os from '@/os'; import { stream } from '@/stream'; -import * as symbols from '@/symbols'; +import { useRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; -export default defineComponent({ - components: { - MkButton - }, +const router = useRouter(); - data() { - return { - [symbols.PAGE_INFO]: { - title: this.$ts.messaging, - icon: 'fas fa-comments', - bg: 'var(--bg)', - }, - fetching: true, - moreFetching: false, - messages: [], - connection: null, - }; - }, +let fetching = $ref(true); +let moreFetching = $ref(false); +let messages = $ref([]); +let connection = $ref(null); - mounted() { - this.connection = markRaw(stream.useChannel('messagingIndex')); +const getAcct = Acct.toString; - this.connection.on('message', this.onMessage); - this.connection.on('read', this.onRead); +function isMe(message) { + return message.userId === $i.id; +} - os.api('messaging/history', { group: false }).then(userMessages => { - os.api('messaging/history', { group: true }).then(groupMessages => { - const messages = userMessages.concat(groupMessages); - messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - this.messages = messages; - this.fetching = false; - }); - }); - }, +function onMessage(message) { + if (message.recipientId) { + messages = messages.filter(m => !( + (m.recipientId === message.recipientId && m.userId === message.userId) || + (m.recipientId === message.userId && m.userId === message.recipientId))); - beforeUnmount() { - this.connection.dispose(); - }, + messages.unshift(message); + } else if (message.groupId) { + messages = messages.filter(m => m.groupId !== message.groupId); + messages.unshift(message); + } +} - methods: { - getAcct: Acct.toString, +function onRead(ids) { + for (const id of ids) { + const found = messages.find(m => m.id === id); + if (found) { + if (found.recipientId) { + found.isRead = true; + } else if (found.groupId) { + found.reads.push($i.id); + } + } + } +} - isMe(message) { - return message.userId === this.$i.id; - }, +function start(ev) { + os.popupMenu([{ + text: i18n.ts.messagingWithUser, + icon: 'fas fa-user', + action: () => { startUser(); }, + }, { + text: i18n.ts.messagingWithGroup, + icon: 'fas fa-users', + action: () => { startGroup(); }, + }], ev.currentTarget ?? ev.target); +} - onMessage(message) { - if (message.recipientId) { - this.messages = this.messages.filter(m => !( - (m.recipientId === message.recipientId && m.userId === message.userId) || - (m.recipientId === message.userId && m.userId === message.recipientId))); +async function startUser() { + os.selectUser().then(user => { + router.push(`/my/messaging/${Acct.toString(user)}`); + }); +} - this.messages.unshift(message); - } else if (message.groupId) { - this.messages = this.messages.filter(m => m.groupId !== message.groupId); - this.messages.unshift(message); - } - }, +async function startGroup() { + const groups1 = await os.api('users/groups/owned'); + const groups2 = await os.api('users/groups/joined'); + if (groups1.length === 0 && groups2.length === 0) { + os.alert({ + type: 'warning', + title: i18n.ts.youHaveNoGroups, + text: i18n.ts.joinOrCreateGroup, + }); + return; + } + const { canceled, result: group } = await os.select({ + title: i18n.ts.group, + items: groups1.concat(groups2).map(group => ({ + value: group, text: group.name, + })), + }); + if (canceled) return; + router.push(`/my/messaging/group/${group.id}`); +} - onRead(ids) { - for (const id of ids) { - const found = this.messages.find(m => m.id === id); - if (found) { - if (found.recipientId) { - found.isRead = true; - } else if (found.groupId) { - found.reads.push(this.$i.id); - } - } - } - }, +onMounted(() => { + connection = markRaw(stream.useChannel('messagingIndex')); + + connection.on('message', onMessage); + connection.on('read', onRead); - start(ev) { - os.popupMenu([{ - text: this.$ts.messagingWithUser, - icon: 'fas fa-user', - action: () => { this.startUser(); } - }, { - text: this.$ts.messagingWithGroup, - icon: 'fas fa-users', - action: () => { this.startGroup(); } - }], ev.currentTarget ?? ev.target); - }, + os.api('messaging/history', { group: false }).then(userMessages => { + os.api('messaging/history', { group: true }).then(groupMessages => { + const _messages = userMessages.concat(groupMessages); + _messages.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + messages = _messages; + fetching = false; + }); + }); +}); + +onUnmounted(() => { + if (connection) connection.dispose(); +}); - async startUser() { - os.selectUser().then(user => { - this.$router.push(`/my/messaging/${Acct.toString(user)}`); - }); - }, +const headerActions = $computed(() => []); - async startGroup() { - const groups1 = await os.api('users/groups/owned'); - const groups2 = await os.api('users/groups/joined'); - if (groups1.length === 0 && groups2.length === 0) { - os.alert({ - type: 'warning', - title: this.$ts.youHaveNoGroups, - text: this.$ts.joinOrCreateGroup, - }); - return; - } - const { canceled, result: group } = await os.select({ - title: this.$ts.group, - items: groups1.concat(groups2).map(group => ({ - value: group, text: group.name - })) - }); - if (canceled) return; - this.$router.push(`/my/messaging/group/${group.id}`); - }, +const headerTabs = $computed(() => []); - acct - } +definePageMetadata({ + title: i18n.ts.messaging, + icon: 'fas fa-comments', }); </script> diff --git a/packages/client/src/pages/messaging/messaging-room.form.vue b/packages/client/src/pages/messaging/messaging-room.form.vue index 8e779c4f39..38bab90502 100644 --- a/packages/client/src/pages/messaging/messaging-room.form.vue +++ b/packages/client/src/pages/messaging/messaging-room.form.vue @@ -1,223 +1,223 @@ <template> -<div class="pemppnzi _block" +<div + class="pemppnzi _block" @dragover.stop="onDragover" @drop.stop="onDrop" > <textarea - ref="text" + ref="textEl" v-model="text" - :placeholder="$ts.inputMessageHere" + :placeholder="i18n.ts.inputMessageHere" @keydown="onKeydown" @compositionupdate="onCompositionUpdate" @paste="onPaste" ></textarea> - <div v-if="file" class="file" @click="file = null">{{ file.name }}</div> - <button class="send _button" :disabled="!canSend || sending" :title="$ts.send" @click="send"> - <template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template> - </button> - <button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button> - <button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button> - <input ref="file" type="file" @change="onChangeFile"/> + <footer> + <div v-if="file" class="file" @click="file = null">{{ file.name }}</div> + <div class="buttons"> + <button class="_button" @click="chooseFile"><i class="fas fa-photo-video"></i></button> + <button class="_button" @click="insertEmoji"><i class="fas fa-laugh-squint"></i></button> + <button class="send _button" :disabled="!canSend || sending" :title="i18n.ts.send" @click="send"> + <template v-if="!sending"><i class="fas fa-paper-plane"></i></template><template v-if="sending"><i class="fas fa-spinner fa-pulse fa-fw"></i></template> + </button> + </div> + </footer> + <input ref="fileEl" type="file" @change="onChangeFile"/> </div> </template> -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import insertTextAtCursor from 'insert-text-at-cursor'; +<script lang="ts" setup> +import { onMounted, watch } from 'vue'; +import * as Misskey from 'misskey-js'; import autosize from 'autosize'; +//import insertTextAtCursor from 'insert-text-at-cursor'; +import { throttle } from 'throttle-debounce'; import { formatTimeString } from '@/scripts/format-time-string'; import { selectFile } from '@/scripts/select-file'; import * as os from '@/os'; import { stream } from '@/stream'; -import { Autocomplete } from '@/scripts/autocomplete'; -import { throttle } from 'throttle-debounce'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +//import { Autocomplete } from '@/scripts/autocomplete'; import { uploadFile } from '@/scripts/upload'; -export default defineComponent({ - props: { - user: { - type: Object, - requird: false, - }, - group: { - type: Object, - requird: false, - }, - }, - data() { - return { - text: null, - file: null, - sending: false, - typing: throttle(3000, () => { - stream.send('typingOnMessaging', this.user ? { partner: this.user.id } : { group: this.group.id }); - }), - }; - }, - computed: { - draftKey(): string { - return this.user ? 'user:' + this.user.id : 'group:' + this.group.id; - }, - canSend(): boolean { - return (this.text != null && this.text !== '') || this.file != null; - }, - room(): any { - return this.$parent; +const props = defineProps<{ + user?: Misskey.entities.UserDetailed | null; + group?: Misskey.entities.UserGroup | null; +}>(); + +let textEl = $ref<HTMLTextAreaElement>(); +let fileEl = $ref<HTMLInputElement>(); + +let text = $ref<string>(''); +let file = $ref<Misskey.entities.DriveFile | null>(null); +let sending = $ref(false); +const typing = throttle(3000, () => { + stream.send('typingOnMessaging', props.user ? { partner: props.user.id } : { group: props.group?.id }); +}); + +let draftKey = $computed(() => props.user ? 'user:' + props.user.id : 'group:' + props.group?.id); +let canSend = $computed(() => (text != null && text !== '') || file != null); + +watch([$$(text), $$(file)], saveDraft); + +async function onPaste(ev: ClipboardEvent) { + if (!ev.clipboardData) return; + + const clipboardData = ev.clipboardData; + const items = clipboardData.items; + + if (items.length === 1) { + if (items[0].kind === 'file') { + const pastedFile = items[0].getAsFile(); + if (!pastedFile) return; + const lio = pastedFile.name.lastIndexOf('.'); + const ext = lio >= 0 ? pastedFile.name.slice(lio) : ''; + const formatted = formatTimeString(new Date(pastedFile.lastModified), defaultStore.state.pastedFileName).replace(/{{number}}/g, '1') + ext; + if (formatted) upload(pastedFile, formatted); } - }, - watch: { - text() { - this.saveDraft(); - }, - file() { - this.saveDraft(); + } else { + if (items[0].kind === 'file') { + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); } - }, - mounted() { - autosize(this.$refs.text); + } +} - // TODO: detach when unmount - // TODO - //new Autocomplete(this.$refs.text, this, { model: 'text' }); +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; - // 書きかけの投稿を復元 - const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[this.draftKey]; - if (draft) { - this.text = draft.data.text; - this.file = draft.data.file; - } - }, - methods: { - async onPaste(evt: ClipboardEvent) { - const items = evt.clipboardData.items; + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + if (isFile || isDriveFile) { + ev.preventDefault(); + ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; + } +} - if (items.length === 1) { - if (items[0].kind === 'file') { - const file = items[0].getAsFile(); - const lio = file.name.lastIndexOf('.'); - const ext = lio >= 0 ? file.name.slice(lio) : ''; - const formatted = `${formatTimeString(new Date(file.lastModified), this.$store.state.pastedFileName).replace(/{{number}}/g, '1')}${ext}`; - if (formatted) this.upload(file, formatted); - } - } else { - if (items[0].kind === 'file') { - os.alert({ - type: 'error', - text: this.$ts.onlyOneFileCanBeAttached - }); - } - } - }, +function onDrop(ev: DragEvent): void { + if (!ev.dataTransfer) return; - onDragover(evt) { - const isFile = evt.dataTransfer.items[0].kind === 'file'; - const isDriveFile = evt.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; - if (isFile || isDriveFile) { - evt.preventDefault(); - evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; - } - }, + // ファイルだったら + if (ev.dataTransfer.files.length === 1) { + ev.preventDefault(); + upload(ev.dataTransfer.files[0]); + return; + } else if (ev.dataTransfer.files.length > 1) { + ev.preventDefault(); + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + return; + } - onDrop(evt): void { - // ファイルだったら - if (evt.dataTransfer.files.length === 1) { - evt.preventDefault(); - this.upload(evt.dataTransfer.files[0]); - return; - } else if (evt.dataTransfer.files.length > 1) { - evt.preventDefault(); - os.alert({ - type: 'error', - text: this.$ts.onlyOneFileCanBeAttached - }); - return; - } + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + file = JSON.parse(driveFile); + ev.preventDefault(); + } + //#endregion +} - //#region ドライブのファイル - const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - this.file = JSON.parse(driveFile); - evt.preventDefault(); - } - //#endregion - }, +function onKeydown(ev: KeyboardEvent) { + typing(); + if ((ev.key === 'Enter') && (ev.ctrlKey || ev.metaKey) && canSend) { + send(); + } +} - onKeydown(evt) { - this.typing(); - if ((evt.which === 10 || evt.which === 13) && (evt.ctrlKey || evt.metaKey) && this.canSend) { - this.send(); - } - }, +function onCompositionUpdate() { + typing(); +} - onCompositionUpdate() { - this.typing(); - }, +function chooseFile(ev: MouseEvent) { + selectFile(ev.currentTarget ?? ev.target, i18n.ts.selectFile).then(selectedFile => { + file = selectedFile; + }); +} - chooseFile(evt) { - selectFile(evt.currentTarget ?? evt.target, this.$ts.selectFile).then(file => { - this.file = file; - }); - }, +function onChangeFile() { + if (fileEl.files![0]) upload(fileEl.files[0]); +} - onChangeFile() { - this.upload((this.$refs.file as any).files[0]); - }, +function upload(fileToUpload: File, name?: string) { + uploadFile(fileToUpload, defaultStore.state.uploadFolder, name).then(res => { + file = res; + }); +} - upload(file: File, name?: string) { - uploadFile(file, this.$store.state.uploadFolder, name).then(res => { - this.file = res; - }); - }, +function send() { + sending = true; + os.api('messaging/messages/create', { + userId: props.user ? props.user.id : undefined, + groupId: props.group ? props.group.id : undefined, + text: text ? text : undefined, + fileId: file ? file.id : undefined, + }).then(message => { + clear(); + }).catch(err => { + console.error(err); + }).then(() => { + sending = false; + }); +} - send() { - this.sending = true; - os.api('messaging/messages/create', { - userId: this.user ? this.user.id : undefined, - groupId: this.group ? this.group.id : undefined, - text: this.text ? this.text : undefined, - fileId: this.file ? this.file.id : undefined - }).then(message => { - this.clear(); - }).catch(err => { - console.error(err); - }).then(() => { - this.sending = false; - }); - }, +function clear() { + text = ''; + file = null; + deleteDraft(); +} + +function saveDraft() { + const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}'); - clear() { - this.text = ''; - this.file = null; - this.deleteDraft(); + drafts[draftKey] = { + updatedAt: new Date(), + // eslint-disable-next-line id-denylist + data: { + text: text, + file: file, }, + }; - saveDraft() { - const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + localStorage.setItem('message_drafts', JSON.stringify(drafts)); +} - drafts[this.draftKey] = { - updatedAt: new Date(), - data: { - text: this.text, - file: this.file - } - }; +function deleteDraft() { + const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}'); - localStorage.setItem('message_drafts', JSON.stringify(drafts)); - }, + delete drafts[draftKey]; - deleteDraft() { - const drafts = JSON.parse(localStorage.getItem('message_drafts') || '{}'); + localStorage.setItem('message_drafts', JSON.stringify(drafts)); +} - delete drafts[this.draftKey]; +async function insertEmoji(ev: MouseEvent) { + os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, textEl); +} - localStorage.setItem('message_drafts', JSON.stringify(drafts)); - }, +onMounted(() => { + autosize(textEl); - async insertEmoji(ev) { - os.openEmojiPicker(ev.currentTarget ?? ev.target, {}, this.$refs.text); - } + // TODO: detach when unmount + // TODO + //new Autocomplete(textEl, this, { model: 'text' }); + + // 書きかけの投稿を復元 + const draft = JSON.parse(localStorage.getItem('message_drafts') || '{}')[draftKey]; + if (draft) { + text = draft.data.text; + file = draft.data.file; } }); + +defineExpose({ + file, + upload, +}); </script> <style lang="scss" scoped> @@ -230,7 +230,7 @@ export default defineComponent({ width: 100%; min-width: 100%; max-width: 100%; - height: 80px; + min-height: 80px; margin: 0; padding: 16px 16px 0 16px; resize: none; @@ -245,26 +245,16 @@ export default defineComponent({ color: var(--fg); } - > .file { - padding: 8px; - color: #444; - background: #eee; - cursor: pointer; - } - - > .send { - position: absolute; + footer { + position: sticky; bottom: 0; - right: 0; - margin: 0; - padding: 16px; - font-size: 1em; - transition: color 0.1s ease; - color: var(--accent); + background: var(--panel); - &:active { - color: var(--accentDarken); - transition: color 0s ease; + > .file { + padding: 8px; + color: var(--fg); + background: transparent; + cursor: pointer; } } @@ -316,21 +306,39 @@ export default defineComponent({ } } - ._button { - margin: 0; - padding: 16px; - font-size: 1em; - font-weight: normal; - text-decoration: none; - transition: color 0.1s ease; + .buttons { + display: flex; - &:hover { - color: var(--accent); + ._button { + margin: 0; + padding: 16px; + font-size: 1em; + font-weight: normal; + text-decoration: none; + transition: color 0.1s ease; + + &:hover { + color: var(--accent); + } + + &:active { + color: var(--accentDarken); + transition: color 0s ease; + } } - &:active { - color: var(--accentDarken); - transition: color 0s ease; + > .send { + margin-left: auto; + color: var(--accent); + + &:hover { + color: var(--accentLighten); + } + + &:active { + color: var(--accentDarken); + transition: color 0s ease; + } } } diff --git a/packages/client/src/pages/messaging/messaging-room.message.vue b/packages/client/src/pages/messaging/messaging-room.message.vue index 4315bbecdb..393d2a17b2 100644 --- a/packages/client/src/pages/messaging/messaging-room.message.vue +++ b/packages/client/src/pages/messaging/messaging-room.message.vue @@ -35,45 +35,28 @@ </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import * as mfm from 'mfm-js'; +import * as Misskey from 'misskey-js'; import { extractUrlFromMfm } from '@/scripts/extract-url-from-mfm'; import MkUrlPreview from '@/components/url-preview.vue'; import * as os from '@/os'; +import { $i } from '@/account'; -export default defineComponent({ - components: { - MkUrlPreview - }, - props: { - message: { - required: true - }, - isGroup: { - required: false - } - }, - computed: { - isMe(): boolean { - return this.message.userId === this.$i.id; - }, - urls(): string[] { - if (this.message.text) { - return extractUrlFromMfm(mfm.parse(this.message.text)); - } else { - return []; - } - } - }, - methods: { - del() { - os.api('messaging/messages/delete', { - messageId: this.message.id - }); - } - } -}); +const props = defineProps<{ + message: Misskey.entities.MessagingMessage; + isGroup?: boolean; +}>(); + +const isMe = $computed(() => props.message.userId === $i?.id); +const urls = $computed(() => props.message.text ? extractUrlFromMfm(mfm.parse(props.message.text)) : []); + +function del(): void { + os.api('messaging/messages/delete', { + messageId: props.message.id, + }); +} </script> <style lang="scss" scoped> @@ -266,6 +249,7 @@ export default defineComponent({ &.isMe { flex-direction: row-reverse; padding-right: var(--margin); + right: var(--margin); // 削除時にposition: absoluteになったときに使う > .content { padding-right: 16px; diff --git a/packages/client/src/pages/messaging/messaging-room.vue b/packages/client/src/pages/messaging/messaging-room.vue index fd1962218a..2e00c3ab19 100644 --- a/packages/client/src/pages/messaging/messaging-room.vue +++ b/packages/client/src/pages/messaging/messaging-room.vue @@ -1,379 +1,300 @@ <template> -<div class="_section" +<div + ref="rootEl" + class="_section" @dragover.prevent.stop="onDragover" @drop.prevent.stop="onDrop" > <div class="_content mk-messaging-room"> <div class="body"> - <MkLoading v-if="fetching"/> - <p v-if="!fetching && messages.length == 0" class="empty"><i class="fas fa-info-circle"></i>{{ $ts.noMessagesYet }}</p> - <p v-if="!fetching && messages.length > 0 && !existMoreMessages" class="no-history"><i class="fas fa-flag"></i>{{ $ts.noMoreHistory }}</p> - <button v-show="existMoreMessages" ref="loadMore" class="more _button" :class="{ fetching: fetchingMoreMessages }" :disabled="fetchingMoreMessages" @click="fetchMoreMessages"> - <template v-if="fetchingMoreMessages"><i class="fas fa-spinner fa-pulse fa-fw"></i></template>{{ fetchingMoreMessages ? $ts.loading : $ts.loadMore }} - </button> - <XList v-if="messages.length > 0" v-slot="{ item: message }" class="messages" :items="messages" direction="up" reversed> - <XMessage :key="message.id" :message="message" :is-group="group != null"/> - </XList> + <MkPagination v-if="pagination" ref="pagingComponent" :key="userAcct || groupId" :pagination="pagination"> + <template #empty> + <div class="_fullinfo"> + <img src="https://xn--931a.moe/assets/info.jpg" class="_ghost"/> + <div>{{ i18n.ts.noMessagesYet }}</div> + </div> + </template> + + <template #default="{ items: messages, fetching: pFetching }"> + <XList + v-if="messages.length > 0" + v-slot="{ item: message }" + :class="{ messages: true, 'deny-move-transition': pFetching }" + :items="messages" + direction="up" + reversed + > + <XMessage :key="message.id" :message="message" :is-group="group != null"/> + </XList> + </template> + </MkPagination> </div> <footer> <div v-if="typers.length > 0" class="typers"> - <I18n :src="$ts.typingUsers" text-tag="span" class="users"> + <I18n :src="i18n.ts.typingUsers" text-tag="span" class="users"> <template #users> - <b v-for="user in typers" :key="user.id" class="user">{{ user.username }}</b> + <b v-for="typer in typers" :key="typer.id" class="user">{{ typer.username }}</b> </template> </I18n> <MkEllipsis/> </div> - <transition :name="$store.state.animation ? 'fade' : ''"> + <transition :name="animation ? 'fade' : ''"> <div v-show="showIndicator" class="new-message"> - <button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-arrow-circle-down"></i>{{ $ts.newMessageExists }}</button> + <button class="_buttonPrimary" @click="onIndicatorClick"><i class="fas fa-fw fa-arrow-circle-down"></i>{{ i18n.ts.newMessageExists }}</button> </div> </transition> - <XForm v-if="!fetching" ref="form" :user="user" :group="group" class="form"/> + <XForm v-if="!fetching" ref="formEl" :user="user" :group="group" class="form"/> </footer> </div> </div> </template> -<script lang="ts"> -import { computed, defineComponent, markRaw } from 'vue'; -import XList from '@/components/date-separated-list.vue'; +<script lang="ts" setup> +import { computed, watch, onMounted, nextTick, onBeforeUnmount } from 'vue'; +import * as Misskey from 'misskey-js'; +import * as Acct from 'misskey-js/built/acct'; import XMessage from './messaging-room.message.vue'; import XForm from './messaging-room.form.vue'; -import * as Acct from 'misskey-js/built/acct'; -import { isBottom, onScrollBottom, scroll } from '@/scripts/scroll'; +import XList from '@/components/date-separated-list.vue'; +import MkPagination, { Paging } from '@/components/ui/pagination.vue'; +import { isBottomVisible, onScrollBottom, scrollToBottom } from '@/scripts/scroll'; import * as os from '@/os'; import { stream } from '@/stream'; -import { popout } from '@/scripts/popout'; import * as sound from '@/scripts/sound'; -import * as symbols from '@/symbols'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; +import { defaultStore } from '@/store'; +import { definePageMetadata } from '@/scripts/page-metadata'; -const Component = defineComponent({ - components: { - XMessage, - XForm, - XList, - }, +const props = defineProps<{ + userAcct?: string; + groupId?: string; +}>(); - inject: ['inWindow'], +let rootEl = $ref<HTMLDivElement>(); +let formEl = $ref<InstanceType<typeof XForm>>(); +let pagingComponent = $ref<InstanceType<typeof MkPagination>>(); - props: { - userAcct: { - type: String, - required: false, - }, - groupId: { - type: String, - required: false, - }, - }, +let fetching = $ref(true); +let user: Misskey.entities.UserDetailed | null = $ref(null); +let group: Misskey.entities.UserGroup | null = $ref(null); +let typers: Misskey.entities.User[] = $ref([]); +let connection: Misskey.ChannelConnection<Misskey.Channels['messaging']> | null = $ref(null); +let showIndicator = $ref(false); +const { + animation, +} = defaultStore.reactiveState; - data() { - return { - [symbols.PAGE_INFO]: computed(() => !this.fetching ? this.user ? { - userName: this.user, - avatar: this.user, - action: { - icon: 'fas fa-ellipsis-h', - handler: this.menu, - }, - } : { - title: this.group.name, - icon: 'fas fa-users', - action: { - icon: 'fas fa-ellipsis-h', - handler: this.menu, - }, - } : null), - fetching: true, - user: null, - group: null, - fetchingMoreMessages: false, - messages: [], - existMoreMessages: false, - connection: null, - showIndicator: false, - timer: null, - typers: [], - ilObserver: new IntersectionObserver( - (entries) => entries.some((entry) => entry.isIntersecting) - && !this.fetching - && !this.fetchingMoreMessages - && this.existMoreMessages - && this.fetchMoreMessages() - ), - }; - }, - - computed: { - form(): any { - return this.$refs.form; - } - }, - - watch: { - userAcct: 'fetch', - groupId: 'fetch', - }, - - mounted() { - this.fetch(); - if (this.$store.state.enableInfiniteScroll) { - this.$nextTick(() => this.ilObserver.observe(this.$refs.loadMore as Element)); - } - }, +let pagination: Paging | null = $ref(null); - beforeUnmount() { - this.connection.dispose(); - - document.removeEventListener('visibilitychange', this.onVisibilitychange); - - this.ilObserver.disconnect(); - }, - - methods: { - async fetch() { - this.fetching = true; - if (this.userAcct) { - const user = await os.api('users/show', Acct.parse(this.userAcct)); - this.user = user; - } else { - const group = await os.api('users/groups/show', { groupId: this.groupId }); - this.group = group; - } +watch([() => props.userAcct, () => props.groupId], () => { + if (connection) connection.dispose(); + fetch(); +}); - this.connection = markRaw(stream.useChannel('messaging', { - otherparty: this.user ? this.user.id : undefined, - group: this.group ? this.group.id : undefined, - })); +async function fetch() { + fetching = true; - this.connection.on('message', this.onMessage); - this.connection.on('read', this.onRead); - this.connection.on('deleted', this.onDeleted); - this.connection.on('typers', typers => { - this.typers = typers.filter(u => u.id !== this.$i.id); - }); + if (props.userAcct) { + const acct = Acct.parse(props.userAcct); + user = await os.api('users/show', { username: acct.username, host: acct.host || undefined }); + group = null; + + pagination = { + endpoint: 'messaging/messages', + limit: 20, + params: { + userId: user.id, + }, + reversed: true, + pageEl: $$(rootEl).value, + }; + connection = stream.useChannel('messaging', { + otherparty: user.id, + }); + } else { + user = null; + group = await os.api('users/groups/show', { groupId: props.groupId }); - document.addEventListener('visibilitychange', this.onVisibilitychange); + pagination = { + endpoint: 'messaging/messages', + limit: 20, + params: { + groupId: group?.id, + }, + reversed: true, + pageEl: $$(rootEl).value, + }; + connection = stream.useChannel('messaging', { + group: group?.id, + }); + } - this.fetchMessages().then(() => { - this.scrollToBottom(); + connection.on('message', onMessage); + connection.on('read', onRead); + connection.on('deleted', onDeleted); + connection.on('typers', _typers => { + typers = _typers.filter(u => u.id !== $i?.id); + }); - // もっと見るの交差検知を発火させないためにfetchは - // スクロールが終わるまでfalseにしておく - // scrollendのようなイベントはないのでsetTimeoutで - window.setTimeout(() => this.fetching = false, 300); - }); - }, + document.addEventListener('visibilitychange', onVisibilitychange); - onDragover(evt) { - const isFile = evt.dataTransfer.items[0].kind === 'file'; - const isDriveFile = evt.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; + nextTick(() => { + thisScrollToBottom(); + window.setTimeout(() => { + fetching = false; + }, 300); + }); +} - if (isFile || isDriveFile) { - evt.dataTransfer.dropEffect = evt.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; - } else { - evt.dataTransfer.dropEffect = 'none'; - } - }, +function onDragover(ev: DragEvent) { + if (!ev.dataTransfer) return; - onDrop(evt): void { - // ファイルだったら - if (evt.dataTransfer.files.length === 1) { - this.form.upload(evt.dataTransfer.files[0]); - return; - } else if (evt.dataTransfer.files.length > 1) { - os.alert({ - type: 'error', - text: this.$ts.onlyOneFileCanBeAttached - }); - return; - } - - //#region ドライブのファイル - const driveFile = evt.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); - if (driveFile != null && driveFile !== '') { - const file = JSON.parse(driveFile); - this.form.file = file; - } - //#endregion - }, + const isFile = ev.dataTransfer.items[0].kind === 'file'; + const isDriveFile = ev.dataTransfer.types[0] === _DATA_TRANSFER_DRIVE_FILE_; - fetchMessages() { - return new Promise((resolve, reject) => { - const max = this.existMoreMessages ? 20 : 10; + if (isFile || isDriveFile) { + ev.dataTransfer.dropEffect = ev.dataTransfer.effectAllowed === 'all' ? 'copy' : 'move'; + } else { + ev.dataTransfer.dropEffect = 'none'; + } +} - os.api('messaging/messages', { - userId: this.user ? this.user.id : undefined, - groupId: this.group ? this.group.id : undefined, - limit: max + 1, - untilId: this.existMoreMessages ? this.messages[0].id : undefined - }).then(messages => { - if (messages.length === max + 1) { - this.existMoreMessages = true; - messages.pop(); - } else { - this.existMoreMessages = false; - } +function onDrop(ev: DragEvent): void { + if (!ev.dataTransfer) return; - this.messages.unshift.apply(this.messages, messages.reverse()); - resolve(); - }); - }); - }, + // ファイルだったら + if (ev.dataTransfer.files.length === 1) { + formEl.upload(ev.dataTransfer.files[0]); + return; + } else if (ev.dataTransfer.files.length > 1) { + os.alert({ + type: 'error', + text: i18n.ts.onlyOneFileCanBeAttached, + }); + return; + } - fetchMoreMessages() { - this.fetchingMoreMessages = true; - this.fetchMessages().then(() => { - this.fetchingMoreMessages = false; - }); - }, + //#region ドライブのファイル + const driveFile = ev.dataTransfer.getData(_DATA_TRANSFER_DRIVE_FILE_); + if (driveFile != null && driveFile !== '') { + const file = JSON.parse(driveFile); + formEl.file = file; + } + //#endregion +} - onMessage(message) { - sound.play('chat'); +function onMessage(message) { + sound.play('chat'); - const _isBottom = isBottom(this.$el, 64); + const _isBottom = isBottomVisible(rootEl, 64); - this.messages.push(message); - if (message.userId !== this.$i.id && !document.hidden) { - this.connection.send('read', { - id: message.id - }); - } + pagingComponent.prepend(message); + if (message.userId !== $i?.id && !document.hidden) { + connection?.send('read', { + id: message.id, + }); + } - if (_isBottom) { - // Scroll to bottom - this.$nextTick(() => { - this.scrollToBottom(); - }); - } else if (message.userId !== this.$i.id) { - // Notify - this.notifyNewMessage(); - } - }, + if (_isBottom) { + // Scroll to bottom + nextTick(() => { + thisScrollToBottom(); + }); + } else if (message.userId !== $i?.id) { + // Notify + notifyNewMessage(); + } +} - onRead(x) { - if (this.user) { - if (!Array.isArray(x)) x = [x]; - for (const id of x) { - if (this.messages.some(x => x.id === id)) { - const exist = this.messages.map(x => x.id).indexOf(id); - this.messages[exist] = { - ...this.messages[exist], - isRead: true, - }; - } - } - } else if (this.group) { - for (const id of x.ids) { - if (this.messages.some(x => x.id === id)) { - const exist = this.messages.map(x => x.id).indexOf(id); - this.messages[exist] = { - ...this.messages[exist], - reads: [...this.messages[exist].reads, x.userId] - }; - } - } +function onRead(x) { + if (user) { + if (!Array.isArray(x)) x = [x]; + for (const id of x) { + if (pagingComponent.items.some(y => y.id === id)) { + const exist = pagingComponent.items.map(y => y.id).indexOf(id); + pagingComponent.items[exist] = { + ...pagingComponent.items[exist], + isRead: true, + }; } - }, - - onDeleted(id) { - const msg = this.messages.find(m => m.id === id); - if (msg) { - this.messages = this.messages.filter(m => m.id !== msg.id); + } + } else if (group) { + for (const id of x.ids) { + if (pagingComponent.items.some(y => y.id === id)) { + const exist = pagingComponent.items.map(y => y.id).indexOf(id); + pagingComponent.items[exist] = { + ...pagingComponent.items[exist], + reads: [...pagingComponent.items[exist].reads, x.userId], + }; } - }, - - scrollToBottom() { - scroll(this.$el, { top: this.$el.offsetHeight }); - }, - - onIndicatorClick() { - this.showIndicator = false; - this.scrollToBottom(); - }, + } + } +} - notifyNewMessage() { - this.showIndicator = true; +function onDeleted(id) { + const msg = pagingComponent.items.find(m => m.id === id); + if (msg) { + pagingComponent.items = pagingComponent.items.filter(m => m.id !== msg.id); + } +} - onScrollBottom(this.$el, () => { - this.showIndicator = false; - }); +function thisScrollToBottom() { + scrollToBottom($$(rootEl).value, { behavior: 'smooth' }); +} - if (this.timer) window.clearTimeout(this.timer); +function onIndicatorClick() { + showIndicator = false; + thisScrollToBottom(); +} - this.timer = window.setTimeout(() => { - this.showIndicator = false; - }, 4000); - }, +let scrollRemove: (() => void) | null = $ref(null); - onVisibilitychange() { - if (document.hidden) return; - for (const message of this.messages) { - if (message.userId !== this.$i.id && !message.isRead) { - this.connection.send('read', { - id: message.id - }); - } - } - }, +function notifyNewMessage() { + showIndicator = true; - menu(ev) { - const path = this.groupId ? `/my/messaging/group/${this.groupId}` : `/my/messaging/${this.userAcct}`; + scrollRemove = onScrollBottom(rootEl, () => { + showIndicator = false; + scrollRemove = null; + }); +} - os.popupMenu([this.inWindow ? undefined : { - text: this.$ts.openInWindow, - icon: 'fas fa-window-maximize', - action: () => { - os.pageWindow(path); - this.$router.back(); - }, - }, this.inWindow ? undefined : { - text: this.$ts.popout, - icon: 'fas fa-external-link-alt', - action: () => { - popout(path); - this.$router.back(); - }, - }], ev.currentTarget ?? ev.target); +function onVisibilitychange() { + if (document.hidden) return; + for (const message of pagingComponent.items) { + if (message.userId !== $i?.id && !message.isRead) { + connection?.send('read', { + id: message.id, + }); } } +} + +onMounted(() => { + fetch(); }); -export default Component; +onBeforeUnmount(() => { + connection?.dispose(); + document.removeEventListener('visibilitychange', onVisibilitychange); + if (scrollRemove) scrollRemove(); +}); + +definePageMetadata(computed(() => !fetching ? user ? { + userName: user, + avatar: user, +} : { + title: group?.name, + icon: 'fas fa-users', +} : null)); </script> <style lang="scss" scoped> .mk-messaging-room { - > .body { - > .empty { - width: 100%; - margin: 0; - padding: 16px 8px 8px 8px; - text-align: center; - font-size: 0.8em; - opacity: 0.5; - - i { - margin-right: 4px; - } - } - - > .no-history { - display: block; - margin: 0; - padding: 16px; - text-align: center; - font-size: 0.8em; - color: var(--messagingRoomInfo); - opacity: 0.5; - - i { - margin-right: 4px; - } - } + position: relative; - > .more { + > .body { + .more { display: block; margin: 16px auto; padding: 0 12px; @@ -399,7 +320,9 @@ export default Component; } } - > .messages { + .messages { + padding: 8px 0; + > ::v-deep(*) { margin-bottom: 16px; } @@ -408,29 +331,31 @@ export default Component; > footer { width: 100%; - position: relative; + position: sticky; + z-index: 2; + bottom: 0; + padding-top: 8px; + + @media (max-width: 500px) { + bottom: calc(env(safe-area-inset-bottom, 0px) + 92px); + } > .new-message { - position: absolute; - top: -48px; width: 100%; - padding: 8px 0; + padding-bottom: 8px; text-align: center; > button { display: inline-block; margin: 0; - padding: 0 12px 0 30px; + padding: 0 12px; line-height: 32px; font-size: 12px; border-radius: 16px; > i { - position: absolute; - top: 0; - left: 10px; - line-height: 32px; - font-size: 16px; + display: inline-block; + margin-right: 8px; } } } @@ -455,6 +380,8 @@ export default Component; } > .form { + max-height: 12em; + overflow-y: scroll; border-top: solid 0.5px var(--divider); } } diff --git a/packages/client/src/pages/mfm-cheat-sheet.vue b/packages/client/src/pages/mfm-cheat-sheet.vue index 2c10494ede..3315479abf 100644 --- a/packages/client/src/pages/mfm-cheat-sheet.vue +++ b/packages/client/src/pages/mfm-cheat-sheet.vue @@ -1,127 +1,129 @@ <template> -<div class="mwysmxbg"> - <div class="_isolated">{{ $ts._mfm.intro }}</div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.mention }}</div> - <div class="content"> - <p>{{ $ts._mfm.mentionDescription }}</p> - <div class="preview"> - <Mfm :text="preview_mention"/> - <MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea> +<MkStickyContainer> + <template #header><MkPageHeader/></template> + <div class="mwysmxbg"> + <div>{{ $ts._mfm.intro }}</div> + <div class="section _block"> + <div class="title">{{ $ts._mfm.mention }}</div> + <div class="content"> + <p>{{ $ts._mfm.mentionDescription }}</p> + <div class="preview"> + <Mfm :text="preview_mention"/> + <MkTextarea v-model="preview_mention"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.hashtag }}</div> - <div class="content"> - <p>{{ $ts._mfm.hashtagDescription }}</p> - <div class="preview"> - <Mfm :text="preview_hashtag"/> - <MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.hashtag }}</div> + <div class="content"> + <p>{{ $ts._mfm.hashtagDescription }}</p> + <div class="preview"> + <Mfm :text="preview_hashtag"/> + <MkTextarea v-model="preview_hashtag"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.url }}</div> - <div class="content"> - <p>{{ $ts._mfm.urlDescription }}</p> - <div class="preview"> - <Mfm :text="preview_url"/> - <MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.url }}</div> + <div class="content"> + <p>{{ $ts._mfm.urlDescription }}</p> + <div class="preview"> + <Mfm :text="preview_url"/> + <MkTextarea v-model="preview_url"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.link }}</div> - <div class="content"> - <p>{{ $ts._mfm.linkDescription }}</p> - <div class="preview"> - <Mfm :text="preview_link"/> - <MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.link }}</div> + <div class="content"> + <p>{{ $ts._mfm.linkDescription }}</p> + <div class="preview"> + <Mfm :text="preview_link"/> + <MkTextarea v-model="preview_link"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.emoji }}</div> - <div class="content"> - <p>{{ $ts._mfm.emojiDescription }}</p> - <div class="preview"> - <Mfm :text="preview_emoji"/> - <MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.emoji }}</div> + <div class="content"> + <p>{{ $ts._mfm.emojiDescription }}</p> + <div class="preview"> + <Mfm :text="preview_emoji"/> + <MkTextarea v-model="preview_emoji"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.bold }}</div> - <div class="content"> - <p>{{ $ts._mfm.boldDescription }}</p> - <div class="preview"> - <Mfm :text="preview_bold"/> - <MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.bold }}</div> + <div class="content"> + <p>{{ $ts._mfm.boldDescription }}</p> + <div class="preview"> + <Mfm :text="preview_bold"/> + <MkTextarea v-model="preview_bold"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.small }}</div> - <div class="content"> - <p>{{ $ts._mfm.smallDescription }}</p> - <div class="preview"> - <Mfm :text="preview_small"/> - <MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.small }}</div> + <div class="content"> + <p>{{ $ts._mfm.smallDescription }}</p> + <div class="preview"> + <Mfm :text="preview_small"/> + <MkTextarea v-model="preview_small"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.quote }}</div> - <div class="content"> - <p>{{ $ts._mfm.quoteDescription }}</p> - <div class="preview"> - <Mfm :text="preview_quote"/> - <MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.quote }}</div> + <div class="content"> + <p>{{ $ts._mfm.quoteDescription }}</p> + <div class="preview"> + <Mfm :text="preview_quote"/> + <MkTextarea v-model="preview_quote"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.center }}</div> - <div class="content"> - <p>{{ $ts._mfm.centerDescription }}</p> - <div class="preview"> - <Mfm :text="preview_center"/> - <MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.center }}</div> + <div class="content"> + <p>{{ $ts._mfm.centerDescription }}</p> + <div class="preview"> + <Mfm :text="preview_center"/> + <MkTextarea v-model="preview_center"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.inlineCode }}</div> - <div class="content"> - <p>{{ $ts._mfm.inlineCodeDescription }}</p> - <div class="preview"> - <Mfm :text="preview_inlineCode"/> - <MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.inlineCode }}</div> + <div class="content"> + <p>{{ $ts._mfm.inlineCodeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_inlineCode"/> + <MkTextarea v-model="preview_inlineCode"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.blockCode }}</div> - <div class="content"> - <p>{{ $ts._mfm.blockCodeDescription }}</p> - <div class="preview"> - <Mfm :text="preview_blockCode"/> - <MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.blockCode }}</div> + <div class="content"> + <p>{{ $ts._mfm.blockCodeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_blockCode"/> + <MkTextarea v-model="preview_blockCode"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.inlineMath }}</div> - <div class="content"> - <p>{{ $ts._mfm.inlineMathDescription }}</p> - <div class="preview"> - <Mfm :text="preview_inlineMath"/> - <MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.inlineMath }}</div> + <div class="content"> + <p>{{ $ts._mfm.inlineMathDescription }}</p> + <div class="preview"> + <Mfm :text="preview_inlineMath"/> + <MkTextarea v-model="preview_inlineMath"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <!-- deprecated + <!-- deprecated <div class="section _block"> <div class="title">{{ $ts._mfm.search }}</div> <div class="content"> @@ -133,216 +135,210 @@ </div> </div> --> - <div class="section _block"> - <div class="title">{{ $ts._mfm.flip }}</div> - <div class="content"> - <p>{{ $ts._mfm.flipDescription }}</p> - <div class="preview"> - <Mfm :text="preview_flip"/> - <MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.flip }}</div> + <div class="content"> + <p>{{ $ts._mfm.flipDescription }}</p> + <div class="preview"> + <Mfm :text="preview_flip"/> + <MkTextarea v-model="preview_flip"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.font }}</div> - <div class="content"> - <p>{{ $ts._mfm.fontDescription }}</p> - <div class="preview"> - <Mfm :text="preview_font"/> - <MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.font }}</div> + <div class="content"> + <p>{{ $ts._mfm.fontDescription }}</p> + <div class="preview"> + <Mfm :text="preview_font"/> + <MkTextarea v-model="preview_font"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.x2 }}</div> - <div class="content"> - <p>{{ $ts._mfm.x2Description }}</p> - <div class="preview"> - <Mfm :text="preview_x2"/> - <MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.x2 }}</div> + <div class="content"> + <p>{{ $ts._mfm.x2Description }}</p> + <div class="preview"> + <Mfm :text="preview_x2"/> + <MkTextarea v-model="preview_x2"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.x3 }}</div> - <div class="content"> - <p>{{ $ts._mfm.x3Description }}</p> - <div class="preview"> - <Mfm :text="preview_x3"/> - <MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.x3 }}</div> + <div class="content"> + <p>{{ $ts._mfm.x3Description }}</p> + <div class="preview"> + <Mfm :text="preview_x3"/> + <MkTextarea v-model="preview_x3"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.x4 }}</div> - <div class="content"> - <p>{{ $ts._mfm.x4Description }}</p> - <div class="preview"> - <Mfm :text="preview_x4"/> - <MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.x4 }}</div> + <div class="content"> + <p>{{ $ts._mfm.x4Description }}</p> + <div class="preview"> + <Mfm :text="preview_x4"/> + <MkTextarea v-model="preview_x4"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.blur }}</div> - <div class="content"> - <p>{{ $ts._mfm.blurDescription }}</p> - <div class="preview"> - <Mfm :text="preview_blur"/> - <MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.blur }}</div> + <div class="content"> + <p>{{ $ts._mfm.blurDescription }}</p> + <div class="preview"> + <Mfm :text="preview_blur"/> + <MkTextarea v-model="preview_blur"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.jelly }}</div> - <div class="content"> - <p>{{ $ts._mfm.jellyDescription }}</p> - <div class="preview"> - <Mfm :text="preview_jelly"/> - <MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.jelly }}</div> + <div class="content"> + <p>{{ $ts._mfm.jellyDescription }}</p> + <div class="preview"> + <Mfm :text="preview_jelly"/> + <MkTextarea v-model="preview_jelly"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.tada }}</div> - <div class="content"> - <p>{{ $ts._mfm.tadaDescription }}</p> - <div class="preview"> - <Mfm :text="preview_tada"/> - <MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.tada }}</div> + <div class="content"> + <p>{{ $ts._mfm.tadaDescription }}</p> + <div class="preview"> + <Mfm :text="preview_tada"/> + <MkTextarea v-model="preview_tada"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.jump }}</div> - <div class="content"> - <p>{{ $ts._mfm.jumpDescription }}</p> - <div class="preview"> - <Mfm :text="preview_jump"/> - <MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.jump }}</div> + <div class="content"> + <p>{{ $ts._mfm.jumpDescription }}</p> + <div class="preview"> + <Mfm :text="preview_jump"/> + <MkTextarea v-model="preview_jump"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.bounce }}</div> - <div class="content"> - <p>{{ $ts._mfm.bounceDescription }}</p> - <div class="preview"> - <Mfm :text="preview_bounce"/> - <MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.bounce }}</div> + <div class="content"> + <p>{{ $ts._mfm.bounceDescription }}</p> + <div class="preview"> + <Mfm :text="preview_bounce"/> + <MkTextarea v-model="preview_bounce"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.spin }}</div> - <div class="content"> - <p>{{ $ts._mfm.spinDescription }}</p> - <div class="preview"> - <Mfm :text="preview_spin"/> - <MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.spin }}</div> + <div class="content"> + <p>{{ $ts._mfm.spinDescription }}</p> + <div class="preview"> + <Mfm :text="preview_spin"/> + <MkTextarea v-model="preview_spin"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.shake }}</div> - <div class="content"> - <p>{{ $ts._mfm.shakeDescription }}</p> - <div class="preview"> - <Mfm :text="preview_shake"/> - <MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.shake }}</div> + <div class="content"> + <p>{{ $ts._mfm.shakeDescription }}</p> + <div class="preview"> + <Mfm :text="preview_shake"/> + <MkTextarea v-model="preview_shake"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.twitch }}</div> - <div class="content"> - <p>{{ $ts._mfm.twitchDescription }}</p> - <div class="preview"> - <Mfm :text="preview_twitch"/> - <MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.twitch }}</div> + <div class="content"> + <p>{{ $ts._mfm.twitchDescription }}</p> + <div class="preview"> + <Mfm :text="preview_twitch"/> + <MkTextarea v-model="preview_twitch"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.rainbow }}</div> - <div class="content"> - <p>{{ $ts._mfm.rainbowDescription }}</p> - <div class="preview"> - <Mfm :text="preview_rainbow"/> - <MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.rainbow }}</div> + <div class="content"> + <p>{{ $ts._mfm.rainbowDescription }}</p> + <div class="preview"> + <Mfm :text="preview_rainbow"/> + <MkTextarea v-model="preview_rainbow"><template #label>MFM</template></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.sparkle }}</div> - <div class="content"> - <p>{{ $ts._mfm.sparkleDescription }}</p> - <div class="preview"> - <Mfm :text="preview_sparkle"/> - <MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.sparkle }}</div> + <div class="content"> + <p>{{ $ts._mfm.sparkleDescription }}</p> + <div class="preview"> + <Mfm :text="preview_sparkle"/> + <MkTextarea v-model="preview_sparkle"><span>MFM</span></MkTextarea> + </div> </div> </div> - </div> - <div class="section _block"> - <div class="title">{{ $ts._mfm.rotate }}</div> - <div class="content"> - <p>{{ $ts._mfm.rotateDescription }}</p> - <div class="preview"> - <Mfm :text="preview_rotate"/> - <MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea> + <div class="section _block"> + <div class="title">{{ $ts._mfm.rotate }}</div> + <div class="content"> + <p>{{ $ts._mfm.rotateDescription }}</p> + <div class="preview"> + <Mfm :text="preview_rotate"/> + <MkTextarea v-model="preview_rotate"><span>MFM</span></MkTextarea> + </div> </div> </div> </div> -</div> +</MkStickyContainer> </template> -<script lang="ts"> +<script lang="ts" setup> import { defineComponent } from 'vue'; import MkTextarea from '@/components/form/textarea.vue'; -import * as symbols from '@/symbols'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { instance } from '@/instance'; -export default defineComponent({ - components: { - MkTextarea - }, +const preview_mention = '@example'; +const preview_hashtag = '#test'; +const preview_url = 'https://example.com'; +const preview_link = `[${i18n.ts._mfm.dummy}](https://example.com)`; +const preview_emoji = instance.emojis.length ? `:${instance.emojis[0].name}:` : ':emojiname:'; +const preview_bold = `**${i18n.ts._mfm.dummy}**`; +const preview_small = `<small>${i18n.ts._mfm.dummy}</small>`; +const preview_center = `<center>${i18n.ts._mfm.dummy}</center>`; +const preview_inlineCode = '`<: "Hello, world!"`'; +const preview_blockCode = '```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```'; +const preview_inlineMath = '\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)'; +const preview_quote = `> ${i18n.ts._mfm.dummy}`; +const preview_search = `${i18n.ts._mfm.dummy} 検索`; +const preview_jelly = '$[jelly 🍮] $[jelly.speed=5s 🍮]'; +const preview_tada = '$[tada 🍮] $[tada.speed=5s 🍮]'; +const preview_jump = '$[jump 🍮] $[jump.speed=5s 🍮]'; +const preview_bounce = '$[bounce 🍮] $[bounce.speed=5s 🍮]'; +const preview_shake = '$[shake 🍮] $[shake.speed=5s 🍮]'; +const preview_twitch = '$[twitch 🍮] $[twitch.speed=5s 🍮]'; +const preview_spin = '$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]'; +const preview_flip = `$[flip ${i18n.ts._mfm.dummy}]\n$[flip.v ${i18n.ts._mfm.dummy}]\n$[flip.h,v ${i18n.ts._mfm.dummy}]`; +const preview_font = `$[font.serif ${i18n.ts._mfm.dummy}]\n$[font.monospace ${i18n.ts._mfm.dummy}]\n$[font.cursive ${i18n.ts._mfm.dummy}]\n$[font.fantasy ${i18n.ts._mfm.dummy}]`; +const preview_x2 = '$[x2 🍮]'; +const preview_x3 = '$[x3 🍮]'; +const preview_x4 = '$[x4 🍮]'; +const preview_blur = `$[blur ${i18n.ts._mfm.dummy}]`; +const preview_rainbow = '$[rainbow 🍮] $[rainbow.speed=5s 🍮]'; +const preview_sparkle = '$[sparkle 🍮]'; +const preview_rotate = '$[rotate 🍮]'; - data() { - return { - [symbols.PAGE_INFO]: { - title: this.$ts._mfm.cheatSheet, - icon: 'fas fa-question-circle', - }, - preview_mention: '@example', - preview_hashtag: '#test', - preview_url: `https://example.com`, - preview_link: `[${this.$ts._mfm.dummy}](https://example.com)`, - preview_emoji: this.$instance.emojis.length ? `:${this.$instance.emojis[0].name}:` : `:emojiname:`, - preview_bold: `**${this.$ts._mfm.dummy}**`, - preview_small: `<small>${this.$ts._mfm.dummy}</small>`, - preview_center: `<center>${this.$ts._mfm.dummy}</center>`, - preview_inlineCode: '`<: "Hello, world!"`', - preview_blockCode: '```\n~ (#i, 100) {\n\t<: ? ((i % 15) = 0) "FizzBuzz"\n\t\t.? ((i % 3) = 0) "Fizz"\n\t\t.? ((i % 5) = 0) "Buzz"\n\t\t. i\n}\n```', - preview_inlineMath: '\\(x= \\frac{-b\' \\pm \\sqrt{(b\')^2-ac}}{a}\\)', - preview_quote: `> ${this.$ts._mfm.dummy}`, - preview_search: `${this.$ts._mfm.dummy} 検索`, - preview_jelly: `$[jelly 🍮] $[jelly.speed=5s 🍮]`, - preview_tada: `$[tada 🍮] $[tada.speed=5s 🍮]`, - preview_jump: `$[jump 🍮] $[jump.speed=5s 🍮]`, - preview_bounce: `$[bounce 🍮] $[bounce.speed=5s 🍮]`, - preview_shake: `$[shake 🍮] $[shake.speed=5s 🍮]`, - preview_twitch: `$[twitch 🍮] $[twitch.speed=5s 🍮]`, - preview_spin: `$[spin 🍮] $[spin.left 🍮] $[spin.alternate 🍮]\n$[spin.x 🍮] $[spin.x,left 🍮] $[spin.x,alternate 🍮]\n$[spin.y 🍮] $[spin.y,left 🍮] $[spin.y,alternate 🍮]\n\n$[spin.speed=5s 🍮]`, - preview_flip: `$[flip ${this.$ts._mfm.dummy}]\n$[flip.v ${this.$ts._mfm.dummy}]\n$[flip.h,v ${this.$ts._mfm.dummy}]`, - preview_font: `$[font.serif ${this.$ts._mfm.dummy}]\n$[font.monospace ${this.$ts._mfm.dummy}]\n$[font.cursive ${this.$ts._mfm.dummy}]\n$[font.fantasy ${this.$ts._mfm.dummy}]`, - preview_x2: `$[x2 🍮]`, - preview_x3: `$[x3 🍮]`, - preview_x4: `$[x4 🍮]`, - preview_blur: `$[blur ${this.$ts._mfm.dummy}]`, - preview_rainbow: `$[rainbow 🍮] $[rainbow.speed=5s 🍮]`, - preview_sparkle: `$[sparkle 🍮]`, - preview_rotate: `$[rotate 🍮]`, - }; - }, +definePageMetadata({ + title: i18n.ts._mfm.cheatSheet, + icon: 'fas fa-question-circle', }); </script> diff --git a/packages/client/src/pages/miauth.vue b/packages/client/src/pages/miauth.vue index 4032d7723e..4b3ac7761e 100644 --- a/packages/client/src/pages/miauth.vue +++ b/packages/client/src/pages/miauth.vue @@ -49,28 +49,12 @@ export default defineComponent({ MkSignin, MkButton, }, + props: ['session', 'callback', 'name', 'icon', 'permission'], data() { return { - state: null + state: null, }; }, - computed: { - session(): string { - return this.$route.params.session; - }, - callback(): string { - return this.$route.query.callback; - }, - name(): string { - return this.$route.query.name; - }, - icon(): string { - return this.$route.query.icon; - }, - permission(): string[] { - return this.$route.query.permission ? this.$route.query.permission.split(',') : []; - }, - }, methods: { async accept() { this.state = 'waiting'; @@ -84,7 +68,7 @@ export default defineComponent({ this.state = 'accepted'; if (this.callback) { location.href = appendQuery(this.callback, query({ - session: this.session + session: this.session, })); } }, @@ -93,8 +77,8 @@ export default defineComponent({ }, onLogin(res) { login(res.i); - } - } + }, + }, }); </script> diff --git a/packages/client/src/pages/my-antennas/create.vue b/packages/client/src/pages/my-antennas/create.vue index a08bece731..dc10bece81 100644 --- a/packages/client/src/pages/my-antennas/create.vue +++ b/packages/client/src/pages/my-antennas/create.vue @@ -5,11 +5,13 @@ </template> <script lang="ts" setup> -import { } from 'vue'; +import { inject } from 'vue'; import XAntenna from './editor.vue'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; -import { router } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { useRouter } from '@/router'; + +const router = useRouter(); let draft = $ref({ name: '', @@ -22,19 +24,20 @@ let draft = $ref({ withReplies: false, caseSensitive: false, withFile: false, - notify: false + notify: false, }); function onAntennaCreated() { router.push('/my/antennas'); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.manageAntennas, - icon: 'fas fa-satellite', - bg: 'var(--bg)', - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.manageAntennas, + icon: 'fas fa-satellite', }); </script> diff --git a/packages/client/src/pages/my-antennas/edit.vue b/packages/client/src/pages/my-antennas/edit.vue index 38e56ce35d..53f9b07db0 100644 --- a/packages/client/src/pages/my-antennas/edit.vue +++ b/packages/client/src/pages/my-antennas/edit.vue @@ -5,14 +5,14 @@ </template> <script lang="ts" setup> -import { watch } from 'vue'; +import { inject, watch } from 'vue'; import XAntenna from './editor.vue'; -import * as symbols from '@/symbols'; import * as os from '@/os'; -import { MisskeyNavigator } from '@/scripts/navigate'; import { i18n } from '@/i18n'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; -const nav = new MisskeyNavigator(); +const router = useRouter(); let antenna: any = $ref(null); @@ -21,18 +21,20 @@ const props = defineProps<{ }>(); function onAntennaUpdated() { - nav.push('/my/antennas'); + router.push('/my/antennas'); } os.api('antennas/show', { antennaId: props.antennaId }).then((antennaResponse) => { antenna = antennaResponse; }); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.manageAntennas, - icon: 'fas fa-satellite', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.manageAntennas, + icon: 'fas fa-satellite', }); </script> diff --git a/packages/client/src/pages/my-antennas/editor.vue b/packages/client/src/pages/my-antennas/editor.vue index 6f3c4afbfe..9470257c6c 100644 --- a/packages/client/src/pages/my-antennas/editor.vue +++ b/packages/client/src/pages/my-antennas/editor.vue @@ -46,6 +46,7 @@ <script lang="ts" setup> import { watch } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; import MkButton from '@/components/ui/button.vue'; import MkInput from '@/components/form/input.vue'; import MkTextarea from '@/components/form/textarea.vue'; diff --git a/packages/client/src/pages/my-antennas/index.vue b/packages/client/src/pages/my-antennas/index.vue index a568f64c52..70e444da52 100644 --- a/packages/client/src/pages/my-antennas/index.vue +++ b/packages/client/src/pages/my-antennas/index.vue @@ -1,5 +1,6 @@ -<template> -<MkSpacer :content-max="700"> +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> <div class="ieepwinx"> <MkButton :link="true" to="/my/antennas/create" primary class="add"><i class="fas fa-plus"></i> {{ i18n.ts.add }}</MkButton> @@ -11,27 +12,28 @@ </MkPagination> </div> </div> -</MkSpacer> +</MkSpacer></MkStickyContainer> </template> <script lang="ts" setup> import { } from 'vue'; import MkPagination from '@/components/ui/pagination.vue'; import MkButton from '@/components/ui/button.vue'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const pagination = { endpoint: 'antennas/list' as const, limit: 10, }; -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.manageAntennas, - icon: 'fas fa-satellite', - bg: 'var(--bg)' - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.manageAntennas, + icon: 'fas fa-satellite', }); </script> diff --git a/packages/client/src/pages/my-clips/index.vue b/packages/client/src/pages/my-clips/index.vue index e287357a42..ac5a3578f8 100644 --- a/packages/client/src/pages/my-clips/index.vue +++ b/packages/client/src/pages/my-clips/index.vue @@ -1,5 +1,6 @@ -<template> -<MkSpacer :content-max="700"> +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> <div class="qtcaoidl"> <MkButton primary class="add" @click="create"><i class="fas fa-plus"></i> {{ $ts.add }}</MkButton> @@ -10,7 +11,7 @@ </MkA> </MkPagination> </div> -</MkSpacer> +</MkSpacer></MkStickyContainer> </template> <script lang="ts" setup> @@ -18,8 +19,8 @@ import { } from 'vue'; import MkPagination from '@/components/ui/pagination.vue'; import MkButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const pagination = { endpoint: 'clips/list' as const, @@ -61,15 +62,16 @@ function onClipDeleted() { pagingComponent.reload(); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.clip, - icon: 'fas fa-paperclip', - bg: 'var(--bg)', - action: { - icon: 'fas fa-plus', - handler: create - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.clip, + icon: 'fas fa-paperclip', + action: { + icon: 'fas fa-plus', + handler: create, }, }); </script> diff --git a/packages/client/src/pages/my-groups/group.vue b/packages/client/src/pages/my-groups/group.vue deleted file mode 100644 index 92c0483af9..0000000000 --- a/packages/client/src/pages/my-groups/group.vue +++ /dev/null @@ -1,178 +0,0 @@ -<template> -<div class="mk-group-page"> - <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> - <div v-if="group" class="_section"> - <div class="_content" style="display: flex; gap: var(--margin); flex-wrap: wrap;"> - <MkButton inline @click="invite()">{{ $ts.invite }}</MkButton> - <MkButton inline @click="renameGroup()">{{ $ts.rename }}</MkButton> - <MkButton inline @click="transfer()">{{ $ts.transfer }}</MkButton> - <MkButton inline @click="deleteGroup()">{{ $ts.delete }}</MkButton> - </div> - </div> - </transition> - - <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> - <div v-if="group" class="_section members _gap"> - <div class="_title">{{ $ts.members }}</div> - <div class="_content"> - <div class="users"> - <div v-for="user in users" :key="user.id" class="user _panel"> - <MkAvatar :user="user" class="avatar" :show-indicator="true"/> - <div class="body"> - <MkUserName :user="user" class="name"/> - <MkAcct :user="user" class="acct"/> - </div> - <div class="action"> - <button class="_button" @click="removeUser(user)"><i class="fas fa-times"></i></button> - </div> - </div> - </div> - </div> - </div> - </transition> -</div> -</template> - -<script lang="ts"> -import { computed, defineComponent } from 'vue'; -import MkButton from '@/components/ui/button.vue'; -import * as os from '@/os'; -import * as symbols from '@/symbols'; - -export default defineComponent({ - components: { - MkButton - }, - - props: { - groupId: { - type: String, - required: true, - }, - }, - - data() { - return { - [symbols.PAGE_INFO]: computed(() => this.group ? { - title: this.group.name, - icon: 'fas fa-users', - } : null), - group: null, - users: [], - }; - }, - - watch: { - groupId: 'fetch', - }, - - created() { - this.fetch(); - }, - - methods: { - fetch() { - os.api('users/groups/show', { - groupId: this.groupId - }).then(group => { - this.group = group; - os.api('users/show', { - userIds: this.group.userIds - }).then(users => { - this.users = users; - }); - }); - }, - - invite() { - os.selectUser().then(user => { - os.apiWithDialog('users/groups/invite', { - groupId: this.group.id, - userId: user.id - }); - }); - }, - - removeUser(user) { - os.api('users/groups/pull', { - groupId: this.group.id, - userId: user.id - }).then(() => { - this.users = this.users.filter(x => x.id !== user.id); - }); - }, - - async renameGroup() { - const { canceled, result: name } = await os.inputText({ - title: this.$ts.groupName, - default: this.group.name - }); - if (canceled) return; - - await os.api('users/groups/update', { - groupId: this.group.id, - name: name - }); - - this.group.name = name; - }, - - transfer() { - os.selectUser().then(user => { - os.apiWithDialog('users/groups/transfer', { - groupId: this.group.id, - userId: user.id - }); - }); - }, - - async deleteGroup() { - const { canceled } = await os.confirm({ - type: 'warning', - text: this.$t('removeAreYouSure', { x: this.group.name }), - }); - if (canceled) return; - - await os.apiWithDialog('users/groups/delete', { - groupId: this.group.id - }); - this.$router.push('/my/groups'); - } - } -}); -</script> - -<style lang="scss" scoped> -.mk-group-page { - > .members { - > ._content { - > .users { - > .user { - display: flex; - align-items: center; - padding: 16px; - - > .avatar { - width: 50px; - height: 50px; - } - - > .body { - flex: 1; - padding: 8px; - - > .name { - display: block; - font-weight: bold; - } - - > .acct { - opacity: 0.5; - } - } - } - } - } - } -} -</style> diff --git a/packages/client/src/pages/my-groups/index.vue b/packages/client/src/pages/my-groups/index.vue deleted file mode 100644 index 4b2b2963a8..0000000000 --- a/packages/client/src/pages/my-groups/index.vue +++ /dev/null @@ -1,147 +0,0 @@ -<template> -<MkSpacer :content-max="700"> - <div v-if="tab === 'owned'" class="_content"> - <MkButton primary style="margin: 0 auto var(--margin) auto;" @click="create"><i class="fas fa-plus"></i> {{ $ts.createGroup }}</MkButton> - - <MkPagination v-slot="{items}" ref="owned" :pagination="ownedPagination"> - <div v-for="group in items" :key="group.id" class="_card"> - <div class="_title"><MkA :to="`/my/groups/${ group.id }`" class="_link">{{ group.name }}</MkA></div> - <div class="_content"><MkAvatars :user-ids="group.userIds"/></div> - </div> - </MkPagination> - </div> - - <div v-else-if="tab === 'joined'" class="_content"> - <MkPagination v-slot="{items}" ref="joined" :pagination="joinedPagination"> - <div v-for="group in items" :key="group.id" class="_card"> - <div class="_title">{{ group.name }}</div> - <div class="_content"><MkAvatars :user-ids="group.userIds"/></div> - <div class="_footer"> - <MkButton danger @click="leave(group)">{{ $ts.leaveGroup }}</MkButton> - </div> - </div> - </MkPagination> - </div> - - <div v-else-if="tab === 'invites'" class="_content"> - <MkPagination v-slot="{items}" ref="invitations" :pagination="invitationPagination"> - <div v-for="invitation in items" :key="invitation.id" class="_card"> - <div class="_title">{{ invitation.group.name }}</div> - <div class="_content"><MkAvatars :user-ids="invitation.group.userIds"/></div> - <div class="_footer"> - <MkButton primary inline @click="acceptInvite(invitation)"><i class="fas fa-check"></i> {{ $ts.accept }}</MkButton> - <MkButton primary inline @click="rejectInvite(invitation)"><i class="fas fa-ban"></i> {{ $ts.reject }}</MkButton> - </div> - </div> - </MkPagination> - </div> -</MkSpacer> -</template> - -<script lang="ts"> -import { defineComponent, computed } from 'vue'; -import MkPagination from '@/components/ui/pagination.vue'; -import MkButton from '@/components/ui/button.vue'; -import MkContainer from '@/components/ui/container.vue'; -import MkAvatars from '@/components/avatars.vue'; -import MkTab from '@/components/tab.vue'; -import * as os from '@/os'; -import * as symbols from '@/symbols'; - -export default defineComponent({ - components: { - MkPagination, - MkButton, - MkContainer, - MkTab, - MkAvatars, - }, - - data() { - return { - [symbols.PAGE_INFO]: computed(() => ({ - title: this.$ts.groups, - icon: 'fas fa-users', - bg: 'var(--bg)', - actions: [{ - icon: 'fas fa-plus', - text: this.$ts.createGroup, - handler: this.create, - }], - tabs: [{ - active: this.tab === 'owned', - title: this.$ts.ownedGroups, - icon: 'fas fa-user-tie', - onClick: () => { this.tab = 'owned'; }, - }, { - active: this.tab === 'joined', - title: this.$ts.joinedGroups, - icon: 'fas fa-id-badge', - onClick: () => { this.tab = 'joined'; }, - }, { - active: this.tab === 'invites', - title: this.$ts.invites, - icon: 'fas fa-envelope-open-text', - onClick: () => { this.tab = 'invites'; }, - },] - })), - tab: 'owned', - ownedPagination: { - endpoint: 'users/groups/owned' as const, - limit: 10, - }, - joinedPagination: { - endpoint: 'users/groups/joined' as const, - limit: 10, - }, - invitationPagination: { - endpoint: 'i/user-group-invites' as const, - limit: 10, - }, - }; - }, - - methods: { - async create() { - const { canceled, result: name } = await os.inputText({ - title: this.$ts.groupName, - }); - if (canceled) return; - await os.api('users/groups/create', { name: name }); - this.$refs.owned.reload(); - os.success(); - }, - acceptInvite(invitation) { - os.api('users/groups/invitations/accept', { - invitationId: invitation.id - }).then(() => { - os.success(); - this.$refs.invitations.reload(); - this.$refs.joined.reload(); - }); - }, - rejectInvite(invitation) { - os.api('users/groups/invitations/reject', { - invitationId: invitation.id - }).then(() => { - this.$refs.invitations.reload(); - }); - }, - async leave(group) { - const { canceled } = await os.confirm({ - type: 'warning', - text: this.$t('leaveGroupConfirm', { name: group.name }), - }); - if (canceled) return; - os.apiWithDialog('users/groups/leave', { - groupId: group.id, - }).then(() => { - this.$refs.joined.reload(); - }); - } - } -}); -</script> - -<style lang="scss" scoped> -</style> diff --git a/packages/client/src/pages/my-lists/index.vue b/packages/client/src/pages/my-lists/index.vue index 9ed9e2960e..03b638151e 100644 --- a/packages/client/src/pages/my-lists/index.vue +++ b/packages/client/src/pages/my-lists/index.vue @@ -1,5 +1,6 @@ -<template> -<MkSpacer :content-max="700"> +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> <div class="qkcjvfiv"> <MkButton primary class="add" @click="create"><i class="fas fa-plus"></i> {{ $ts.createList }}</MkButton> @@ -10,7 +11,7 @@ </MkA> </MkPagination> </div> -</MkSpacer> +</MkSpacer></MkStickyContainer> </template> <script lang="ts" setup> @@ -19,8 +20,8 @@ import MkPagination from '@/components/ui/pagination.vue'; import MkButton from '@/components/ui/button.vue'; import MkAvatars from '@/components/avatars.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const pagingComponent = $ref<InstanceType<typeof MkPagination>>(); @@ -38,15 +39,16 @@ async function create() { pagingComponent.reload(); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.manageLists, - icon: 'fas fa-list-ul', - bg: 'var(--bg)', - action: { - icon: 'fas fa-plus', - handler: create, - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.manageLists, + icon: 'fas fa-list-ul', + action: { + icon: 'fas fa-plus', + handler: create, }, }); </script> diff --git a/packages/client/src/pages/my-lists/list.vue b/packages/client/src/pages/my-lists/list.vue index bc24f58431..892878ae88 100644 --- a/packages/client/src/pages/my-lists/list.vue +++ b/packages/client/src/pages/my-lists/list.vue @@ -1,5 +1,6 @@ -<template> -<MkSpacer :content-max="700"> +<template><MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> <div class="mk-list-page"> <transition :name="$store.state.animation ? 'zoom' : ''" mode="out-in"> <div v-if="list" class="_section"> @@ -31,104 +32,96 @@ </div> </transition> </div> -</MkSpacer> +</MkSpacer></MkStickyContainer> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed, defineComponent, watch } from 'vue'; import MkButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; +import { mainRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - MkButton - }, +const props = defineProps<{ + listId: string; +}>(); - data() { - return { - [symbols.PAGE_INFO]: computed(() => this.list ? { - title: this.list.name, - icon: 'fas fa-list-ul', - bg: 'var(--bg)', - } : null), - list: null, - users: [], - }; - }, +let list = $ref(null); +let users = $ref([]); - watch: { - $route: 'fetch' - }, +function fetchList() { + os.api('users/lists/show', { + listId: props.listId, + }).then(_list => { + list = _list; + os.api('users/show', { + userIds: list.userIds, + }).then(_users => { + users = _users; + }); + }); +} - created() { - this.fetch(); - }, +function addUser() { + os.selectUser().then(user => { + os.apiWithDialog('users/lists/push', { + listId: list.id, + userId: user.id, + }).then(() => { + users.push(user); + }); + }); +} - methods: { - fetch() { - os.api('users/lists/show', { - listId: this.$route.params.list - }).then(list => { - this.list = list; - os.api('users/show', { - userIds: this.list.userIds - }).then(users => { - this.users = users; - }); - }); - }, +function removeUser(user) { + os.api('users/lists/pull', { + listId: list.id, + userId: user.id, + }).then(() => { + users = users.filter(x => x.id !== user.id); + }); +} - addUser() { - os.selectUser().then(user => { - os.apiWithDialog('users/lists/push', { - listId: this.list.id, - userId: user.id - }).then(() => { - this.users.push(user); - }); - }); - }, +async function renameList() { + const { canceled, result: name } = await os.inputText({ + title: i18n.ts.enterListName, + default: list.name, + }); + if (canceled) return; - removeUser(user) { - os.api('users/lists/pull', { - listId: this.list.id, - userId: user.id - }).then(() => { - this.users = this.users.filter(x => x.id !== user.id); - }); - }, + await os.api('users/lists/update', { + listId: list.id, + name: name, + }); - async renameList() { - const { canceled, result: name } = await os.inputText({ - title: this.$ts.enterListName, - default: this.list.name - }); - if (canceled) return; + list.name = name; +} - await os.api('users/lists/update', { - listId: this.list.id, - name: name - }); +async function deleteList() { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: list.name }), + }); + if (canceled) return; - this.list.name = name; - }, + await os.api('users/lists/delete', { + listId: list.id, + }); + os.success(); + mainRouter.push('/my/lists'); +} - async deleteList() { - const { canceled } = await os.confirm({ - type: 'warning', - text: this.$t('removeAreYouSure', { x: this.list.name }), - }); - if (canceled) return; +watch(() => props.listId, fetchList, { immediate: true }); - await os.api('users/lists/delete', { - listId: this.list.id - }); - os.success(); - this.$router.push('/my/lists'); - } - } -}); +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => list ? { + title: list.name, + icon: 'fas fa-list-ul', +} : null)); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/not-found.vue b/packages/client/src/pages/not-found.vue index cdeb54b88b..a819cce961 100644 --- a/packages/client/src/pages/not-found.vue +++ b/packages/client/src/pages/not-found.vue @@ -8,14 +8,15 @@ </template> <script lang="ts" setup> -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.notFound, - icon: 'fas fa-exclamation-triangle', - bg: 'var(--bg)', - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.notFound, + icon: 'fas fa-exclamation-triangle', }); </script> diff --git a/packages/client/src/pages/note.vue b/packages/client/src/pages/note.vue index f0a18ecc36..5e153482d6 100644 --- a/packages/client/src/pages/note.vue +++ b/packages/client/src/pages/note.vue @@ -1,147 +1,140 @@ <template> -<MkSpacer :content-max="800"> - <div class="fcuexfpr"> - <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> - <div v-if="note" class="note"> - <div v-if="showNext" class="_gap"> - <XNotes class="_content" :pagination="next" :no-gap="true"/> - </div> - - <div class="main _gap"> - <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="fas fa-chevron-up"></i></MkButton> - <div class="note _gap"> - <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri" class="_isolated"/> - <XNoteDetailed :key="note.id" v-model:note="note" class="_isolated note"/> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <div class="fcuexfpr"> + <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <div v-if="note" class="note"> + <div v-if="showNext" class="_gap"> + <XNotes class="_content" :pagination="nextPagination" :no-gap="true"/> </div> - <div v-if="clips && clips.length > 0" class="_content clips _gap"> - <div class="title">{{ $ts.clip }}</div> - <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> - <b>{{ item.name }}</b> - <div v-if="item.description" class="description">{{ item.description }}</div> - <div class="user"> - <MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/> - </div> - </MkA> + + <div class="main _gap"> + <MkButton v-if="!showNext && hasNext" class="load next" @click="showNext = true"><i class="fas fa-chevron-up"></i></MkButton> + <div class="note _gap"> + <MkRemoteCaution v-if="note.user.host != null" :href="note.url ?? note.uri"/> + <XNoteDetailed :key="note.id" v-model:note="note" class="note"/> + </div> + <div v-if="clips && clips.length > 0" class="_content clips _gap"> + <div class="title">{{ $ts.clip }}</div> + <MkA v-for="item in clips" :key="item.id" :to="`/clips/${item.id}`" class="item _panel _gap"> + <b>{{ item.name }}</b> + <div v-if="item.description" class="description">{{ item.description }}</div> + <div class="user"> + <MkAvatar :user="item.user" class="avatar" :show-indicator="true"/> <MkUserName :user="item.user" :nowrap="false"/> + </div> + </MkA> + </div> + <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="fas fa-chevron-down"></i></MkButton> </div> - <MkButton v-if="!showPrev && hasPrev" class="load prev" @click="showPrev = true"><i class="fas fa-chevron-down"></i></MkButton> - </div> - <div v-if="showPrev" class="_gap"> - <XNotes class="_content" :pagination="prev" :no-gap="true"/> + <div v-if="showPrev" class="_gap"> + <XNotes class="_content" :pagination="prevPagination" :no-gap="true"/> + </div> </div> - </div> - <MkError v-else-if="error" @retry="fetch()"/> - <MkLoading v-else/> - </transition> - </div> -</MkSpacer> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> + </div> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed, defineComponent, watch } from 'vue'; +import * as misskey from 'misskey-js'; import XNote from '@/components/note.vue'; import XNoteDetailed from '@/components/note-detailed.vue'; import XNotes from '@/components/notes.vue'; import MkRemoteCaution from '@/components/remote-caution.vue'; import MkButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - XNote, - XNoteDetailed, - XNotes, - MkRemoteCaution, - MkButton, - }, - props: { - noteId: { - type: String, - required: true - } - }, - data() { - return { - [symbols.PAGE_INFO]: computed(() => this.note ? { - title: this.$ts.note, - subtitle: new Date(this.note.createdAt).toLocaleString(), - avatar: this.note.user, - path: `/notes/${this.note.id}`, - share: { - title: this.$t('noteOf', { user: this.note.user.name }), - text: this.note.text, - }, - bg: 'var(--bg)', - } : null), - note: null, - clips: null, - hasPrev: false, - hasNext: false, - showPrev: false, - showNext: false, - error: null, - prev: { - endpoint: 'users/notes' as const, - limit: 10, - params: computed(() => ({ - userId: this.note.userId, - untilId: this.note.id, - })), - }, - next: { - reversed: true, - endpoint: 'users/notes' as const, - limit: 10, - params: computed(() => ({ - userId: this.note.userId, - sinceId: this.note.id, - })), - }, - }; - }, - watch: { - noteId: 'fetch' - }, - created() { - this.fetch(); - }, - methods: { - fetch() { - this.hasPrev = false; - this.hasNext = false; - this.showPrev = false; - this.showNext = false; - this.note = null; - os.api('notes/show', { - noteId: this.noteId - }).then(note => { - this.note = note; - Promise.all([ - os.api('notes/clips', { - noteId: note.id, - }), - os.api('users/notes', { - userId: note.userId, - untilId: note.id, - limit: 1, - }), - os.api('users/notes', { - userId: note.userId, - sinceId: note.id, - limit: 1, - }), - ]).then(([clips, prev, next]) => { - this.clips = clips; - this.hasPrev = prev.length !== 0; - this.hasNext = next.length !== 0; - }); - }).catch(err => { - this.error = err; - }); - } - } +const props = defineProps<{ + noteId: string; +}>(); + +let note = $ref<null | misskey.entities.Note>(); +let clips = $ref(); +let hasPrev = $ref(false); +let hasNext = $ref(false); +let showPrev = $ref(false); +let showNext = $ref(false); +let error = $ref(); + +const prevPagination = { + endpoint: 'users/notes' as const, + limit: 10, + params: computed(() => note ? ({ + userId: note.userId, + untilId: note.id, + }) : null), +}; + +const nextPagination = { + reversed: true, + endpoint: 'users/notes' as const, + limit: 10, + params: computed(() => note ? ({ + userId: note.userId, + sinceId: note.id, + }) : null), +}; + +function fetchNote() { + hasPrev = false; + hasNext = false; + showPrev = false; + showNext = false; + note = null; + os.api('notes/show', { + noteId: props.noteId, + }).then(res => { + note = res; + Promise.all([ + os.api('notes/clips', { + noteId: note.id, + }), + os.api('users/notes', { + userId: note.userId, + untilId: note.id, + limit: 1, + }), + os.api('users/notes', { + userId: note.userId, + sinceId: note.id, + limit: 1, + }), + ]).then(([_clips, prev, next]) => { + clips = _clips; + hasPrev = prev.length !== 0; + hasNext = next.length !== 0; + }); + }).catch(err => { + error = err; + }); +} + +watch(() => props.noteId, fetchNote, { + immediate: true, }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => note ? { + title: i18n.ts.note, + subtitle: new Date(note.createdAt).toLocaleString(), + avatar: note.user, + path: `/notes/${note.id}`, + share: { + title: i18n.t('noteOf', { user: note.user.name }), + text: note.text, + }, +} : null)); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/notifications.vue b/packages/client/src/pages/notifications.vue index 36e423e534..acf338c2c2 100644 --- a/packages/client/src/pages/notifications.vue +++ b/packages/client/src/pages/notifications.vue @@ -1,21 +1,45 @@ <template> -<MkSpacer :content-max="800"> - <div class="clupoqwt"> - <XNotifications class="notifications" :include-types="includeTypes" :unread-only="tab === 'unread'"/> - </div> -</MkSpacer> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <div v-if="tab === 'all' || tab === 'unread'"> + <XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/> + </div> + <div v-else-if="tab === 'mentions'"> + <XNotes :pagination="mentionsPagination"/> + </div> + <div v-else-if="tab === 'directNotes'"> + <XNotes :pagination="directNotesPagination"/> + </div> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { computed } from 'vue'; +import { notificationTypes } from 'misskey-js'; import XNotifications from '@/components/notifications.vue'; +import XNotes from '@/components/notes.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; -import { notificationTypes } from 'misskey-js'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let tab = $ref('all'); let includeTypes = $ref<string[] | null>(null); +let unreadOnly = $computed(() => tab === 'unread'); + +const mentionsPagination = { + endpoint: 'notes/mentions' as const, + limit: 10, +}; + +const directNotesPagination = { + endpoint: 'notes/mentions' as const, + limit: 10, + params: { + visibility: 'specified', + }, +}; function setFilter(ev) { const typeItems = notificationTypes.map(t => ({ @@ -23,49 +47,49 @@ function setFilter(ev) { active: includeTypes && includeTypes.includes(t), action: () => { includeTypes = [t]; - } + }, })); const items = includeTypes != null ? [{ icon: 'fas fa-times', text: i18n.ts.clear, action: () => { includeTypes = null; - } + }, }, null, ...typeItems] : typeItems; os.popupMenu(items, ev.currentTarget ?? ev.target); } -defineExpose({ - [symbols.PAGE_INFO]: computed(() => ({ - title: i18n.ts.notifications, - icon: 'fas fa-bell', - bg: 'var(--bg)', - actions: [{ - text: i18n.ts.filter, - icon: 'fas fa-filter', - highlighted: includeTypes != null, - handler: setFilter, - }, { - text: i18n.ts.markAllAsRead, - icon: 'fas fa-check', - handler: () => { - os.apiWithDialog('notifications/mark-all-as-read'); - }, - }], - tabs: [{ - active: tab === 'all', - title: i18n.ts.all, - onClick: () => { tab = 'all'; }, - }, { - active: tab === 'unread', - title: i18n.ts.unread, - onClick: () => { tab = 'unread'; }, - },] - })), -}); -</script> +const headerActions = $computed(() => [tab === 'all' ? { + text: i18n.ts.filter, + icon: 'fas fa-filter', + highlighted: includeTypes != null, + handler: setFilter, +} : undefined, tab === 'all' ? { + text: i18n.ts.markAllAsRead, + icon: 'fas fa-check', + handler: () => { + os.apiWithDialog('notifications/mark-all-as-read'); + }, +} : undefined].filter(x => x !== undefined)); -<style lang="scss" scoped> -.clupoqwt { -} -</style> +const headerTabs = $computed(() => [{ + key: 'all', + title: i18n.ts.all, +}, { + key: 'unread', + title: i18n.ts.unread, +}, { + key: 'mentions', + title: i18n.ts.mentions, + icon: 'fas fa-at', +}, { + key: 'directNotes', + title: i18n.ts.directNotes, + icon: 'fas fa-envelope', +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.notifications, + icon: 'fas fa-bell', +}))); +</script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.button.vue b/packages/client/src/pages/page-editor/els/page-editor.el.button.vue index 827679d6a9..4c2e0e4eb4 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.button.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.button.vue @@ -38,44 +38,28 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent } from 'vue'; +import { } from 'vue'; import XContainer from '../page-editor.container.vue'; import MkSelect from '@/components/form/select.vue'; import MkInput from '@/components/form/input.vue'; import MkSwitch from '@/components/form/switch.vue'; -import * as os from '@/os'; -export default defineComponent({ - components: { - XContainer, MkSelect, MkInput, MkSwitch - }, - - props: { - value: { - required: true - }, - hpml: { - required: true, - }, - }, - - data() { - return { - }; - }, - - created() { - if (this.value.text == null) this.value.text = ''; - if (this.value.action == null) this.value.action = 'dialog'; - if (this.value.content == null) this.value.content = null; - if (this.value.event == null) this.value.event = null; - if (this.value.message == null) this.value.message = null; - if (this.value.primary == null) this.value.primary = false; - if (this.value.var == null) this.value.var = null; - if (this.value.fn == null) this.value.fn = null; - }, +withDefaults(defineProps<{ + value: any, + hpml: any +}>(), { + value: { + text: '', + action: 'dialog', + content: null, + event: null, + message: null, + primary: false, + var: null, + fn: null + } }); </script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue b/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue index ba5d0ba1f7..191321ae14 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.canvas.vue @@ -20,33 +20,19 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent } from 'vue'; +import { } from 'vue'; import XContainer from '../page-editor.container.vue'; import MkInput from '@/components/form/input.vue'; -import * as os from '@/os'; -export default defineComponent({ - components: { - XContainer, MkInput - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - }; - }, - - created() { - if (this.value.name == null) this.value.name = ''; - if (this.value.width == null) this.value.width = 300; - if (this.value.height == null) this.value.height = 200; - }, +withDefaults(defineProps<{ + value: any +}>(), { + value: { + name: '', + width: 300, + height: 200 + } }); </script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue b/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue index dc98a610ba..1a2078448d 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.counter.vue @@ -18,31 +18,17 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent } from 'vue'; +import { } from 'vue'; import XContainer from '../page-editor.container.vue'; import MkInput from '@/components/form/input.vue'; -import * as os from '@/os'; -export default defineComponent({ - components: { - XContainer, MkInput - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - }; - }, - - created() { - if (this.value.name == null) this.value.name = ''; - }, +withDefaults(defineProps<{ + value: any +}>(), { + value: { + name: '' + } }); </script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.if.vue b/packages/client/src/pages/page-editor/els/page-editor.el.if.vue index be3a520ea5..d763070b15 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.if.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.if.vue @@ -25,54 +25,39 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent, defineAsyncComponent } from 'vue'; +import { defineAsyncComponent, inject } from 'vue'; import { v4 as uuid } from 'uuid'; import XContainer from '../page-editor.container.vue'; import MkSelect from '@/components/form/select.vue'; import * as os from '@/os'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - XContainer, MkSelect, - XBlocks: defineAsyncComponent(() => import('../page-editor.blocks.vue')), - }, +const XBlocks = defineAsyncComponent(() => import('../page-editor.blocks.vue')); - inject: ['getPageBlockList'], - - props: { - value: { - required: true - }, - hpml: { - required: true, - }, - }, - - data() { - return { - }; - }, +const props = withDefaults(defineProps<{ + value: any, + hpml: any +}>(), { + value: { + children: [], + var: null + } +}); - created() { - if (this.value.children == null) this.value.children = []; - if (this.value.var === undefined) this.value.var = null; - }, +const getPageBlockList = inject<(any) => any>('getPageBlockList'); - methods: { - async add() { - const { canceled, result: type } = await os.select({ - title: this.$ts._pages.chooseBlock, - groupedItems: this.getPageBlockList() - }); - if (canceled) return; +async function add() { + const { canceled, result: type } = await os.select({ + title: i18n.ts._pages.chooseBlock, + groupedItems: getPageBlockList() + }); + if (canceled) return; - const id = uuid(); - this.value.children.push({ id, type }); - }, - } -}); + const id = uuid(); + props.value.children.push({ id, type }); +} </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.image.vue b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue index 9a6adab30a..b22bf1cb34 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.image.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.image.vue @@ -14,53 +14,39 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent } from 'vue'; +import { onMounted } from 'vue'; import XContainer from '../page-editor.container.vue'; import MkDriveFileThumbnail from '@/components/drive-file-thumbnail.vue'; import * as os from '@/os'; -export default defineComponent({ - components: { - XContainer, MkDriveFileThumbnail - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - file: null, - }; - }, +const props = withDefaults(defineProps<{ + value: any +}>(), { + value: { + fileId: null + } +}); - created() { - if (this.value.fileId === undefined) this.value.fileId = null; - }, +let file: any = $ref(null); - mounted() { - if (this.value.fileId == null) { - this.choose(); - } else { - os.api('drive/files/show', { - fileId: this.value.fileId - }).then(file => { - this.file = file; - }); - } - }, +async function choose() { + os.selectDriveFile(false).then((fileResponse: any) => { + file = fileResponse; + props.value.fileId = fileResponse.id; + }); +} - methods: { - async choose() { - os.selectDriveFile(false).then(file => { - this.file = file; - this.value.fileId = file.id; - }); - }, +onMounted(async () => { + if (props.value.fileId == null) { + await choose(); + } else { + os.api('drive/files/show', { + fileId: props.value.fileId + }).then(fileResponse => { + file = fileResponse; + }); } }); </script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.note.vue b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue index 2d4d9c5dcc..27f9f961f3 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.note.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.note.vue @@ -16,9 +16,9 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent } from 'vue'; +import { watch } from 'vue'; import XContainer from '../page-editor.container.vue'; import MkInput from '@/components/form/input.vue'; import MkSwitch from '@/components/form/switch.vue'; @@ -26,42 +26,27 @@ import XNote from '@/components/note.vue'; import XNoteDetailed from '@/components/note-detailed.vue'; import * as os from '@/os'; -export default defineComponent({ - components: { - XContainer, MkInput, MkSwitch, XNote, XNoteDetailed, - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - id: this.value.note, - note: null, - }; - }, +const props = withDefaults(defineProps<{ + value: any +}>(), { + value: { + note: null, + detailed: false + } +}); - watch: { - id: { - async handler() { - if (this.id && (this.id.startsWith('http://') || this.id.startsWith('https://'))) { - this.value.note = this.id.endsWith('/') ? this.id.substr(0, this.id.length - 1).split('/').pop() : this.id.split('/').pop(); - } else { - this.value.note = this.id; - } +let id: any = $ref(props.value.note); +let note: any = $ref(null); - this.note = await os.api('notes/show', { noteId: this.value.note }); - }, - immediate: true - }, - }, +watch(id, async () => { + if (id && (id.startsWith('http://') || id.startsWith('https://'))) { + props.value.note = (id.endsWith('/') ? id.slice(0, -1) : id).split('/').pop(); + } else { + props.value.note = id; + } - created() { - if (this.value.note == null) this.value.note = null; - if (this.value.detailed == null) this.value.detailed = false; - }, + note = await os.api('notes/show', { noteId: props.value.note }); +}, { + immediate: true }); </script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue index 9083f0c493..479a859e76 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.number-input.vue @@ -18,31 +18,17 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent } from 'vue'; +import { } from 'vue'; import XContainer from '../page-editor.container.vue'; import MkInput from '@/components/form/input.vue'; -import * as os from '@/os'; -export default defineComponent({ - components: { - XContainer, MkInput - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - }; - }, - - created() { - if (this.value.name == null) this.value.name = ''; - }, +withDefaults(defineProps<{ + value: any +}>(), { + value: { + name: '' + } }); </script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.post.vue b/packages/client/src/pages/page-editor/els/page-editor.el.post.vue index 3af720f4b7..f8c42c296b 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.post.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.post.vue @@ -11,35 +11,21 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent } from 'vue'; +import { } from 'vue'; import XContainer from '../page-editor.container.vue'; import MkTextarea from '@/components/form/textarea.vue'; import MkInput from '@/components/form/input.vue'; import MkSwitch from '@/components/form/switch.vue'; -import * as os from '@/os'; -export default defineComponent({ - components: { - XContainer, MkTextarea, MkInput, MkSwitch - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - }; - }, - - created() { - if (this.value.text == null) this.value.text = ''; - if (this.value.attachCanvasImage == null) this.value.attachCanvasImage = false; - if (this.value.canvasId == null) this.value.canvasId = ''; - }, +withDefaults(defineProps<{ + value: any +}>(), { + value: { + text: '', + attachCanvasImage: false, + canvasId: '' + } }); </script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue b/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue index 2502a54d79..4b28f120a9 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.radio-button.vue @@ -12,41 +12,28 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent } from 'vue'; +import { watch } from 'vue'; import XContainer from '../page-editor.container.vue'; import MkTextarea from '@/components/form/textarea.vue'; import MkInput from '@/components/form/input.vue'; -import * as os from '@/os'; -export default defineComponent({ - components: { - XContainer, MkTextarea, MkInput - }, - props: { - value: { - required: true - }, - }, - data() { - return { - values: '', - }; - }, - watch: { - values: { - handler() { - this.value.values = this.values.split('\n'); - }, - deep: true - } - }, - created() { - if (this.value.name == null) this.value.name = ''; - if (this.value.title == null) this.value.title = ''; - if (this.value.values == null) this.value.values = []; - this.values = this.value.values.join('\n'); - }, +const props = withDefaults(defineProps<{ + value: any +}>(), { + value: { + name: '', + title: '', + values: [] + } +}); + +let values: string = $ref(props.value.values.join('\n')); + +watch(values, () => { + props.value.values = values.split('\n'); +}, { + deep: true }); </script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.section.vue b/packages/client/src/pages/page-editor/els/page-editor.el.section.vue index 1684895fe1..7276cc1e1b 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.section.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.section.vue @@ -17,66 +17,51 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent, defineAsyncComponent } from 'vue'; +import { defineAsyncComponent, inject, onMounted } from 'vue'; import { v4 as uuid } from 'uuid'; import XContainer from '../page-editor.container.vue'; import * as os from '@/os'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - XContainer, - XBlocks: defineAsyncComponent(() => import('../page-editor.blocks.vue')), - }, +const XBlocks = defineAsyncComponent(() => import('../page-editor.blocks.vue')); - inject: ['getPageBlockList'], - - props: { - value: { - required: true - }, - hpml: { - required: true, - }, - }, - - data() { - return { - }; - }, +const props = withDefaults(defineProps<{ + value: any, + hpml: any +}>(), { + value: { + title: null, + children: [] + } +}); - created() { - if (this.value.title == null) this.value.title = null; - if (this.value.children == null) this.value.children = []; - }, +const getPageBlockList = inject<(any) => any>('getPageBlockList'); - mounted() { - if (this.value.title == null) { - this.rename(); - } - }, +async function rename() { + const { canceled, result: title } = await os.inputText({ + title: 'Enter title', + default: props.value.title + }); + if (canceled) return; + props.value.title = title; +} - methods: { - async rename() { - const { canceled, result: title } = await os.inputText({ - title: 'Enter title', - default: this.value.title - }); - if (canceled) return; - this.value.title = title; - }, +async function add() { + const { canceled, result: type } = await os.select({ + title: i18n.ts._pages.chooseBlock, + groupedItems: getPageBlockList() + }); + if (canceled) return; - async add() { - const { canceled, result: type } = await os.select({ - title: this.$ts._pages.chooseBlock, - groupedItems: this.getPageBlockList() - }); - if (canceled) return; + const id = uuid(); + props.value.children.push({ id, type }); +} - const id = uuid(); - this.value.children.push({ id, type }); - }, +onMounted(() => { + if (props.value.title == null) { + rename(); } }); </script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue b/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue index b989dce0ac..ded57cf304 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.switch.vue @@ -11,33 +11,19 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent } from 'vue'; +import { } from 'vue'; import XContainer from '../page-editor.container.vue'; import MkSwitch from '@/components/form/switch.vue'; import MkInput from '@/components/form/input.vue'; -import * as os from '@/os'; -export default defineComponent({ - components: { - XContainer, MkSwitch, MkInput - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - }; - }, - - created() { - if (this.value.name == null) this.value.name = ''; - }, +withDefaults(defineProps<{ + value: any +}>(), { + value: { + name: '' + } }); </script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue index b25ac38d51..1e269ae58c 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.text-input.vue @@ -11,31 +11,17 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent } from 'vue'; +import { } from 'vue'; import XContainer from '../page-editor.container.vue'; import MkInput from '@/components/form/input.vue'; -import * as os from '@/os'; -export default defineComponent({ - components: { - XContainer, MkInput - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - }; - }, - - created() { - if (this.value.name == null) this.value.name = ''; - }, +withDefaults(defineProps<{ + value: any +}>(), { + value: { + name: '' + } }); </script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.text.vue b/packages/client/src/pages/page-editor/els/page-editor.el.text.vue index f23a8ded90..e0ebe68dda 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.text.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.text.vue @@ -9,31 +9,17 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent } from 'vue'; +import { } from 'vue'; import XContainer from '../page-editor.container.vue'; -import * as os from '@/os'; -export default defineComponent({ - components: { - XContainer - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - }; - }, - - created() { - if (this.value.text == null) this.value.text = ''; - }, +withDefaults(defineProps<{ + value: any +}>(), { + value: { + text: '' + } }); </script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue b/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue index f61f0cb1b7..1bb4aaa543 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.textarea-input.vue @@ -11,32 +11,18 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent } from 'vue'; +import { } from 'vue'; import XContainer from '../page-editor.container.vue'; import MkTextarea from '@/components/form/textarea.vue'; import MkInput from '@/components/form/input.vue'; -import * as os from '@/os'; -export default defineComponent({ - components: { - XContainer, MkTextarea, MkInput - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - }; - }, - - created() { - if (this.value.name == null) this.value.name = ''; - }, +withDefaults(defineProps<{ + value: any +}>(), { + value: { + name: '' + } }); </script> diff --git a/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue b/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue index bbabe28488..dca7de8df9 100644 --- a/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue +++ b/packages/client/src/pages/page-editor/els/page-editor.el.textarea.vue @@ -9,31 +9,17 @@ </XContainer> </template> -<script lang="ts"> +<script lang="ts" setup> /* eslint-disable vue/no-mutating-props */ -import { defineComponent } from 'vue'; +import { } from 'vue'; import XContainer from '../page-editor.container.vue'; -import * as os from '@/os'; -export default defineComponent({ - components: { - XContainer - }, - - props: { - value: { - required: true - }, - }, - - data() { - return { - }; - }, - - created() { - if (this.value.text == null) this.value.text = ''; - }, +withDefaults(defineProps<{ + value: any +}>(), { + value: { + text: '' + } }); </script> diff --git a/packages/client/src/pages/page-editor/page-editor.vue b/packages/client/src/pages/page-editor/page-editor.vue index 9566592618..aaa61e6e36 100644 --- a/packages/client/src/pages/page-editor/page-editor.vue +++ b/packages/client/src/pages/page-editor/page-editor.vue @@ -1,85 +1,88 @@ <template> -<MkSpacer :content-max="700"> - <div class="jqqmcavi"> - <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="fas fa-external-link-square-alt"></i> {{ $ts._pages.viewPage }}</MkButton> - <MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> - <MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="fas fa-copy"></i> {{ $ts.duplicate }}</MkButton> - <MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton> - </div> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div class="jqqmcavi"> + <MkButton v-if="pageId" class="button" inline link :to="`/@${ author.username }/pages/${ currentName }`"><i class="fas fa-external-link-square-alt"></i> {{ $ts._pages.viewPage }}</MkButton> + <MkButton v-if="!readonly" inline primary class="button" @click="save"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton> + <MkButton v-if="pageId" inline class="button" @click="duplicate"><i class="fas fa-copy"></i> {{ $ts.duplicate }}</MkButton> + <MkButton v-if="pageId && !readonly" inline class="button" danger @click="del"><i class="fas fa-trash-alt"></i> {{ $ts.delete }}</MkButton> + </div> - <div v-if="tab === 'settings'"> - <div class="_formRoot"> - <MkInput v-model="title" class="_formBlock"> - <template #label>{{ $ts._pages.title }}</template> - </MkInput> + <div v-if="tab === 'settings'"> + <div class="_formRoot"> + <MkInput v-model="title" class="_formBlock"> + <template #label>{{ $ts._pages.title }}</template> + </MkInput> - <MkInput v-model="summary" class="_formBlock"> - <template #label>{{ $ts._pages.summary }}</template> - </MkInput> + <MkInput v-model="summary" class="_formBlock"> + <template #label>{{ $ts._pages.summary }}</template> + </MkInput> - <MkInput v-model="name" class="_formBlock"> - <template #prefix>{{ url }}/@{{ author.username }}/pages/</template> - <template #label>{{ $ts._pages.url }}</template> - </MkInput> + <MkInput v-model="name" class="_formBlock"> + <template #prefix>{{ url }}/@{{ author.username }}/pages/</template> + <template #label>{{ $ts._pages.url }}</template> + </MkInput> - <MkSwitch v-model="alignCenter" class="_formBlock">{{ $ts._pages.alignCenter }}</MkSwitch> + <MkSwitch v-model="alignCenter" class="_formBlock">{{ $ts._pages.alignCenter }}</MkSwitch> - <MkSelect v-model="font" class="_formBlock"> - <template #label>{{ $ts._pages.font }}</template> - <option value="serif">{{ $ts._pages.fontSerif }}</option> - <option value="sans-serif">{{ $ts._pages.fontSansSerif }}</option> - </MkSelect> + <MkSelect v-model="font" class="_formBlock"> + <template #label>{{ $ts._pages.font }}</template> + <option value="serif">{{ $ts._pages.fontSerif }}</option> + <option value="sans-serif">{{ $ts._pages.fontSansSerif }}</option> + </MkSelect> - <MkSwitch v-model="hideTitleWhenPinned" class="_formBlock">{{ $ts._pages.hideTitleWhenPinned }}</MkSwitch> + <MkSwitch v-model="hideTitleWhenPinned" class="_formBlock">{{ $ts._pages.hideTitleWhenPinned }}</MkSwitch> - <div class="eyeCatch"> - <MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="fas fa-plus"></i> {{ $ts._pages.eyeCatchingImageSet }}</MkButton> - <div v-else-if="eyeCatchingImage"> - <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/> - <MkButton v-if="!readonly" @click="removeEyeCatchingImage()"><i class="fas fa-trash-alt"></i> {{ $ts._pages.eyeCatchingImageRemove }}</MkButton> + <div class="eyeCatch"> + <MkButton v-if="eyeCatchingImageId == null && !readonly" @click="setEyeCatchingImage"><i class="fas fa-plus"></i> {{ $ts._pages.eyeCatchingImageSet }}</MkButton> + <div v-else-if="eyeCatchingImage"> + <img :src="eyeCatchingImage.url" :alt="eyeCatchingImage.name" style="max-width: 100%;"/> + <MkButton v-if="!readonly" @click="removeEyeCatchingImage()"><i class="fas fa-trash-alt"></i> {{ $ts._pages.eyeCatchingImageRemove }}</MkButton> + </div> </div> </div> </div> - </div> - <div v-else-if="tab === 'contents'"> - <div> - <XBlocks v-model="content" class="content" :hpml="hpml"/> + <div v-else-if="tab === 'contents'"> + <div> + <XBlocks v-model="content" class="content" :hpml="hpml"/> - <MkButton v-if="!readonly" @click="add()"><i class="fas fa-plus"></i></MkButton> + <MkButton v-if="!readonly" @click="add()"><i class="fas fa-plus"></i></MkButton> + </div> </div> - </div> - <div v-else-if="tab === 'variables'"> - <div class="qmuvgica"> - <XDraggable v-show="variables.length > 0" v-model="variables" tag="div" class="variables" item-key="name" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5"> - <template #item="{element}"> - <XVariable - :modelValue="element" - :removable="true" - :hpml="hpml" - :name="element.name" - :title="element.name" - :draggable="true" - @remove="() => removeVariable(element)" - /> - </template> - </XDraggable> + <div v-else-if="tab === 'variables'"> + <div class="qmuvgica"> + <XDraggable v-show="variables.length > 0" v-model="variables" tag="div" class="variables" item-key="name" handle=".drag-handle" :group="{ name: 'variables' }" animation="150" swap-threshold="0.5"> + <template #item="{element}"> + <XVariable + :model-value="element" + :removable="true" + :hpml="hpml" + :name="element.name" + :title="element.name" + :draggable="true" + @remove="() => removeVariable(element)" + /> + </template> + </XDraggable> - <MkButton v-if="!readonly" class="add" @click="addVariable()"><i class="fas fa-plus"></i></MkButton> + <MkButton v-if="!readonly" class="add" @click="addVariable()"><i class="fas fa-plus"></i></MkButton> + </div> </div> - </div> - <div v-else-if="tab === 'script'"> - <div> - <MkTextarea v-model="script" class="_code"/> + <div v-else-if="tab === 'script'"> + <div> + <MkTextarea v-model="script" class="_code"/> + </div> </div> - </div> -</MkSpacer> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -import { defineComponent, defineAsyncComponent, computed } from 'vue'; +<script lang="ts" setup> +import { defineAsyncComponent, computed, provide, watch } from 'vue'; import 'prismjs'; import { highlight, languages } from 'prismjs/components/prism-core'; import 'prismjs/components/prism-clike'; @@ -90,7 +93,6 @@ import { v4 as uuid } from 'uuid'; import XVariable from './page-editor.script-block.vue'; import XBlocks from './page-editor.blocks.vue'; import MkTextarea from '@/components/form/textarea.vue'; -import MkContainer from '@/components/ui/container.vue'; import MkButton from '@/components/ui/button.vue'; import MkSelect from '@/components/form/select.vue'; import MkSwitch from '@/components/form/switch.vue'; @@ -101,367 +103,343 @@ import { url } from '@/config'; import { collectPageVars } from '@/scripts/collect-page-vars'; import * as os from '@/os'; import { selectFile } from '@/scripts/select-file'; -import * as symbols from '@/symbols'; +import { mainRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; +const XDraggable = defineAsyncComponent(() => import('vuedraggable').then(x => x.default)); -export default defineComponent({ - components: { - XDraggable: defineAsyncComponent(() => import('vuedraggable').then(x => x.default)), - XVariable, XBlocks, MkTextarea, MkContainer, MkButton, MkSelect, MkSwitch, MkInput, - }, +const props = defineProps<{ + initPageId?: string; + initPageName?: string; + initUser?: string; +}>(); - provide() { - return { - readonly: this.readonly, - getScriptBlockList: this.getScriptBlockList, - getPageBlockList: this.getPageBlockList - }; - }, +let tab = $ref('settings'); +let author = $ref($i); +let readonly = $ref(false); +let page = $ref(null); +let pageId = $ref(null); +let currentName = $ref(null); +let title = $ref(''); +let summary = $ref(null); +let name = $ref(Date.now().toString()); +let eyeCatchingImage = $ref(null); +let eyeCatchingImageId = $ref(null); +let font = $ref('sans-serif'); +let content = $ref([]); +let alignCenter = $ref(false); +let hideTitleWhenPinned = $ref(false); +let variables = $ref([]); +let hpml = $ref(null); +let script = $ref(''); - props: { - initPageId: { - type: String, - required: false - }, - initPageName: { - type: String, - required: false - }, - initUser: { - type: String, - required: false - }, - }, +provide('readonly', readonly); +provide('getScriptBlockList', getScriptBlockList); +provide('getPageBlockList', getPageBlockList); - data() { - return { - [symbols.PAGE_INFO]: computed(() => { - let title = this.$ts._pages.newPage; - if (this.initPageId) { - title = this.$ts._pages.editPage; - } - else if (this.initPageName && this.initUser) { - title = this.$ts._pages.readPage; - } - return { - title: title, - icon: 'fas fa-pencil-alt', - bg: 'var(--bg)', - tabs: [{ - active: this.tab === 'settings', - title: this.$ts._pages.pageSetting, - icon: 'fas fa-cog', - onClick: () => { this.tab = 'settings'; }, - }, { - active: this.tab === 'contents', - title: this.$ts._pages.contents, - icon: 'fas fa-sticky-note', - onClick: () => { this.tab = 'contents'; }, - }, { - active: this.tab === 'variables', - title: this.$ts._pages.variables, - icon: 'fas fa-magic', - onClick: () => { this.tab = 'variables'; }, - }, { - active: this.tab === 'script', - title: this.$ts.script, - icon: 'fas fa-code', - onClick: () => { this.tab = 'script'; }, - }], - }; - }), - tab: 'settings', - author: this.$i, - readonly: false, - page: null, - pageId: null, - currentName: null, - title: '', - summary: null, - name: Date.now().toString(), - eyeCatchingImage: null, - eyeCatchingImageId: null, - font: 'sans-serif', - content: [], - alignCenter: false, - hideTitleWhenPinned: false, - variables: [], - hpml: null, - script: '', - url, - }; - }, +watch($$(eyeCatchingImageId), async () => { + if (eyeCatchingImageId == null) { + eyeCatchingImage = null; + } else { + eyeCatchingImage = await os.api('drive/files/show', { + fileId: eyeCatchingImageId, + }); + } +}); + +function getSaveOptions() { + return { + title: title.trim(), + name: name.trim(), + summary: summary, + font: font, + script: script, + hideTitleWhenPinned: hideTitleWhenPinned, + alignCenter: alignCenter, + content: content, + variables: variables, + eyeCatchingImageId: eyeCatchingImageId, + }; +} + +function save() { + const options = getSaveOptions(); - watch: { - async eyeCatchingImageId() { - if (this.eyeCatchingImageId == null) { - this.eyeCatchingImage = null; - } else { - this.eyeCatchingImage = await os.api('drive/files/show', { - fileId: this.eyeCatchingImageId, + const onError = err => { + if (err.id === '3d81ceae-475f-4600-b2a8-2bc116157532') { + if (err.info.param === 'name') { + os.alert({ + type: 'error', + title: i18n.ts._pages.invalidNameTitle, + text: i18n.ts._pages.invalidNameText, }); } - }, - }, + } else if (err.code === 'NAME_ALREADY_EXISTS') { + os.alert({ + type: 'error', + text: i18n.ts._pages.nameAlreadyExists, + }); + } + }; - async created() { - this.hpml = new HpmlTypeChecker(); + if (pageId) { + options.pageId = pageId; + os.api('pages/update', options) + .then(page => { + currentName = name.trim(); + os.alert({ + type: 'success', + text: i18n.ts._pages.updated, + }); + }).catch(onError); + } else { + os.api('pages/create', options) + .then(created => { + pageId = created.id; + currentName = name.trim(); + os.alert({ + type: 'success', + text: i18n.ts._pages.created, + }); + mainRouter.push(`/pages/edit/${pageId}`); + }).catch(onError); + } +} - this.$watch('variables', () => { - this.hpml.variables = this.variables; - }, { deep: true }); +function del() { + os.confirm({ + type: 'warning', + text: i18n.t('removeAreYouSure', { x: title.trim() }), + }).then(({ canceled }) => { + if (canceled) return; + os.api('pages/delete', { + pageId: pageId, + }).then(() => { + os.alert({ + type: 'success', + text: i18n.ts._pages.deleted, + }); + mainRouter.push('/pages'); + }); + }); +} - this.$watch('content', () => { - this.hpml.pageVars = collectPageVars(this.content); - }, { deep: true }); +function duplicate() { + title = title + ' - copy'; + name = name + '-copy'; + os.api('pages/create', getSaveOptions()).then(created => { + pageId = created.id; + currentName = name.trim(); + os.alert({ + type: 'success', + text: i18n.ts._pages.created, + }); + mainRouter.push(`/pages/edit/${pageId}`); + }); +} - if (this.initPageId) { - this.page = await os.api('pages/show', { - pageId: this.initPageId, - }); - } else if (this.initPageName && this.initUser) { - this.page = await os.api('pages/show', { - name: this.initPageName, - username: this.initUser, - }); - this.readonly = true; - } +async function add() { + const { canceled, result: type } = await os.select({ + type: null, + title: i18n.ts._pages.chooseBlock, + groupedItems: getPageBlockList(), + }); + if (canceled) return; - if (this.page) { - this.author = this.page.user; - this.pageId = this.page.id; - this.title = this.page.title; - this.name = this.page.name; - this.currentName = this.page.name; - this.summary = this.page.summary; - this.font = this.page.font; - this.script = this.page.script; - this.hideTitleWhenPinned = this.page.hideTitleWhenPinned; - this.alignCenter = this.page.alignCenter; - this.content = this.page.content; - this.variables = this.page.variables; - this.eyeCatchingImageId = this.page.eyeCatchingImageId; - } else { - const id = uuid(); - this.content = [{ - id, - type: 'text', - text: 'Hello World!' - }]; - } - }, + const id = uuid(); + content.push({ id, type }); +} - methods: { - getSaveOptions() { - return { - title: this.title.trim(), - name: this.name.trim(), - summary: this.summary, - font: this.font, - script: this.script, - hideTitleWhenPinned: this.hideTitleWhenPinned, - alignCenter: this.alignCenter, - content: this.content, - variables: this.variables, - eyeCatchingImageId: this.eyeCatchingImageId, - }; - }, +async function addVariable() { + let { canceled, result: name } = await os.inputText({ + title: i18n.ts._pages.enterVariableName, + }); + if (canceled) return; - save() { - const options = this.getSaveOptions(); + name = name.trim(); - const onError = err => { - if (err.id == '3d81ceae-475f-4600-b2a8-2bc116157532') { - if (err.info.param == 'name') { - os.alert({ - type: 'error', - title: this.$ts._pages.invalidNameTitle, - text: this.$ts._pages.invalidNameText - }); - } - } else if (err.code == 'NAME_ALREADY_EXISTS') { - os.alert({ - type: 'error', - text: this.$ts._pages.nameAlreadyExists - }); - } - }; + if (hpml.isUsedName(name)) { + os.alert({ + type: 'error', + text: i18n.ts._pages.variableNameIsAlreadyUsed, + }); + return; + } - if (this.pageId) { - options.pageId = this.pageId; - os.api('pages/update', options) - .then(page => { - this.currentName = this.name.trim(); - os.alert({ - type: 'success', - text: this.$ts._pages.updated - }); - }).catch(onError); - } else { - os.api('pages/create', options) - .then(page => { - this.pageId = page.id; - this.currentName = this.name.trim(); - os.alert({ - type: 'success', - text: this.$ts._pages.created - }); - this.$router.push(`/pages/edit/${this.pageId}`); - }).catch(onError); - } - }, + const id = uuid(); + variables.push({ id, name, type: null }); +} - del() { - os.confirm({ - type: 'warning', - text: this.$t('removeAreYouSure', { x: this.title.trim() }), - }).then(({ canceled }) => { - if (canceled) return; - os.api('pages/delete', { - pageId: this.pageId, - }).then(() => { - os.alert({ - type: 'success', - text: this.$ts._pages.deleted - }); - this.$router.push(`/pages`); - }); - }); - }, +function removeVariable(v) { + variables = variables.filter(x => x.name !== v.name); +} - duplicate() { - this.title = this.title + ' - copy'; - this.name = this.name + '-copy'; - os.api('pages/create', this.getSaveOptions()).then(page => { - this.pageId = page.id; - this.currentName = this.name.trim(); - os.alert({ - type: 'success', - text: this.$ts._pages.created - }); - this.$router.push(`/pages/edit/${this.pageId}`); - }); - }, +function getPageBlockList() { + return [{ + label: i18n.ts._pages.contentBlocks, + items: [ + { value: 'section', text: i18n.ts._pages.blocks.section }, + { value: 'text', text: i18n.ts._pages.blocks.text }, + { value: 'image', text: i18n.ts._pages.blocks.image }, + { value: 'textarea', text: i18n.ts._pages.blocks.textarea }, + { value: 'note', text: i18n.ts._pages.blocks.note }, + { value: 'canvas', text: i18n.ts._pages.blocks.canvas }, + ], + }, { + label: i18n.ts._pages.inputBlocks, + items: [ + { value: 'button', text: i18n.ts._pages.blocks.button }, + { value: 'radioButton', text: i18n.ts._pages.blocks.radioButton }, + { value: 'textInput', text: i18n.ts._pages.blocks.textInput }, + { value: 'textareaInput', text: i18n.ts._pages.blocks.textareaInput }, + { value: 'numberInput', text: i18n.ts._pages.blocks.numberInput }, + { value: 'switch', text: i18n.ts._pages.blocks.switch }, + { value: 'counter', text: i18n.ts._pages.blocks.counter }, + ], + }, { + label: i18n.ts._pages.specialBlocks, + items: [ + { value: 'if', text: i18n.ts._pages.blocks.if }, + { value: 'post', text: i18n.ts._pages.blocks.post }, + ], + }]; +} - async add() { - const { canceled, result: type } = await os.select({ - type: null, - title: this.$ts._pages.chooseBlock, - groupedItems: this.getPageBlockList() - }); - if (canceled) return; +function getScriptBlockList(type: string = null) { + const list = []; - const id = uuid(); - this.content.push({ id, type }); - }, + const blocks = blockDefs.filter(block => type == null || block.out == null || block.out === type || typeof block.out === 'number'); - async addVariable() { - let { canceled, result: name } = await os.inputText({ - title: this.$ts._pages.enterVariableName, + for (const block of blocks) { + const category = list.find(x => x.category === block.category); + if (category) { + category.items.push({ + value: block.type, + text: i18n.t(`_pages.script.blocks.${block.type}`), }); - if (canceled) return; + } else { + list.push({ + category: block.category, + label: i18n.t(`_pages.script.categories.${block.category}`), + items: [{ + value: block.type, + text: i18n.t(`_pages.script.blocks.${block.type}`), + }], + }); + } + } - name = name.trim(); + const userFns = variables.filter(x => x.type === 'fn'); + if (userFns.length > 0) { + list.unshift({ + label: i18n.t('_pages.script.categories.fn'), + items: userFns.map(v => ({ + value: 'fn:' + v.name, + text: v.name, + })), + }); + } - if (this.hpml.isUsedName(name)) { - os.alert({ - type: 'error', - text: this.$ts._pages.variableNameIsAlreadyUsed - }); - return; - } + return list; +} - const id = uuid(); - this.variables.push({ id, name, type: null }); - }, +function setEyeCatchingImage(img) { + selectFile(img.currentTarget ?? img.target, null).then(file => { + eyeCatchingImageId = file.id; + }); +} - removeVariable(v) { - this.variables = this.variables.filter(x => x.name !== v.name); - }, +function removeEyeCatchingImage() { + eyeCatchingImageId = null; +} - getPageBlockList() { - return [{ - label: this.$ts._pages.contentBlocks, - items: [ - { value: 'section', text: this.$ts._pages.blocks.section }, - { value: 'text', text: this.$ts._pages.blocks.text }, - { value: 'image', text: this.$ts._pages.blocks.image }, - { value: 'textarea', text: this.$ts._pages.blocks.textarea }, - { value: 'note', text: this.$ts._pages.blocks.note }, - { value: 'canvas', text: this.$ts._pages.blocks.canvas }, - ] - }, { - label: this.$ts._pages.inputBlocks, - items: [ - { value: 'button', text: this.$ts._pages.blocks.button }, - { value: 'radioButton', text: this.$ts._pages.blocks.radioButton }, - { value: 'textInput', text: this.$ts._pages.blocks.textInput }, - { value: 'textareaInput', text: this.$ts._pages.blocks.textareaInput }, - { value: 'numberInput', text: this.$ts._pages.blocks.numberInput }, - { value: 'switch', text: this.$ts._pages.blocks.switch }, - { value: 'counter', text: this.$ts._pages.blocks.counter } - ] - }, { - label: this.$ts._pages.specialBlocks, - items: [ - { value: 'if', text: this.$ts._pages.blocks.if }, - { value: 'post', text: this.$ts._pages.blocks.post } - ] - }]; - }, +function highlighter(code) { + return highlight(code, languages.js, 'javascript'); +} - getScriptBlockList(type: string = null) { - const list = []; +async function init() { + hpml = new HpmlTypeChecker(); - const blocks = blockDefs.filter(block => type === null || block.out === null || block.out === type || typeof block.out === 'number'); + watch($$(variables), () => { + hpml.variables = variables; + }, { deep: true }); - for (const block of blocks) { - const category = list.find(x => x.category === block.category); - if (category) { - category.items.push({ - value: block.type, - text: this.$t(`_pages.script.blocks.${block.type}`) - }); - } else { - list.push({ - category: block.category, - label: this.$t(`_pages.script.categories.${block.category}`), - items: [{ - value: block.type, - text: this.$t(`_pages.script.blocks.${block.type}`) - }] - }); - } - } + watch($$(content), () => { + hpml.pageVars = collectPageVars(content); + }, { deep: true }); - const userFns = this.variables.filter(x => x.type === 'fn'); - if (userFns.length > 0) { - list.unshift({ - label: this.$t(`_pages.script.categories.fn`), - items: userFns.map(v => ({ - value: 'fn:' + v.name, - text: v.name - })) - }); - } + if (props.initPageId) { + page = await os.api('pages/show', { + pageId: props.initPageId, + }); + } else if (props.initPageName && props.initUser) { + page = await os.api('pages/show', { + name: props.initPageName, + username: props.initUser, + }); + readonly = true; + } - return list; - }, + if (page) { + author = page.user; + pageId = page.id; + title = page.title; + name = page.name; + currentName = page.name; + summary = page.summary; + font = page.font; + script = page.script; + hideTitleWhenPinned = page.hideTitleWhenPinned; + alignCenter = page.alignCenter; + content = page.content; + variables = page.variables; + eyeCatchingImageId = page.eyeCatchingImageId; + } else { + const id = uuid(); + content = [{ + id, + type: 'text', + text: 'Hello World!', + }]; + } +} - setEyeCatchingImage(e) { - selectFile(e.currentTarget ?? e.target, null).then(file => { - this.eyeCatchingImageId = file.id; - }); - }, +init(); - removeEyeCatchingImage() { - this.eyeCatchingImageId = null; - }, +const headerActions = $computed(() => []); - highlighter(code) { - return highlight(code, languages.js, 'javascript'); - }, +const headerTabs = $computed(() => [{ + key: 'settings', + title: i18n.ts._pages.pageSetting, + icon: 'fas fa-cog', +}, { + key: 'contents', + title: i18n.ts._pages.contents, + icon: 'fas fa-sticky-note', +}, { + key: 'variables', + title: i18n.ts._pages.variables, + icon: 'fas fa-magic', +}, { + key: 'script', + title: i18n.ts.script, + icon: 'fas fa-code', +}]); + +definePageMetadata(computed(() => { + let title = i18n.ts._pages.newPage; + if (props.initPageId) { + title = i18n.ts._pages.editPage; } -}); + else if (props.initPageName && props.initUser) { + title = i18n.ts._pages.readPage; + } + return { + title: title, + icon: 'fas fa-pencil-alt', + }; +})); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/page.vue b/packages/client/src/pages/page.vue index 5bca971438..c60b7069e9 100644 --- a/packages/client/src/pages/page.vue +++ b/packages/client/src/pages/page.vue @@ -1,193 +1,166 @@ <template> -<MkSpacer :content-max="700"> - <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> - <div v-if="page" :key="page.id" v-size="{ max: [450] }" class="xcukqgmh"> - <div class="_block main"> - <!-- +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <transition :name="$store.state.animation ? 'fade' : ''" mode="out-in"> + <div v-if="page" :key="page.id" v-size="{ max: [450] }" class="xcukqgmh"> + <div class="_block main"> + <!-- <div class="header"> <h1>{{ page.title }}</h1> </div> --> - <div class="banner"> - <img v-if="page.eyeCatchingImageId" :src="page.eyeCatchingImage.url"/> - </div> - <div class="content"> - <XPage :page="page"/> - </div> - <div class="actions"> - <div class="like"> - <MkButton v-if="page.isLiked" v-tooltip="$ts._pages.unlike" class="button" primary @click="unlike()"><i class="fas fa-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> - <MkButton v-else v-tooltip="$ts._pages.like" class="button" @click="like()"><i class="far fa-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> + <div class="banner"> + <img v-if="page.eyeCatchingImageId" :src="page.eyeCatchingImage.url"/> </div> - <div class="other"> - <button v-tooltip="$ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="fas fa-retweet fa-fw"></i></button> - <button v-tooltip="$ts.share" v-click-anime class="_button" @click="share"><i class="fas fa-share-alt fa-fw"></i></button> + <div class="content"> + <XPage :page="page"/> </div> - </div> - <div class="user"> - <MkAvatar :user="page.user" class="avatar"/> - <div class="name"> - <MkUserName :user="page.user" style="display: block;"/> - <MkAcct :user="page.user"/> + <div class="actions"> + <div class="like"> + <MkButton v-if="page.isLiked" v-tooltip="$ts._pages.unlike" class="button" primary @click="unlike()"><i class="fas fa-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> + <MkButton v-else v-tooltip="$ts._pages.like" class="button" @click="like()"><i class="far fa-heart"></i><span v-if="page.likedCount > 0" class="count">{{ page.likedCount }}</span></MkButton> + </div> + <div class="other"> + <button v-tooltip="$ts.shareWithNote" v-click-anime class="_button" @click="shareWithNote"><i class="fas fa-retweet fa-fw"></i></button> + <button v-tooltip="$ts.share" v-click-anime class="_button" @click="share"><i class="fas fa-share-alt fa-fw"></i></button> + </div> + </div> + <div class="user"> + <MkAvatar :user="page.user" class="avatar"/> + <div class="name"> + <MkUserName :user="page.user" style="display: block;"/> + <MkAcct :user="page.user"/> + </div> + <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> + </div> + <div class="links"> + <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ $ts._pages.viewSource }}</MkA> + <template v-if="$i && $i.id === page.userId"> + <MkA :to="`/pages/edit/${page.id}`" class="link">{{ $ts._pages.editThisPage }}</MkA> + <button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ $ts.unpin }}</button> + <button v-else class="link _textButton" @click="pin(true)">{{ $ts.pin }}</button> + </template> </div> - <MkFollowButton v-if="!$i || $i.id != page.user.id" :user="page.user" :inline="true" :transparent="false" :full="true" large class="koudoku"/> </div> - <div class="links"> - <MkA :to="`/@${username}/pages/${pageName}/view-source`" class="link">{{ $ts._pages.viewSource }}</MkA> - <template v-if="$i && $i.id === page.userId"> - <MkA :to="`/pages/edit/${page.id}`" class="link">{{ $ts._pages.editThisPage }}</MkA> - <button v-if="$i.pinnedPageId === page.id" class="link _textButton" @click="pin(false)">{{ $ts.unpin }}</button> - <button v-else class="link _textButton" @click="pin(true)">{{ $ts.pin }}</button> - </template> + <div class="footer"> + <div><i class="far fa-clock"></i> {{ $ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> + <div v-if="page.createdAt != page.updatedAt"><i class="far fa-clock"></i> {{ $ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> </div> + <MkAd :prefer="['horizontal', 'horizontal-big']"/> + <MkContainer :max-height="300" :foldable="true" class="other"> + <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template> + <MkPagination v-slot="{items}" :pagination="otherPostsPagination"> + <MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_gap"/> + </MkPagination> + </MkContainer> </div> - <div class="footer"> - <div><i class="far fa-clock"></i> {{ $ts.createdAt }}: <MkTime :time="page.createdAt" mode="detail"/></div> - <div v-if="page.createdAt != page.updatedAt"><i class="far fa-clock"></i> {{ $ts.updatedAt }}: <MkTime :time="page.updatedAt" mode="detail"/></div> - </div> - <MkAd :prefer="['horizontal', 'horizontal-big']"/> - <MkContainer :max-height="300" :foldable="true" class="other"> - <template #header><i class="fas fa-clock"></i> {{ $ts.recentPosts }}</template> - <MkPagination v-slot="{items}" :pagination="otherPostsPagination"> - <MkPagePreview v-for="page in items" :key="page.id" :page="page" class="_gap"/> - </MkPagination> - </MkContainer> - </div> - <MkError v-else-if="error" @retry="fetch()"/> - <MkLoading v-else/> - </transition> -</MkSpacer> + <MkError v-else-if="error" @retry="fetchPage()"/> + <MkLoading v-else/> + </transition> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed, watch } from 'vue'; import XPage from '@/components/page/page.vue'; import MkButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { url } from '@/config'; import MkFollowButton from '@/components/follow-button.vue'; import MkContainer from '@/components/ui/container.vue'; import MkPagination from '@/components/ui/pagination.vue'; import MkPagePreview from '@/components/page-preview.vue'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; -export default defineComponent({ - components: { - XPage, - MkButton, - MkFollowButton, - MkContainer, - MkPagination, - MkPagePreview, - }, +const props = defineProps<{ + pageName: string; + username: string; +}>(); - props: { - pageName: { - type: String, - required: true - }, - username: { - type: String, - required: true - }, - }, +let page = $ref(null); +let error = $ref(null); +const otherPostsPagination = { + endpoint: 'users/pages' as const, + limit: 6, + params: computed(() => ({ + userId: page.user.id, + })), +}; +const path = $computed(() => props.username + '/' + props.pageName); - data() { - return { - [symbols.PAGE_INFO]: computed(() => this.page ? { - title: computed(() => this.page.title || this.page.name), - avatar: this.page.user, - path: `/@${this.page.user.username}/pages/${this.page.name}`, - share: { - title: this.page.title || this.page.name, - text: this.page.summary, - }, - } : null), - page: null, - error: null, - otherPostsPagination: { - endpoint: 'users/pages' as const, - limit: 6, - params: computed(() => ({ - userId: this.page.user.id - })), - }, - }; - }, +function fetchPage() { + page = null; + os.api('pages/show', { + name: props.pageName, + username: props.username, + }).then(_page => { + page = _page; + }).catch(err => { + error = err; + }); +} - computed: { - path(): string { - return this.username + '/' + this.pageName; - } - }, +function share() { + navigator.share({ + title: page.title ?? page.name, + text: page.summary, + url: `${url}/@${page.user.username}/pages/${page.name}`, + }); +} - watch: { - path() { - this.fetch(); - } - }, +function shareWithNote() { + os.post({ + initialText: `${page.title || page.name} ${url}/@${page.user.username}/pages/${page.name}`, + }); +} - created() { - this.fetch(); - }, +function like() { + os.apiWithDialog('pages/like', { + pageId: page.id, + }).then(() => { + page.isLiked = true; + page.likedCount++; + }); +} - methods: { - fetch() { - this.page = null; - os.api('pages/show', { - name: this.pageName, - username: this.username, - }).then(page => { - this.page = page; - }).catch(err => { - this.error = err; - }); - }, +async function unlike() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.unlikeConfirm, + }); + if (confirm.canceled) return; + os.apiWithDialog('pages/unlike', { + pageId: page.id, + }).then(() => { + page.isLiked = false; + page.likedCount--; + }); +} - share() { - navigator.share({ - title: this.page.title || this.page.name, - text: this.page.summary, - url: `${url}/@${this.page.user.username}/pages/${this.page.name}` - }); - }, +function pin(pin) { + os.apiWithDialog('i/update', { + pinnedPageId: pin ? page.id : null, + }); +} - shareWithNote() { - os.post({ - initialText: `${this.page.title || this.page.name} ${url}/@${this.page.user.username}/pages/${this.page.name}` - }); - }, +watch(() => path, fetchPage, { immediate: true }); - like() { - os.apiWithDialog('pages/like', { - pageId: this.page.id, - }).then(() => { - this.page.isLiked = true; - this.page.likedCount++; - }); - }, +const headerActions = $computed(() => []); - async unlike() { - const confirm = await os.confirm({ - type: 'warning', - text: this.$ts.unlikeConfirm, - }); - if (confirm.canceled) return; - os.apiWithDialog('pages/unlike', { - pageId: this.page.id, - }).then(() => { - this.page.isLiked = false; - this.page.likedCount--; - }); - }, +const headerTabs = $computed(() => []); - pin(pin) { - os.apiWithDialog('i/update', { - pinnedPageId: pin ? this.page.id : null, - }); - } - } -}); +definePageMetadata(computed(() => page ? { + title: computed(() => page.title || page.name), + avatar: page.user, + path: `/@${page.user.username}/pages/${page.name}`, + share: { + title: page.title || page.name, + text: page.summary, + }, +} : null)); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/pages.vue b/packages/client/src/pages/pages.vue index dcccf7f7c4..62c675e41e 100644 --- a/packages/client/src/pages/pages.vue +++ b/packages/client/src/pages/pages.vue @@ -1,86 +1,83 @@ <template> -<MkSpacer :content-max="700"> - <div v-if="tab === 'featured'" class="rknalgpo"> - <MkPagination v-slot="{items}" :pagination="featuredPagesPagination"> - <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/> - </MkPagination> - </div> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="700"> + <div v-if="tab === 'featured'" class="rknalgpo"> + <MkPagination v-slot="{items}" :pagination="featuredPagesPagination"> + <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/> + </MkPagination> + </div> - <div v-else-if="tab === 'my'" class="rknalgpo my"> - <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton> - <MkPagination v-slot="{items}" :pagination="myPagesPagination"> - <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/> - </MkPagination> - </div> + <div v-else-if="tab === 'my'" class="rknalgpo my"> + <MkButton class="new" @click="create()"><i class="fas fa-plus"></i></MkButton> + <MkPagination v-slot="{items}" :pagination="myPagesPagination"> + <MkPagePreview v-for="page in items" :key="page.id" class="ckltabjg" :page="page"/> + </MkPagination> + </div> - <div v-else-if="tab === 'liked'" class="rknalgpo"> - <MkPagination v-slot="{items}" :pagination="likedPagesPagination"> - <MkPagePreview v-for="like in items" :key="like.page.id" class="ckltabjg" :page="like.page"/> - </MkPagination> - </div> -</MkSpacer> + <div v-else-if="tab === 'liked'" class="rknalgpo"> + <MkPagination v-slot="{items}" :pagination="likedPagesPagination"> + <MkPagePreview v-for="like in items" :key="like.page.id" class="ckltabjg" :page="like.page"/> + </MkPagination> + </div> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed, inject } from 'vue'; import MkPagePreview from '@/components/page-preview.vue'; import MkPagination from '@/components/ui/pagination.vue'; import MkButton from '@/components/ui/button.vue'; -import * as symbols from '@/symbols'; +import { useRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; -export default defineComponent({ - components: { - MkPagePreview, MkPagination, MkButton - }, - data() { - return { - [symbols.PAGE_INFO]: computed(() => ({ - title: this.$ts.pages, - icon: 'fas fa-sticky-note', - bg: 'var(--bg)', - actions: [{ - icon: 'fas fa-plus', - text: this.$ts.create, - handler: this.create, - }], - tabs: [{ - active: this.tab === 'featured', - title: this.$ts._pages.featured, - icon: 'fas fa-fire-alt', - onClick: () => { this.tab = 'featured'; }, - }, { - active: this.tab === 'my', - title: this.$ts._pages.my, - icon: 'fas fa-edit', - onClick: () => { this.tab = 'my'; }, - }, { - active: this.tab === 'liked', - title: this.$ts._pages.liked, - icon: 'fas fa-heart', - onClick: () => { this.tab = 'liked'; }, - },] - })), - tab: 'featured', - featuredPagesPagination: { - endpoint: 'pages/featured' as const, - noPaging: true, - }, - myPagesPagination: { - endpoint: 'i/pages' as const, - limit: 5, - }, - likedPagesPagination: { - endpoint: 'i/page-likes' as const, - limit: 5, - }, - }; - }, - methods: { - create() { - this.$router.push(`/pages/new`); - } - } -}); +const router = useRouter(); + +let tab = $ref('featured'); + +const featuredPagesPagination = { + endpoint: 'pages/featured' as const, + noPaging: true, +}; +const myPagesPagination = { + endpoint: 'i/pages' as const, + limit: 5, +}; +const likedPagesPagination = { + endpoint: 'i/page-likes' as const, + limit: 5, +}; + +function create() { + router.push('/pages/new'); +} + +const headerActions = $computed(() => [{ + icon: 'fas fa-plus', + text: i18n.ts.create, + handler: create, +}]); + +const headerTabs = $computed(() => [{ + key: 'featured', + title: i18n.ts._pages.featured, + icon: 'fas fa-fire-alt', +}, { + key: 'my', + title: i18n.ts._pages.my, + icon: 'fas fa-edit', +}, { + key: 'liked', + title: i18n.ts._pages.liked, + icon: 'fas fa-heart', +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.pages, + icon: 'fas fa-sticky-note', +}))); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/preview.vue b/packages/client/src/pages/preview.vue index 4accac4192..8f211081dd 100644 --- a/packages/client/src/pages/preview.vue +++ b/packages/client/src/pages/preview.vue @@ -7,16 +7,17 @@ <script lang="ts" setup> import { computed } from 'vue'; import MkSample from '@/components/sample.vue'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; -defineExpose({ - [symbols.PAGE_INFO]: computed(() => ({ - title: i18n.ts.preview, - icon: 'fas fa-eye', - bg: 'var(--bg)', - })), -}); +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.ts.preview, + icon: 'fas fa-eye', +}))); </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/reset-password.vue b/packages/client/src/pages/reset-password.vue index b3e2ca8d6f..10c41f2d21 100644 --- a/packages/client/src/pages/reset-password.vue +++ b/packages/client/src/pages/reset-password.vue @@ -1,14 +1,17 @@ <template> -<MkSpacer v-if="token" :content-max="700" :margin-min="16" :margin-max="32"> - <div class="_formRoot"> - <FormInput v-model="password" type="password" class="_formBlock"> - <template #prefix><i class="fas fa-lock"></i></template> - <template #label>{{ i18n.ts.newPassword }}</template> - </FormInput> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer v-if="token" :content-max="700" :margin-min="16" :margin-max="32"> + <div class="_formRoot"> + <FormInput v-model="password" type="password" class="_formBlock"> + <template #prefix><i class="fas fa-lock"></i></template> + <template #label>{{ i18n.ts.newPassword }}</template> + </FormInput> - <FormButton primary class="_formBlock" @click="save">{{ i18n.ts.save }}</FormButton> - </div> -</MkSpacer> + <FormButton primary class="_formBlock" @click="save">{{ i18n.ts.save }}</FormButton> + </div> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> @@ -16,9 +19,9 @@ import { defineAsyncComponent, onMounted } from 'vue'; import FormInput from '@/components/form/input.vue'; import FormButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; -import { router } from '@/router'; +import { mainRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; const props = defineProps<{ token?: string; @@ -31,22 +34,23 @@ async function save() { token: props.token, password: password, }); - router.push('/'); + mainRouter.push('/'); } onMounted(() => { if (props.token == null) { os.popup(defineAsyncComponent(() => import('@/components/forgot-password.vue')), {}, {}, 'closed'); - router.push('/'); + mainRouter.push('/'); } }); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.resetPassword, - icon: 'fas fa-lock', - bg: 'var(--bg)', - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.resetPassword, + icon: 'fas fa-lock', }); </script> diff --git a/packages/client/src/pages/scratchpad.vue b/packages/client/src/pages/scratchpad.vue index 34a41b81a5..d437601475 100644 --- a/packages/client/src/pages/scratchpad.vue +++ b/packages/client/src/pages/scratchpad.vue @@ -19,7 +19,7 @@ </template> <script lang="ts" setup> -import { defineExpose, ref, watch } from 'vue'; +import { ref, watch } from 'vue'; import 'prismjs'; import { highlight, languages } from 'prismjs/components/prism-core'; import 'prismjs/components/prism-clike'; @@ -32,9 +32,9 @@ import MkContainer from '@/components/ui/container.vue'; import MkButton from '@/components/ui/button.vue'; import { createAiScriptEnv } from '@/scripts/aiscript/api'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { $i } from '@/account'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const code = ref(''); const logs = ref<any[]>([]); @@ -67,7 +67,7 @@ async function run() { logs.value.push({ id: Math.random(), text: value.type === 'str' ? value.value : utils.valToString(value), - print: true + print: true, }); }, log: (type, params) => { @@ -75,11 +75,11 @@ async function run() { case 'end': logs.value.push({ id: Math.random(), text: utils.valToString(params.val, true), - print: false + print: false, }); break; default: break; } - } + }, }); let ast; @@ -88,7 +88,7 @@ async function run() { } catch (error) { os.alert({ type: 'error', - text: 'Syntax error :(' + text: 'Syntax error :(', }); return; } @@ -97,7 +97,7 @@ async function run() { } catch (error: any) { os.alert({ type: 'error', - text: error.message + text: error.message, }); } } @@ -106,11 +106,13 @@ function highlighter(code) { return highlight(code, languages.js, 'javascript'); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.scratchpad, - icon: 'fas fa-terminal', - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.scratchpad, + icon: 'fas fa-terminal', }); </script> diff --git a/packages/client/src/pages/search.vue b/packages/client/src/pages/search.vue index ce2b7035da..404b9e3dbd 100644 --- a/packages/client/src/pages/search.vue +++ b/packages/client/src/pages/search.vue @@ -1,16 +1,17 @@ <template> -<div class="_section"> - <div class="_content"> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> <XNotes ref="notes" :pagination="pagination"/> - </div> -</div> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> import { computed } from 'vue'; import XNotes from '@/components/notes.vue'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const props = defineProps<{ query: string; @@ -23,14 +24,15 @@ const pagination = { params: computed(() => ({ query: props.query, channelId: props.channel, - })) + })), }; -defineExpose({ - [symbols.PAGE_INFO]: computed(() => ({ - title: i18n.t('searchWith', { q: props.query }), - icon: 'fas fa-search', - bg: 'var(--bg)', - })), -}); +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: i18n.t('searchWith', { q: props.query }), + icon: 'fas fa-search', +}))); </script> diff --git a/packages/client/src/pages/settings/2fa.vue b/packages/client/src/pages/settings/2fa.vue index fb3a7a17f3..d72d3e2060 100644 --- a/packages/client/src/pages/settings/2fa.vue +++ b/packages/client/src/pages/settings/2fa.vue @@ -55,7 +55,7 @@ <li>{{ i18n.ts._2fa.step2 }}<br><img :src="twoFactorData.qr"><p>{{ $ts._2fa.step2Url }}<br>{{ twoFactorData.url }}</p></li> <li> {{ i18n.ts._2fa.step3 }}<br> - <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput> + <MkInput v-model="token" type="text" pattern="^[0-9]{6}$" autocomplete="off" :spellcheck="false"><template #label>{{ i18n.ts.token }}</template></MkInput> <MkButton primary @click="submit">{{ i18n.ts.done }}</MkButton> </li> </ol> diff --git a/packages/client/src/pages/settings/account-info.vue b/packages/client/src/pages/settings/account-info.vue index 12142b4dc1..65b6233693 100644 --- a/packages/client/src/pages/settings/account-info.vue +++ b/packages/client/src/pages/settings/account-info.vue @@ -127,30 +127,32 @@ </template> <script lang="ts" setup> -import { defineExpose, onMounted, ref } from 'vue'; +import { onMounted, ref } from 'vue'; import FormSection from '@/components/form/section.vue'; import MkKeyValue from '@/components/key-value.vue'; import * as os from '@/os'; import number from '@/filters/number'; import bytes from '@/filters/bytes'; -import * as symbols from '@/symbols'; import { $i } from '@/account'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const stats = ref<any>({}); onMounted(() => { os.api('users/stats', { - userId: $i!.id + userId: $i!.id, }).then(response => { stats.value = response; }); }); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.accountInfo, - icon: 'fas fa-info-circle' - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.accountInfo, + icon: 'fas fa-info-circle', }); </script> diff --git a/packages/client/src/pages/settings/accounts.vue b/packages/client/src/pages/settings/accounts.vue index 5e75639c55..d1e71c4548 100644 --- a/packages/client/src/pages/settings/accounts.vue +++ b/packages/client/src/pages/settings/accounts.vue @@ -21,13 +21,13 @@ </template> <script lang="ts" setup> -import { defineAsyncComponent, defineExpose, ref } from 'vue'; +import { defineAsyncComponent, ref } from 'vue'; import FormSuspense from '@/components/form/suspense.vue'; import FormButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; -import { getAccounts, addAccount as addAccounts, login, $i } from '@/account'; +import { getAccounts, addAccount as addAccounts, removeAccount as _removeAccount, login, $i } from '@/account'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const storedAccounts = ref<any>(null); const accounts = ref<any>(null); @@ -39,7 +39,7 @@ const init = async () => { console.log(storedAccounts.value); return os.api('users/show', { - userIds: storedAccounts.value.map(x => x.id) + userIds: storedAccounts.value.map(x => x.id), }); }).then(response => { accounts.value = response; @@ -70,6 +70,10 @@ function addAccount(ev) { }], ev.currentTarget ?? ev.target); } +function removeAccount(account) { + _removeAccount(account.id); +} + function addExistingAccount() { os.popup(defineAsyncComponent(() => import('@/components/signin-dialog.vue')), {}, { done: res => { @@ -98,12 +102,13 @@ function switchAccountWithToken(token: string) { login(token); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.accounts, - icon: 'fas fa-users', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.accounts, + icon: 'fas fa-users', }); </script> diff --git a/packages/client/src/pages/settings/api.vue b/packages/client/src/pages/settings/api.vue index e6375763f1..b8a2dedb5a 100644 --- a/packages/client/src/pages/settings/api.vue +++ b/packages/client/src/pages/settings/api.vue @@ -7,12 +7,12 @@ </template> <script lang="ts" setup> -import { defineAsyncComponent, defineExpose, ref } from 'vue'; +import { defineAsyncComponent, ref } from 'vue'; import FormLink from '@/components/form/link.vue'; import FormButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const isDesktop = ref(window.innerWidth >= 1100); @@ -29,17 +29,18 @@ function generateToken() { os.alert({ type: 'success', title: i18n.ts.token, - text: token + text: token, }); }, }, 'closed'); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: 'API', - icon: 'fas fa-key', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'API', + icon: 'fas fa-key', }); </script> diff --git a/packages/client/src/pages/settings/apps.vue b/packages/client/src/pages/settings/apps.vue index 7b0b5548d5..10ecbc795d 100644 --- a/packages/client/src/pages/settings/apps.vue +++ b/packages/client/src/pages/settings/apps.vue @@ -7,7 +7,7 @@ <div>{{ i18n.ts.nothing }}</div> </div> </template> - <template v-slot="{items}"> + <template #default="{items}"> <div v-for="token in items" :key="token.id" class="_panel bfomjevm"> <img v-if="token.iconUrl" class="icon" :src="token.iconUrl" alt=""/> <div class="body"> @@ -38,11 +38,11 @@ </template> <script lang="ts" setup> -import { defineExpose, ref } from 'vue'; +import { ref } from 'vue'; import FormPagination from '@/components/ui/pagination.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const list = ref<any>(null); @@ -50,8 +50,8 @@ const pagination = { endpoint: 'i/apps' as const, limit: 100, params: { - sort: '+lastUsedAt' - } + sort: '+lastUsedAt', + }, }; function revoke(token) { @@ -60,12 +60,13 @@ function revoke(token) { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.installedApps, - icon: 'fas fa-plug', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.installedApps, + icon: 'fas fa-plug', }); </script> diff --git a/packages/client/src/pages/settings/custom-css.vue b/packages/client/src/pages/settings/custom-css.vue index 20db077ceb..d5000d3973 100644 --- a/packages/client/src/pages/settings/custom-css.vue +++ b/packages/client/src/pages/settings/custom-css.vue @@ -9,13 +9,13 @@ </template> <script lang="ts" setup> -import { defineExpose, ref, watch } from 'vue'; +import { ref, watch } from 'vue'; import FormTextarea from '@/components/form/textarea.vue'; import FormInfo from '@/components/ui/info.vue'; import * as os from '@/os'; import { unisonReload } from '@/scripts/unison-reload'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const localCustomCss = ref(localStorage.getItem('customCss') ?? ''); @@ -35,11 +35,12 @@ watch(localCustomCss, async () => { await apply(); }); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.customCss, - icon: 'fas fa-code', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.customCss, + icon: 'fas fa-code', }); </script> diff --git a/packages/client/src/pages/settings/deck.vue b/packages/client/src/pages/settings/deck.vue index 2d868aa0a7..c62928eeb0 100644 --- a/packages/client/src/pages/settings/deck.vue +++ b/packages/client/src/pages/settings/deck.vue @@ -1,9 +1,6 @@ <template> <div class="_formRoot"> - <FormGroup> - <template #label>{{ i18n.ts.defaultNavigationBehaviour }}</template> - <FormSwitch v-model="navWindow">{{ i18n.ts.openInWindow }}</FormSwitch> - </FormGroup> + <FormSwitch v-model="navWindow">{{ i18n.ts.defaultNavigationBehaviour }}: {{ i18n.ts.openInWindow }}</FormSwitch> <FormSwitch v-model="alwaysShowMainColumn" class="_formBlock">{{ i18n.ts._deck.alwaysShowMainColumn }}</FormSwitch> @@ -13,56 +10,31 @@ <option value="center">{{ i18n.ts.center }}</option> </FormRadios> - <FormRadios v-model="columnHeaderHeight" class="_formBlock"> - <template #label>{{ i18n.ts._deck.columnHeaderHeight }}</template> - <option :value="42">{{ i18n.ts.narrow }}</option> - <option :value="45">{{ i18n.ts.medium }}</option> - <option :value="48">{{ i18n.ts.wide }}</option> - </FormRadios> - - <FormInput v-model="columnMargin" type="number" class="_formBlock"> - <template #label>{{ i18n.ts._deck.columnMargin }}</template> - <template #suffix>px</template> - </FormInput> - <FormLink class="_formBlock" @click="setProfile">{{ i18n.ts._deck.profile }}<template #suffix>{{ profile }}</template></FormLink> </div> </template> <script lang="ts" setup> -import { computed, defineExpose, watch } from 'vue'; +import { computed, watch } from 'vue'; import FormSwitch from '@/components/form/switch.vue'; import FormLink from '@/components/form/link.vue'; import FormRadios from '@/components/form/radios.vue'; import FormInput from '@/components/form/input.vue'; -import FormGroup from '@/components/form/group.vue'; import { deckStore } from '@/ui/deck/deck-store'; import * as os from '@/os'; import { unisonReload } from '@/scripts/unison-reload'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const navWindow = computed(deckStore.makeGetterSetter('navWindow')); const alwaysShowMainColumn = computed(deckStore.makeGetterSetter('alwaysShowMainColumn')); const columnAlign = computed(deckStore.makeGetterSetter('columnAlign')); -const columnMargin = computed(deckStore.makeGetterSetter('columnMargin')); -const columnHeaderHeight = computed(deckStore.makeGetterSetter('columnHeaderHeight')); const profile = computed(deckStore.makeGetterSetter('profile')); -watch(navWindow, async () => { - const { canceled } = await os.confirm({ - type: 'info', - text: i18n.ts.reloadToApplySetting, - }); - if (canceled) return; - - unisonReload(); -}); - async function setProfile() { const { canceled, result: name } = await os.inputText({ title: i18n.ts._deck.profile, - allowEmpty: false + allowEmpty: false, }); if (canceled) return; @@ -70,11 +42,12 @@ async function setProfile() { unisonReload(); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.deck, - icon: 'fas fa-columns', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.deck, + icon: 'fas fa-columns', }); </script> diff --git a/packages/client/src/pages/settings/delete-account.vue b/packages/client/src/pages/settings/delete-account.vue index e9f19aaf0b..3c4ea716ce 100644 --- a/packages/client/src/pages/settings/delete-account.vue +++ b/packages/client/src/pages/settings/delete-account.vue @@ -8,13 +8,12 @@ </template> <script lang="ts" setup> -import { defineExpose } from 'vue'; import FormInfo from '@/components/ui/info.vue'; import FormButton from '@/components/ui/button.vue'; import * as os from '@/os'; import { signout } from '@/account'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; async function deleteAccount() { { @@ -27,12 +26,12 @@ async function deleteAccount() { const { canceled, result: password } = await os.inputText({ title: i18n.ts.password, - type: 'password' + type: 'password', }); if (canceled) return; await os.apiWithDialog('i/delete-account', { - password: password + password: password, }); await os.alert({ @@ -42,11 +41,12 @@ async function deleteAccount() { await signout(); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts._accountDelete.accountDelete, - icon: 'fas fa-exclamation-triangle', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._accountDelete.accountDelete, + icon: 'fas fa-exclamation-triangle', }); </script> diff --git a/packages/client/src/pages/settings/drive.vue b/packages/client/src/pages/settings/drive.vue index 09a2537ed5..c8c78f2923 100644 --- a/packages/client/src/pages/settings/drive.vue +++ b/packages/client/src/pages/settings/drive.vue @@ -28,13 +28,23 @@ <template #suffix>{{ uploadFolder ? uploadFolder.name : '-' }}</template> <template #suffixIcon><i class="fas fa-folder-open"></i></template> </FormLink> - <FormSwitch v-model="keepOriginalUploading" class="_formBlock">{{ i18n.ts.keepOriginalUploading }}<template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template></FormSwitch> + <FormSwitch v-model="keepOriginalUploading" class="_formBlock"> + <template #label>{{ i18n.ts.keepOriginalUploading }}</template> + <template #caption>{{ i18n.ts.keepOriginalUploadingDescription }}</template> + </FormSwitch> + <FormSwitch v-model="alwaysMarkNsfw" class="_formBlock" @update:modelValue="saveProfile()"> + <template #label>{{ i18n.ts.alwaysMarkSensitive }}</template> + </FormSwitch> + <FormSwitch v-model="autoSensitive" class="_formBlock" @update:modelValue="saveProfile()"> + <template #label>{{ i18n.ts.enableAutoSensitive }}<span class="_beta">{{ i18n.ts.beta }}</span></template> + <template #caption>{{ i18n.ts.enableAutoSensitiveDescription }}</template> + </FormSwitch> </FormSection> </div> </template> <script lang="ts" setup> -import { computed, defineExpose, ref } from 'vue'; +import { computed, ref } from 'vue'; import tinycolor from 'tinycolor2'; import FormLink from '@/components/form/link.vue'; import FormSwitch from '@/components/form/switch.vue'; @@ -43,15 +53,18 @@ import MkKeyValue from '@/components/key-value.vue'; import FormSplit from '@/components/form/split.vue'; import * as os from '@/os'; import bytes from '@/filters/bytes'; -import * as symbols from '@/symbols'; import { defaultStore } from '@/store'; import MkChart from '@/components/chart.vue'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { $i } from '@/account'; const fetching = ref(true); const usage = ref<any>(null); const capacity = ref<any>(null); const uploadFolder = ref<any>(null); +let alwaysMarkNsfw = $ref($i.alwaysMarkNsfw); +let autoSensitive = $ref($i.autoSensitive); const meterStyle = computed(() => { return { @@ -59,8 +72,8 @@ const meterStyle = computed(() => { background: tinycolor({ h: 180 - (usage.value / capacity.value * 180), s: 0.7, - l: 0.5 - }) + l: 0.5, + }), }; }); @@ -74,7 +87,7 @@ os.api('drive').then(info => { if (defaultStore.state.uploadFolder) { os.api('drive/folders/show', { - folderId: defaultStore.state.uploadFolder + folderId: defaultStore.state.uploadFolder, }).then(response => { uploadFolder.value = response; }); @@ -86,7 +99,7 @@ function chooseUploadFolder() { os.success(); if (defaultStore.state.uploadFolder) { uploadFolder.value = await os.api('drive/folders/show', { - folderId: defaultStore.state.uploadFolder + folderId: defaultStore.state.uploadFolder, }); } else { uploadFolder.value = null; @@ -94,12 +107,20 @@ function chooseUploadFolder() { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.drive, - icon: 'fas fa-cloud', - bg: 'var(--bg)', - } +function saveProfile() { + os.api('i/update', { + alwaysMarkNsfw: !!alwaysMarkNsfw, + autoSensitive: !!autoSensitive, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.drive, + icon: 'fas fa-cloud', }); </script> diff --git a/packages/client/src/pages/settings/email.vue b/packages/client/src/pages/settings/email.vue index 37f14068e2..e575af6d6b 100644 --- a/packages/client/src/pages/settings/email.vue +++ b/packages/client/src/pages/settings/email.vue @@ -40,27 +40,27 @@ </template> <script lang="ts" setup> -import { defineExpose, onMounted, ref, watch } from 'vue'; +import { onMounted, ref, watch } from 'vue'; import FormSection from '@/components/form/section.vue'; import FormInput from '@/components/form/input.vue'; import FormSwitch from '@/components/form/switch.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { $i } from '@/account'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const emailAddress = ref($i!.email); const onChangeReceiveAnnouncementEmail = (v) => { os.api('i/update', { - receiveAnnouncementEmail: v + receiveAnnouncementEmail: v, }); }; const saveEmailAddress = () => { os.inputText({ title: i18n.ts.password, - type: 'password' + type: 'password', }).then(({ canceled, result: password }) => { if (canceled) return; os.apiWithDialog('i/update-email', { @@ -86,7 +86,7 @@ const saveNotificationSettings = () => { ...[emailNotification_follow.value ? 'follow' : null], ...[emailNotification_receiveFollowRequest.value ? 'receiveFollowRequest' : null], ...[emailNotification_groupInvited.value ? 'groupInvited' : null], - ].filter(x => x != null) + ].filter(x => x != null), }); }; @@ -100,11 +100,12 @@ onMounted(() => { }); }); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.email, - icon: 'fas fa-envelope', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.email, + icon: 'fas fa-envelope', }); </script> diff --git a/packages/client/src/pages/settings/general.vue b/packages/client/src/pages/settings/general.vue index 64b8cc3106..74fa0bc926 100644 --- a/packages/client/src/pages/settings/general.vue +++ b/packages/client/src/pages/settings/general.vue @@ -48,7 +48,8 @@ <FormSwitch v-model="disableShowingAnimatedImages" class="_formBlock">{{ i18n.ts.disableShowingAnimatedImages }}</FormSwitch> <FormSwitch v-model="squareAvatars" class="_formBlock">{{ i18n.ts.squareAvatars }}</FormSwitch> <FormSwitch v-model="useSystemFont" class="_formBlock">{{ i18n.ts.useSystemFont }}</FormSwitch> - <FormSwitch v-model="useOsNativeEmojis" class="_formBlock">{{ i18n.ts.useOsNativeEmojis }} + <FormSwitch v-model="useOsNativeEmojis" class="_formBlock"> + {{ i18n.ts.useOsNativeEmojis }} <div><Mfm :key="useOsNativeEmojis" text="🍮🍦🍭🍩🍰🍫🍬🥞🍪"/></div> </FormSwitch> <FormSwitch v-model="disableDrawer" class="_formBlock">{{ i18n.ts.disableDrawer }}</FormSwitch> @@ -80,10 +81,10 @@ <option value="force">{{ i18n.ts._nsfw.force }}</option> </FormSelect> - <FormGroup> - <template #label>{{ i18n.ts.defaultNavigationBehaviour }}</template> - <FormSwitch v-model="defaultSideView">{{ i18n.ts.openInSideView }}</FormSwitch> - </FormGroup> + <FormRange v-model="numberOfPageCache" :min="1" :max="10" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.numberOfPageCache }}</template> + <template #caption>{{ i18n.ts.numberOfPageCacheDescription }}</template> + </FormRange> <FormLink to="/settings/deck" class="_formBlock">{{ i18n.ts.deck }}</FormLink> @@ -92,11 +93,11 @@ </template> <script lang="ts" setup> -import { computed, defineExpose, ref, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; import FormSwitch from '@/components/form/switch.vue'; import FormSelect from '@/components/form/select.vue'; import FormRadios from '@/components/form/radios.vue'; -import FormGroup from '@/components/form/group.vue'; +import FormRange from '@/components/form/range.vue'; import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; import MkLink from '@/components/link.vue'; @@ -104,8 +105,8 @@ import { langs } from '@/config'; import { defaultStore } from '@/store'; import * as os from '@/os'; import { unisonReload } from '@/scripts/unison-reload'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const lang = ref(localStorage.getItem('lang')); const fontSize = ref(localStorage.getItem('fontSize')); @@ -136,7 +137,7 @@ const imageNewTab = computed(defaultStore.makeGetterSetter('imageNewTab')); const nsfw = computed(defaultStore.makeGetterSetter('nsfw')); const disablePagesScript = computed(defaultStore.makeGetterSetter('disablePagesScript')); const showFixedPostForm = computed(defaultStore.makeGetterSetter('showFixedPostForm')); -const defaultSideView = computed(defaultStore.makeGetterSetter('defaultSideView')); +const numberOfPageCache = computed(defaultStore.makeGetterSetter('numberOfPageCache')); const instanceTicker = computed(defaultStore.makeGetterSetter('instanceTicker')); const enableInfiniteScroll = computed(defaultStore.makeGetterSetter('enableInfiniteScroll')); const useReactionPickerForContextMenu = computed(defaultStore.makeGetterSetter('useReactionPickerForContextMenu')); @@ -173,16 +174,17 @@ watch([ aiChanMode, showGapBetweenNotesInTimeline, instanceTicker, - overridedDeviceKind + overridedDeviceKind, ], async () => { await reloadAsk(); }); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.general, - icon: 'fas fa-cogs', - bg: 'var(--bg)' - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.general, + icon: 'fas fa-cogs', }); </script> diff --git a/packages/client/src/pages/settings/import-export.vue b/packages/client/src/pages/settings/import-export.vue index 127cbcd4c1..d48dab9f8d 100644 --- a/packages/client/src/pages/settings/import-export.vue +++ b/packages/client/src/pages/settings/import-export.vue @@ -2,51 +2,83 @@ <div class="_formRoot"> <FormSection> <template #label>{{ $ts._exportOrImport.allNotes }}</template> - <MkButton :class="$style.button" inline @click="exportNotes()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + <FormFolder> + <template #label>{{ $ts.export }}</template> + <template #icon><i class="fas fa-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportNotes()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + </FormFolder> </FormSection> <FormSection> <template #label>{{ $ts._exportOrImport.followingList }}</template> - <FormGroup> + <FormFolder class="_formBlock"> + <template #label>{{ $ts.export }}</template> + <template #icon><i class="fas fa-download"></i></template> <FormSwitch v-model="excludeMutingUsers" class="_formBlock"> {{ $ts._exportOrImport.excludeMutingUsers }} </FormSwitch> <FormSwitch v-model="excludeInactiveUsers" class="_formBlock"> {{ $ts._exportOrImport.excludeInactiveUsers }} </FormSwitch> - <MkButton :class="$style.button" inline @click="exportFollowing()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> - </FormGroup> - <FormGroup> - <MkButton :class="$style.button" inline @click="importFollowing($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> - </FormGroup> + <MkButton primary :class="$style.button" inline @click="exportFollowing()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ $ts.import }}</template> + <template #icon><i class="fas fa-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importFollowing($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormFolder> </FormSection> <FormSection> <template #label>{{ $ts._exportOrImport.userLists }}</template> - <MkButton :class="$style.button" inline @click="exportUserLists()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> - <MkButton :class="$style.button" inline @click="importUserLists($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + <FormFolder class="_formBlock"> + <template #label>{{ $ts.export }}</template> + <template #icon><i class="fas fa-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportUserLists()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ $ts.import }}</template> + <template #icon><i class="fas fa-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importUserLists($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormFolder> </FormSection> <FormSection> <template #label>{{ $ts._exportOrImport.muteList }}</template> - <MkButton :class="$style.button" inline @click="exportMuting()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> - <MkButton :class="$style.button" inline @click="importMuting($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + <FormFolder class="_formBlock"> + <template #label>{{ $ts.export }}</template> + <template #icon><i class="fas fa-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportMuting()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ $ts.import }}</template> + <template #icon><i class="fas fa-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importMuting($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormFolder> </FormSection> <FormSection> <template #label>{{ $ts._exportOrImport.blockingList }}</template> - <MkButton :class="$style.button" inline @click="exportBlocking()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> - <MkButton :class="$style.button" inline @click="importBlocking($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + <FormFolder class="_formBlock"> + <template #label>{{ $ts.export }}</template> + <template #icon><i class="fas fa-download"></i></template> + <MkButton primary :class="$style.button" inline @click="exportBlocking()"><i class="fas fa-download"></i> {{ $ts.export }}</MkButton> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ $ts.import }}</template> + <template #icon><i class="fas fa-upload"></i></template> + <MkButton primary :class="$style.button" inline @click="importBlocking($event)"><i class="fas fa-upload"></i> {{ $ts.import }}</MkButton> + </FormFolder> </FormSection> </div> </template> <script lang="ts" setup> -import { defineExpose, ref } from 'vue'; +import { ref } from 'vue'; import MkButton from '@/components/ui/button.vue'; import FormSection from '@/components/form/section.vue'; -import FormGroup from '@/components/form/group.vue'; +import FormFolder from '@/components/form/folder.vue'; import FormSwitch from '@/components/form/switch.vue'; import * as os from '@/os'; import { selectFile } from '@/scripts/select-file'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const excludeMutingUsers = ref(false); const excludeInactiveUsers = ref(false); @@ -116,12 +148,13 @@ const importBlocking = async (ev) => { os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError); }; -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.importAndExport, - icon: 'fas fa-boxes', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.importAndExport, + icon: 'fas fa-boxes', }); </script> diff --git a/packages/client/src/pages/settings/index.vue b/packages/client/src/pages/settings/index.vue index e6670ea930..76410ec12f 100644 --- a/packages/client/src/pages/settings/index.vue +++ b/packages/client/src/pages/settings/index.vue @@ -1,58 +1,54 @@ <template> -<MkSpacer :content-max="900" :margin-min="20" :margin-max="32"> - <div ref="el" class="vvcocwet" :class="{ wide: !narrow }"> - <div class="header"> - <div class="title"> - <MkA v-if="narrow" to="/settings">{{ $ts.settings }}</MkA> - <template v-else>{{ $ts.settings }}</template> - </div> - <div v-if="childInfo" class="subtitle">{{ childInfo.title }}</div> - </div> - <div class="body"> - <div v-if="!narrow || initialPage == null" class="nav"> - <div class="baaadecd"> - <MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo> - <MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="900" :margin-min="20" :margin-max="32"> + <div ref="el" class="vvcocwet" :class="{ wide: !narrow }"> + <div class="body"> + <div v-if="!narrow || initialPage == null" class="nav"> + <div class="baaadecd"> + <MkInfo v-if="emailNotConfigured" warn class="info">{{ $ts.emailNotConfiguredWarning }} <MkA to="/settings/email" class="_link">{{ $ts.configure }}</MkA></MkInfo> + <MkSuperMenu :def="menuDef" :grid="initialPage == null"></MkSuperMenu> + </div> </div> - </div> - <div v-if="!(narrow && initialPage == null)" class="main"> - <div class="bkzroven"> - <component :is="component" :ref="el => pageChanged(el)" :key="initialPage" v-bind="pageProps"/> + <div v-if="!(narrow && initialPage == null)" class="main"> + <div class="bkzroven"> + <component :is="component" :key="initialPage" v-bind="pageProps"/> + </div> </div> </div> </div> - </div> -</MkSpacer> + </MkSpacer> +</mkstickycontainer> </template> <script setup lang="ts"> -import { computed, defineAsyncComponent, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; +import { computed, defineAsyncComponent, inject, nextTick, onMounted, onUnmounted, provide, ref, watch } from 'vue'; import { i18n } from '@/i18n'; import MkInfo from '@/components/ui/info.vue'; import MkSuperMenu from '@/components/ui/super-menu.vue'; import { scroll } from '@/scripts/scroll'; -import { signout } from '@/account'; +import { signout , $i } from '@/account'; import { unisonReload } from '@/scripts/unison-reload'; -import * as symbols from '@/symbols'; import { instance } from '@/instance'; -import { $i } from '@/account'; -import { MisskeyNavigator } from '@/scripts/navigate'; +import { useRouter } from '@/router'; +import { definePageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import * as os from '@/os'; -const props = defineProps<{ - initialPage?: string -}>(); +const props = withDefaults(defineProps<{ + initialPage?: string; +}>(), { +}); const indexInfo = { title: i18n.ts.settings, icon: 'fas fa-cog', - bg: 'var(--bg)', hideHeader: true, }; const INFO = ref(indexInfo); const el = ref<HTMLElement | null>(null); const childInfo = ref(null); -const nav = new MisskeyNavigator(); +const router = useRouter(); const narrow = ref(false); const NARROW_THRESHOLD = 600; @@ -119,6 +115,11 @@ const menuDef = computed(() => [{ active: props.initialPage === 'theme', }, { icon: 'fas fa-list-ul', + text: i18n.ts.statusbar, + to: '/settings/statusbars', + active: props.initialPage === 'statusbars', + }, { + icon: 'fas fa-list-ul', text: i18n.ts.menu, to: '/settings/menu', active: props.initialPage === 'menu', @@ -185,11 +186,16 @@ const menuDef = computed(() => [{ type: 'button', icon: 'fas fa-sign-in-alt fa-flip-horizontal', text: i18n.ts.logout, - action: () => { + action: async () => { + const { canceled } = await os.confirm({ + type: 'warning', + text: i18n.ts.logoutConfirm, + }); + if (canceled) return; signout(); }, danger: true, - },], + }], }]); const pageProps = ref({}); @@ -220,6 +226,7 @@ const component = computed(() => { case 'theme/install': return defineAsyncComponent(() => import('./theme.install.vue')); case 'theme/manage': return defineAsyncComponent(() => import('./theme.manage.vue')); case 'menu': return defineAsyncComponent(() => import('./menu.vue')); + case 'statusbars': return defineAsyncComponent(() => import('./statusbars.vue')); case 'sounds': return defineAsyncComponent(() => import('./sounds.vue')); case 'custom-css': return defineAsyncComponent(() => import('./custom-css.vue')); case 'deck': return defineAsyncComponent(() => import('./deck.vue')); @@ -242,7 +249,7 @@ watch(component, () => { watch(() => props.initialPage, () => { if (props.initialPage == null && !narrow.value) { - nav.push('/settings/profile'); + router.push('/settings/profile'); } else { if (props.initialPage == null) { INFO.value = indexInfo; @@ -252,7 +259,7 @@ watch(() => props.initialPage, () => { watch(narrow, () => { if (props.initialPage == null && !narrow.value) { - nav.push('/settings/profile'); + router.push('/settings/profile'); } }); @@ -261,7 +268,7 @@ onMounted(() => { narrow.value = el.value.offsetWidth < NARROW_THRESHOLD; if (props.initialPage == null && !narrow.value) { - nav.push('/settings/profile'); + router.push('/settings/profile'); } }); @@ -271,38 +278,23 @@ onUnmounted(() => { const emailNotConfigured = computed(() => instance.enableEmail && ($i.email == null || !$i.emailVerified)); -const pageChanged = (page) => { - if (page == null) { +provideMetadataReceiver((info) => { + if (info == null) { childInfo.value = null; } else { - childInfo.value = page[symbols.PAGE_INFO]; + childInfo.value = info; } -}; - -defineExpose({ - [symbols.PAGE_INFO]: INFO, }); -</script> -<style lang="scss" scoped> -.vvcocwet { - > .header { - display: flex; - margin-bottom: 24px; - font-size: 1.3em; - font-weight: bold; +const headerActions = $computed(() => []); - > .title { - display: block; - width: 34%; - } +const headerTabs = $computed(() => []); - > .subtitle { - flex: 1; - min-width: 0; - } - } +definePageMetadata(INFO); +</script> +<style lang="scss" scoped> +.vvcocwet { > .body { > .nav { .baaadecd { diff --git a/packages/client/src/pages/settings/instance-mute.vue b/packages/client/src/pages/settings/instance-mute.vue index bcc2ee85ad..d0ca85adca 100644 --- a/packages/client/src/pages/settings/instance-mute.vue +++ b/packages/client/src/pages/settings/instance-mute.vue @@ -10,14 +10,14 @@ </template> <script lang="ts" setup> -import { defineExpose, ref, watch } from 'vue'; +import { ref, watch } from 'vue'; import FormTextarea from '@/components/form/textarea.vue'; import MkInfo from '@/components/ui/info.vue'; import MkButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { $i } from '@/account'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const instanceMutes = ref($i!.mutedInstances.join('\n')); const changed = ref(false); @@ -42,10 +42,12 @@ watch(instanceMutes, () => { changed.value = true; }); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.instanceMute, - icon: 'fas fa-volume-mute' - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.instanceMute, + icon: 'fas fa-volume-mute', }); </script> diff --git a/packages/client/src/pages/settings/integration.vue b/packages/client/src/pages/settings/integration.vue index 75c6200944..ccb02e08a2 100644 --- a/packages/client/src/pages/settings/integration.vue +++ b/packages/client/src/pages/settings/integration.vue @@ -24,14 +24,14 @@ </template> <script lang="ts" setup> -import { computed, defineExpose, onMounted, ref, watch } from 'vue'; +import { computed, onMounted, ref, watch } from 'vue'; import { apiUrl } from '@/config'; import FormSection from '@/components/form/section.vue'; import MkButton from '@/components/ui/button.vue'; -import * as symbols from '@/symbols'; import { $i } from '@/account'; import { instance } from '@/instance'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const twitterForm = ref<Window | null>(null); const discordForm = ref<Window | null>(null); @@ -42,7 +42,7 @@ const integrations = computed(() => $i!.integrations); function openWindow(service: string, type: string) { return window.open(`${apiUrl}/${type}/${service}`, `${service}_${type}_window`, - 'height=570, width=520' + 'height=570, width=520', ); } @@ -72,7 +72,7 @@ function disconnectGithub() { onMounted(() => { document.cookie = `igi=${$i!.token}; path=/;` + - ` max-age=31536000;` + + ' max-age=31536000;' + (document.location.protocol.startsWith('https') ? ' secure' : ''); watch(integrations, () => { @@ -88,11 +88,12 @@ onMounted(() => { }); }); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.integration, - icon: 'fas fa-share-alt', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.integration, + icon: 'fas fa-share-alt', }); </script> diff --git a/packages/client/src/pages/settings/menu.vue b/packages/client/src/pages/settings/menu.vue index 2288c3f718..076654c105 100644 --- a/packages/client/src/pages/settings/menu.vue +++ b/packages/client/src/pages/settings/menu.vue @@ -18,16 +18,16 @@ </template> <script lang="ts" setup> -import { computed, defineExpose, ref, watch } from 'vue'; +import { computed, ref, watch } from 'vue'; import FormTextarea from '@/components/form/textarea.vue'; import FormRadios from '@/components/form/radios.vue'; import FormButton from '@/components/ui/button.vue'; import * as os from '@/os'; import { menuDef } from '@/menu'; import { defaultStore } from '@/store'; -import * as symbols from '@/symbols'; import { unisonReload } from '@/scripts/unison-reload'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const items = ref(defaultStore.state.menu.join('\n')); @@ -37,7 +37,7 @@ const menuDisplay = computed(defaultStore.makeGetterSetter('menuDisplay')); async function reloadAsk() { const { canceled } = await os.confirm({ type: 'info', - text: i18n.ts.reloadToApplySetting + text: i18n.ts.reloadToApplySetting, }); if (canceled) return; @@ -49,10 +49,10 @@ async function addItem() { const { canceled, result: item } = await os.select({ title: i18n.ts.addItem, items: [...menu.map(k => ({ - value: k, text: i18n.ts[menuDef[k].title] + value: k, text: i18n.ts[menuDef[k].title], })), { - value: '-', text: i18n.ts.divider - }] + value: '-', text: i18n.ts.divider, + }], }); if (canceled) return; items.value = [...split.value, item].join('\n'); @@ -76,11 +76,12 @@ watch(menuDisplay, async () => { await reloadAsk(); }); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.menu, - icon: 'fas fa-list-ul', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.menu, + icon: 'fas fa-list-ul', }); </script> diff --git a/packages/client/src/pages/settings/mute-block.vue b/packages/client/src/pages/settings/mute-block.vue index 28d11809e3..397a0c815c 100644 --- a/packages/client/src/pages/settings/mute-block.vue +++ b/packages/client/src/pages/settings/mute-block.vue @@ -7,7 +7,7 @@ <div v-if="tab === 'mute'"> <MkPagination :pagination="mutingPagination" class="muting"> <template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template> - <template v-slot="{items}"> + <template #default="{items}"> <FormLink v-for="mute in items" :key="mute.id" :to="userPage(mute.mutee)"> <MkAcct :user="mute.mutee"/> </FormLink> @@ -17,7 +17,7 @@ <div v-if="tab === 'block'"> <MkPagination :pagination="blockingPagination" class="blocking"> <template #empty><FormInfo>{{ $ts.noUsers }}</FormInfo></template> - <template v-slot="{items}"> + <template #default="{items}"> <FormLink v-for="block in items" :key="block.id" :to="userPage(block.blockee)"> <MkAcct :user="block.blockee"/> </FormLink> @@ -35,8 +35,8 @@ import FormInfo from '@/components/ui/info.vue'; import FormLink from '@/components/form/link.vue'; import { userPage } from '@/filters/user'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let tab = $ref('mute'); @@ -50,11 +50,12 @@ const blockingPagination = { limit: 10, }; -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.muteAndBlock, - icon: 'fas fa-ban', - bg: 'var(--bg)', - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.muteAndBlock, + icon: 'fas fa-ban', }); </script> diff --git a/packages/client/src/pages/settings/notifications.vue b/packages/client/src/pages/settings/notifications.vue index b8fff95a8d..d2a363965a 100644 --- a/packages/client/src/pages/settings/notifications.vue +++ b/packages/client/src/pages/settings/notifications.vue @@ -10,15 +10,15 @@ </template> <script lang="ts" setup> -import { defineAsyncComponent, defineExpose } from 'vue'; +import { defineAsyncComponent } from 'vue'; +import { notificationTypes } from 'misskey-js'; import FormButton from '@/components/ui/button.vue'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; -import { notificationTypes } from 'misskey-js'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { $i } from '@/account'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; async function readAllUnreadNotes() { await os.api('i/read-all-unread-notes'); @@ -45,15 +45,16 @@ function configure() { }).then(i => { $i!.mutingNotificationTypes = i.mutingNotificationTypes; }); - } + }, }, 'closed'); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.notifications, - icon: 'fas fa-bell', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.notifications, + icon: 'fas fa-bell', }); </script> diff --git a/packages/client/src/pages/settings/other.vue b/packages/client/src/pages/settings/other.vue index 82e174a5b4..52ef4d401f 100644 --- a/packages/client/src/pages/settings/other.vue +++ b/packages/client/src/pages/settings/other.vue @@ -15,30 +15,31 @@ </template> <script lang="ts" setup> -import { computed, defineExpose } from 'vue'; +import { computed } from 'vue'; import FormSwitch from '@/components/form/switch.vue'; import FormLink from '@/components/form/link.vue'; import * as os from '@/os'; import { defaultStore } from '@/store'; -import * as symbols from '@/symbols'; import { $i } from '@/account'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const reportError = computed(defaultStore.makeGetterSetter('reportError')); function onChangeInjectFeaturedNote(v) { os.api('i/update', { - injectFeaturedNote: v + injectFeaturedNote: v, }).then((i) => { $i!.injectFeaturedNote = i.injectFeaturedNote; }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.other, - icon: 'fas fa-ellipsis-h', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.other, + icon: 'fas fa-ellipsis-h', }); </script> diff --git a/packages/client/src/pages/settings/plugin.install.vue b/packages/client/src/pages/settings/plugin.install.vue index 96c0abfd99..a4cab4b7a4 100644 --- a/packages/client/src/pages/settings/plugin.install.vue +++ b/packages/client/src/pages/settings/plugin.install.vue @@ -13,7 +13,7 @@ </template> <script lang="ts" setup> -import { defineExpose, defineAsyncComponent, nextTick, ref } from 'vue'; +import { defineAsyncComponent, nextTick, ref } from 'vue'; import { AiScript, parse } from '@syuilo/aiscript'; import { serialize } from '@syuilo/aiscript/built/serializer'; import { v4 as uuid } from 'uuid'; @@ -24,7 +24,7 @@ import * as os from '@/os'; import { ColdDeviceStorage } from '@/store'; import { unisonReload } from '@/scripts/unison-reload'; import { i18n } from '@/i18n'; -import * as symbols from '@/symbols'; +import { definePageMetadata } from '@/scripts/page-metadata'; const code = ref(null); @@ -35,7 +35,7 @@ function installPlugin({ id, meta, ast, token }) { active: true, configData: {}, token: token, - ast: ast + ast: ast, })); } @@ -46,7 +46,7 @@ async function install() { } catch (err) { os.alert({ type: 'error', - text: 'Syntax error :(' + text: 'Syntax error :(', }); return; } @@ -55,7 +55,7 @@ async function install() { if (meta == null) { os.alert({ type: 'error', - text: 'No metadata found :(' + text: 'No metadata found :(', }); return; } @@ -64,7 +64,7 @@ async function install() { if (metadata == null) { os.alert({ type: 'error', - text: 'No metadata found :(' + text: 'No metadata found :(', }); return; } @@ -73,7 +73,7 @@ async function install() { if (name == null || version == null || author == null) { os.alert({ type: 'error', - text: 'Required property not found :(' + text: 'Required property not found :(', }); return; } @@ -83,7 +83,7 @@ async function install() { title: i18n.ts.tokenRequested, information: i18n.ts.pluginTokenRequestedDescription, initialName: name, - initialPermissions: permissions + initialPermissions: permissions, }, { done: async result => { const { name, permissions } = result; @@ -93,17 +93,17 @@ async function install() { permission: permissions, }); res(token); - } + }, }, 'closed'); }); installPlugin({ id: uuid(), meta: { - name, version, author, description, permissions, config + name, version, author, description, permissions, config, }, token, - ast: serialize(ast) + ast: serialize(ast), }); os.success(); @@ -113,11 +113,12 @@ async function install() { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts._plugin.install, - icon: 'fas fa-download', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._plugin.install, + icon: 'fas fa-download', }); </script> diff --git a/packages/client/src/pages/settings/plugin.vue b/packages/client/src/pages/settings/plugin.vue index 873a022cbc..8e773b7990 100644 --- a/packages/client/src/pages/settings/plugin.vue +++ b/packages/client/src/pages/settings/plugin.vue @@ -7,7 +7,7 @@ <div v-for="plugin in plugins" :key="plugin.id" class="_formBlock _panel" style="padding: 20px;"> <span style="display: flex;"><b>{{ plugin.name }}</b><span style="margin-left: auto;">v{{ plugin.version }}</span></span> - <FormSwitch class="_formBlock" :modelValue="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</FormSwitch> + <FormSwitch class="_formBlock" :model-value="plugin.active" @update:modelValue="changeActive(plugin, $event)">{{ i18n.ts.makeActive }}</FormSwitch> <MkKeyValue class="_formBlock"> <template #key>{{ i18n.ts.author }}</template> @@ -32,7 +32,7 @@ </template> <script lang="ts" setup> -import { defineExpose, nextTick, ref } from 'vue'; +import { nextTick, ref } from 'vue'; import FormLink from '@/components/form/link.vue'; import FormSwitch from '@/components/form/switch.vue'; import FormSection from '@/components/form/section.vue'; @@ -40,9 +40,9 @@ import MkButton from '@/components/ui/button.vue'; import MkKeyValue from '@/components/key-value.vue'; import * as os from '@/os'; import { ColdDeviceStorage } from '@/store'; -import * as symbols from '@/symbols'; import { unisonReload } from '@/scripts/unison-reload'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const plugins = ref(ColdDeviceStorage.get('plugins')); @@ -83,12 +83,13 @@ function changeActive(plugin, active) { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.plugins, - icon: 'fas fa-plug', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.plugins, + icon: 'fas fa-plug', }); </script> diff --git a/packages/client/src/pages/settings/privacy.vue b/packages/client/src/pages/settings/privacy.vue index a84d2f8786..be9e34cdfb 100644 --- a/packages/client/src/pages/settings/privacy.vue +++ b/packages/client/src/pages/settings/privacy.vue @@ -31,8 +31,13 @@ <FormSection> <FormSwitch v-model="rememberNoteVisibility" class="_formBlock" @update:modelValue="save()">{{ $ts.rememberNoteVisibility }}</FormSwitch> - <FormGroup v-if="!rememberNoteVisibility" class="_formBlock"> + <FormFolder v-if="!rememberNoteVisibility" class="_formBlock"> <template #label>{{ $ts.defaultNoteVisibility }}</template> + <template v-if="defaultNoteVisibility === 'public'" #suffix>{{ $ts._visibility.public }}</template> + <template v-else-if="defaultNoteVisibility === 'home'" #suffix>{{ $ts._visibility.home }}</template> + <template v-else-if="defaultNoteVisibility === 'followers'" #suffix>{{ $ts._visibility.followers }}</template> + <template v-else-if="defaultNoteVisibility === 'specified'" #suffix>{{ $ts._visibility.specified }}</template> + <FormSelect v-model="defaultNoteVisibility" class="_formBlock"> <option value="public">{{ $ts._visibility.public }}</option> <option value="home">{{ $ts._visibility.home }}</option> @@ -40,7 +45,7 @@ <option value="specified">{{ $ts._visibility.specified }}</option> </FormSelect> <FormSwitch v-model="defaultNoteLocalOnly" class="_formBlock">{{ $ts._visibility.localOnly }}</FormSwitch> - </FormGroup> + </FormFolder> </FormSection> <FormSwitch v-model="keepCw" class="_formBlock" @update:modelValue="save()">{{ $ts.keepCw }}</FormSwitch> @@ -52,12 +57,12 @@ import { } from 'vue'; import FormSwitch from '@/components/form/switch.vue'; import FormSelect from '@/components/form/select.vue'; import FormSection from '@/components/form/section.vue'; -import FormGroup from '@/components/form/group.vue'; +import FormFolder from '@/components/form/folder.vue'; import * as os from '@/os'; import { defaultStore } from '@/store'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; import { $i } from '@/account'; +import { definePageMetadata } from '@/scripts/page-metadata'; let isLocked = $ref($i.isLocked); let autoAcceptFollowed = $ref($i.autoAcceptFollowed); @@ -84,11 +89,12 @@ function save() { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.privacy, - icon: 'fas fa-lock-open', - bg: 'var(--bg)', - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.privacy, + icon: 'fas fa-lock-open', }); </script> diff --git a/packages/client/src/pages/settings/profile.vue b/packages/client/src/pages/settings/profile.vue index b64dc93cc7..2a326fc2b6 100644 --- a/packages/client/src/pages/settings/profile.vue +++ b/packages/client/src/pages/settings/profile.vue @@ -56,8 +56,6 @@ <FormSwitch v-model="profile.isCat" class="_formBlock">{{ i18n.ts.flagAsCat }}<template #caption>{{ i18n.ts.flagAsCatDescription }}</template></FormSwitch> <FormSwitch v-model="profile.showTimelineReplies" class="_formBlock">{{ i18n.ts.flagShowTimelineReplies }}<template #caption>{{ i18n.ts.flagShowTimelineRepliesDescription }} {{ i18n.ts.reflectMayTakeTime }}</template></FormSwitch> <FormSwitch v-model="profile.isBot" class="_formBlock">{{ i18n.ts.flagAsBot }}<template #caption>{{ i18n.ts.flagAsBotDescription }}</template></FormSwitch> - - <FormSwitch v-model="profile.alwaysMarkNsfw" class="_formBlock">{{ i18n.ts.alwaysMarkSensitive }}</FormSwitch> </div> </template> @@ -74,10 +72,10 @@ import FormSlot from '@/components/form/slot.vue'; import { host } from '@/config'; import { selectFile } from '@/scripts/select-file'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; import { $i } from '@/account'; import { langmap } from '@/scripts/langmap'; +import { definePageMetadata } from '@/scripts/page-metadata'; const profile = reactive({ name: $i.name, @@ -88,7 +86,6 @@ const profile = reactive({ isBot: $i.isBot, isCat: $i.isCat, showTimelineReplies: $i.showTimelineReplies, - alwaysMarkNsfw: $i.alwaysMarkNsfw, }); watch(() => profile, () => { @@ -126,7 +123,6 @@ function save() { isBot: !!profile.isBot, isCat: !!profile.isCat, showTimelineReplies: !!profile.showTimelineReplies, - alwaysMarkNsfw: !!profile.alwaysMarkNsfw, }); } @@ -176,12 +172,13 @@ function changeBanner(ev) { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.profile, - icon: 'fas fa-user', - bg: 'var(--bg)', - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.profile, + icon: 'fas fa-user', }); </script> @@ -191,7 +188,7 @@ defineExpose({ background-size: cover; background-position: center; border-radius: 10px; - overflow: clip; + overflow: hidden; overflow: clip; > .avatar { display: inline-block; diff --git a/packages/client/src/pages/settings/reaction.vue b/packages/client/src/pages/settings/reaction.vue index 963ac81dfa..382e1b081e 100644 --- a/packages/client/src/pages/settings/reaction.vue +++ b/packages/client/src/pages/settings/reaction.vue @@ -64,8 +64,8 @@ import FormSection from '@/components/form/section.vue'; import FormSwitch from '@/components/form/switch.vue'; import * as os from '@/os'; import { defaultStore } from '@/store'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let reactions = $ref(JSON.parse(JSON.stringify(defaultStore.state.reactions))); @@ -83,7 +83,7 @@ function remove(reaction, ev: MouseEvent) { text: i18n.ts.remove, action: () => { reactions = reactions.filter(x => x !== reaction); - } + }, }], ev.currentTarget ?? ev.target); } @@ -106,7 +106,7 @@ async function setDefault() { function chooseEmoji(ev: MouseEvent) { os.pickEmoji(ev.currentTarget ?? ev.target, { - showPinned: false + showPinned: false, }).then(emoji => { if (!reactions.includes(emoji)) { reactions.push(emoji); @@ -120,15 +120,16 @@ watch($$(reactions), () => { deep: true, }); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.reaction, - icon: 'fas fa-laugh', - action: { - icon: 'fas fa-eye', - handler: preview, - }, - bg: 'var(--bg)', +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.reaction, + icon: 'fas fa-laugh', + action: { + icon: 'fas fa-eye', + handler: preview, }, }); </script> diff --git a/packages/client/src/pages/settings/security.vue b/packages/client/src/pages/settings/security.vue index 401648790a..eb3efa9afb 100644 --- a/packages/client/src/pages/settings/security.vue +++ b/packages/client/src/pages/settings/security.vue @@ -13,7 +13,7 @@ <FormSection> <template #label>{{ i18n.ts.signinHistory }}</template> <MkPagination :pagination="pagination"> - <template v-slot="{items}"> + <template #default="{items}"> <div> <div v-for="item in items" :key="item.id" v-panel class="timnmucd"> <header> @@ -38,15 +38,14 @@ </template> <script lang="ts" setup> -import { defineExpose } from 'vue'; +import X2fa from './2fa.vue'; import FormSection from '@/components/form/section.vue'; import FormSlot from '@/components/form/slot.vue'; import FormButton from '@/components/ui/button.vue'; import MkPagination from '@/components/ui/pagination.vue'; -import X2fa from './2fa.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const pagination = { endpoint: 'i/signin-history' as const, @@ -56,54 +55,55 @@ const pagination = { async function change() { const { canceled: canceled1, result: currentPassword } = await os.inputText({ title: i18n.ts.currentPassword, - type: 'password' + type: 'password', }); if (canceled1) return; const { canceled: canceled2, result: newPassword } = await os.inputText({ title: i18n.ts.newPassword, - type: 'password' + type: 'password', }); if (canceled2) return; const { canceled: canceled3, result: newPassword2 } = await os.inputText({ title: i18n.ts.newPasswordRetype, - type: 'password' + type: 'password', }); if (canceled3) return; if (newPassword !== newPassword2) { os.alert({ type: 'error', - text: i18n.ts.retypedNotMatch + text: i18n.ts.retypedNotMatch, }); return; } os.apiWithDialog('i/change-password', { currentPassword, - newPassword + newPassword, }); } function regenerateToken() { os.inputText({ title: i18n.ts.password, - type: 'password' + type: 'password', }).then(({ canceled, result: password }) => { if (canceled) return; os.api('i/regenerate_token', { - password: password + password: password, }); }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.security, - icon: 'fas fa-lock', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.security, + icon: 'fas fa-lock', }); </script> diff --git a/packages/client/src/pages/settings/sounds.vue b/packages/client/src/pages/settings/sounds.vue index d01e87c1f8..f29c9eb049 100644 --- a/packages/client/src/pages/settings/sounds.vue +++ b/packages/client/src/pages/settings/sounds.vue @@ -18,7 +18,7 @@ </template> <script lang="ts" setup> -import { computed, defineExpose, ref } from 'vue'; +import { computed, ref } from 'vue'; import FormRange from '@/components/form/range.vue'; import FormButton from '@/components/ui/button.vue'; import FormLink from '@/components/form/link.vue'; @@ -26,8 +26,8 @@ import FormSection from '@/components/form/section.vue'; import * as os from '@/os'; import { ColdDeviceStorage } from '@/store'; import { playFile } from '@/scripts/sound'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const masterVolume = computed({ get: () => { @@ -35,19 +35,19 @@ const masterVolume = computed({ }, set: (value) => { ColdDeviceStorage.set('sound_masterVolume', value); - } + }, }); const volumeIcon = computed(() => masterVolume.value === 0 ? 'fas fa-volume-mute' : 'fas fa-volume-up'); const sounds = ref({ - note: ColdDeviceStorage.get('sound_note'), - noteMy: ColdDeviceStorage.get('sound_noteMy'), - notification: ColdDeviceStorage.get('sound_notification'), - chat: ColdDeviceStorage.get('sound_chat'), - chatBg: ColdDeviceStorage.get('sound_chatBg'), - antenna: ColdDeviceStorage.get('sound_antenna'), - channel: ColdDeviceStorage.get('sound_channel'), + note: ColdDeviceStorage.get('sound_note'), + noteMy: ColdDeviceStorage.get('sound_noteMy'), + notification: ColdDeviceStorage.get('sound_notification'), + chat: ColdDeviceStorage.get('sound_chat'), + chatBg: ColdDeviceStorage.get('sound_chatBg'), + antenna: ColdDeviceStorage.get('sound_antenna'), + channel: ColdDeviceStorage.get('sound_channel'), }); const soundsTypes = [ @@ -95,15 +95,15 @@ async function edit(type) { step: 0.05, textConverter: (v) => `${Math.floor(v * 100)}%`, label: i18n.ts.volume, - default: sounds.value[type].volume + default: sounds.value[type].volume, }, listen: { type: 'button', content: i18n.ts.listen, action: (_, values) => { playFile(values.type, values.volume); - } - } + }, + }, }); if (canceled) return; @@ -124,11 +124,12 @@ function reset() { } } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.sounds, - icon: 'fas fa-music', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.sounds, + icon: 'fas fa-music', }); </script> diff --git a/packages/client/src/pages/settings/statusbars.statusbar.vue b/packages/client/src/pages/settings/statusbars.statusbar.vue new file mode 100644 index 0000000000..206979925e --- /dev/null +++ b/packages/client/src/pages/settings/statusbars.statusbar.vue @@ -0,0 +1,136 @@ +<template> +<div class="_formRoot"> + <FormSelect v-model="statusbar.type" placeholder="Please select" class="_formBlock"> + <template #label>{{ i18n.ts.type }}</template> + <option value="rss">RSS</option> + <option value="federation">Federation</option> + <option value="userList">User list timeline</option> + </FormSelect> + + <MkInput v-model="statusbar.name" manual-save class="_formBlock"> + <template #label>{{ i18n.ts.label }}</template> + </MkInput> + + <MkSwitch v-model="statusbar.black" class="_formBlock"> + <template #label>Black</template> + </MkSwitch> + + <FormRadios v-model="statusbar.size" class="_formBlock"> + <template #label>{{ i18n.ts.size }}</template> + <option value="verySmall">{{ i18n.ts.small }}+</option> + <option value="small">{{ i18n.ts.small }}</option> + <option value="medium">{{ i18n.ts.medium }}</option> + <option value="large">{{ i18n.ts.large }}</option> + <option value="veryLarge">{{ i18n.ts.large }}+</option> + </FormRadios> + + <template v-if="statusbar.type === 'rss'"> + <MkInput v-model="statusbar.props.url" manual-save class="_formBlock" type="url"> + <template #label>URL</template> + </MkInput> + <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> + <template #label>{{ i18n.ts.refreshInterval }}</template> + </MkInput> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + </template> + <template v-else-if="statusbar.type === 'federation'"> + <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> + <template #label>{{ i18n.ts.refreshInterval }}</template> + </MkInput> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + <MkSwitch v-model="statusbar.props.colored" class="_formBlock"> + <template #label>{{ i18n.ts.colored }}</template> + </MkSwitch> + </template> + <template v-else-if="statusbar.type === 'userList' && userLists != null"> + <FormSelect v-model="statusbar.props.userListId" class="_formBlock"> + <template #label>{{ i18n.ts.userList }}</template> + <option v-for="list in userLists" :value="list.id">{{ list.name }}</option> + </FormSelect> + <MkInput v-model="statusbar.props.refreshIntervalSec" manual-save class="_formBlock" type="number"> + <template #label>{{ i18n.ts.refreshInterval }}</template> + </MkInput> + <FormRange v-model="statusbar.props.marqueeDuration" :min="5" :max="150" :step="1" class="_formBlock"> + <template #label>{{ i18n.ts.speed }}</template> + <template #caption>{{ i18n.ts.fast }} <-> {{ i18n.ts.slow }}</template> + </FormRange> + <MkSwitch v-model="statusbar.props.marqueeReverse" class="_formBlock"> + <template #label>{{ i18n.ts.reverse }}</template> + </MkSwitch> + </template> + + <div style="display: flex; gap: var(--margin); flex-wrap: wrap;"> + <FormButton danger @click="del">{{ i18n.ts.remove }}</FormButton> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, reactive, ref, watch } from 'vue'; +import FormSelect from '@/components/form/select.vue'; +import MkInput from '@/components/form/input.vue'; +import MkSwitch from '@/components/form/switch.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormButton from '@/components/ui/button.vue'; +import FormRange from '@/components/form/range.vue'; +import * as os from '@/os'; +import { menuDef } from '@/menu'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; + +const props = defineProps<{ + _id: string; + userLists: any[] | null; +}>(); + +const statusbar = reactive(JSON.parse(JSON.stringify(defaultStore.state.statusbars.find(x => x.id === props._id)))); + +watch(() => statusbar.type, () => { + if (statusbar.type === 'rss') { + statusbar.name = 'NEWS'; + statusbar.props.url = 'http://feeds.afpbb.com/rss/afpbb/afpbbnews'; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + } else if (statusbar.type === 'federation') { + statusbar.name = 'FEDERATION'; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + statusbar.props.colored = false; + } else if (statusbar.type === 'userList') { + statusbar.name = 'LIST TL'; + statusbar.props.refreshIntervalSec = 120; + statusbar.props.display = 'marquee'; + statusbar.props.marqueeDuration = 100; + statusbar.props.marqueeReverse = false; + } +}); + +watch(statusbar, save); + +async function save() { + const i = defaultStore.state.statusbars.findIndex(x => x.id === props._id); + const statusbars = JSON.parse(JSON.stringify(defaultStore.state.statusbars)); + statusbars[i] = JSON.parse(JSON.stringify(statusbar)); + defaultStore.set('statusbars', statusbars); +} + +function del() { + defaultStore.set('statusbars', defaultStore.state.statusbars.filter(x => x.id !== props._id)); +} +</script> diff --git a/packages/client/src/pages/settings/statusbars.vue b/packages/client/src/pages/settings/statusbars.vue new file mode 100644 index 0000000000..c81bd7fbdf --- /dev/null +++ b/packages/client/src/pages/settings/statusbars.vue @@ -0,0 +1,54 @@ +<template> +<div class="_formRoot"> + <FormFolder v-for="x in statusbars" :key="x.id" class="_formBlock"> + <template #label>{{ x.type ?? i18n.ts.notSet }}</template> + <template #suffix>{{ x.name }}</template> + <XStatusbar :_id="x.id" :user-lists="userLists"/> + </FormFolder> + <FormButton primary @click="add">{{ i18n.ts.add }}</FormButton> +</div> +</template> + +<script lang="ts" setup> +import { computed, onMounted, ref, watch } from 'vue'; +import { v4 as uuid } from 'uuid'; +import XStatusbar from './statusbars.statusbar.vue'; +import FormRadios from '@/components/form/radios.vue'; +import FormFolder from '@/components/form/folder.vue'; +import FormButton from '@/components/ui/button.vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +import { unisonReload } from '@/scripts/unison-reload'; +import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; + +const statusbars = defaultStore.reactiveState.statusbars; + +let userLists = $ref(); + +onMounted(() => { + os.api('users/lists/list').then(res => { + userLists = res; + }); +}); + +async function add() { + defaultStore.push('statusbars', { + id: uuid(), + type: null, + black: false, + size: 'medium', + props: {}, + }); +} + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.statusbar, + icon: 'fas fa-list-ul', + bg: 'var(--bg)', +}); +</script> diff --git a/packages/client/src/pages/settings/theme.install.vue b/packages/client/src/pages/settings/theme.install.vue index 25fa6c012b..2994d8fb1a 100644 --- a/packages/client/src/pages/settings/theme.install.vue +++ b/packages/client/src/pages/settings/theme.install.vue @@ -19,8 +19,8 @@ import FormButton from '@/components/ui/button.vue'; import { applyTheme, validateTheme } from '@/scripts/theme'; import * as os from '@/os'; import { addTheme, getThemes } from '@/theme-store'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let installThemeCode = $ref(null); @@ -32,21 +32,21 @@ function parseThemeCode(code: string) { } catch (err) { os.alert({ type: 'error', - text: i18n.ts._theme.invalid + text: i18n.ts._theme.invalid, }); return false; } if (!validateTheme(theme)) { os.alert({ type: 'error', - text: i18n.ts._theme.invalid + text: i18n.ts._theme.invalid, }); return false; } if (getThemes().some(t => t.id === theme.id)) { os.alert({ type: 'info', - text: i18n.ts._theme.alreadyInstalled + text: i18n.ts._theme.alreadyInstalled, }); return false; } @@ -65,15 +65,16 @@ async function install(code: string): Promise<void> { await addTheme(theme); os.alert({ type: 'success', - text: i18n.t('_theme.installed', { name: theme.name }) + text: i18n.t('_theme.installed', { name: theme.name }), }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts._theme.install, - icon: 'fas fa-download', - bg: 'var(--bg)', - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._theme.install, + icon: 'fas fa-download', }); </script> diff --git a/packages/client/src/pages/settings/theme.manage.vue b/packages/client/src/pages/settings/theme.manage.vue index 94b2d24455..9d28b4a316 100644 --- a/packages/client/src/pages/settings/theme.manage.vue +++ b/packages/client/src/pages/settings/theme.manage.vue @@ -26,7 +26,7 @@ </template> <script lang="ts" setup> -import { computed, defineExpose, ref } from 'vue'; +import { computed, ref } from 'vue'; import JSON5 from 'json5'; import FormTextarea from '@/components/form/textarea.vue'; import FormSelect from '@/components/form/select.vue'; @@ -36,8 +36,8 @@ import { Theme, getBuiltinThemesRef } from '@/scripts/theme'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import * as os from '@/os'; import { getThemes, removeTheme } from '@/theme-store'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const installedThemes = ref(getThemes()); const builtinThemes = getBuiltinThemesRef(); @@ -67,11 +67,12 @@ function uninstall() { os.success(); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts._theme.manage, - icon: 'fas fa-folder-open', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts._theme.manage, + icon: 'fas fa-folder-open', }); </script> diff --git a/packages/client/src/pages/settings/theme.vue b/packages/client/src/pages/settings/theme.vue index 5e7ffcff4b..d330e1ba25 100644 --- a/packages/client/src/pages/settings/theme.vue +++ b/packages/client/src/pages/settings/theme.vue @@ -1,5 +1,5 @@ <template> -<div class="_formRoot"> +<div class="_formRoot rsljpzjq"> <div v-adaptive-border class="rfqxtzch _panel _formBlock"> <div class="toggle"> <div class="toggleWrapper"> @@ -26,18 +26,8 @@ </div> </div> - <template v-if="darkMode"> - <FormSelect v-model="darkThemeId" class="_formBlock"> - <template #label>{{ $ts.themeForDarkMode }}</template> - <template #prefix><i class="fas fa-moon"></i></template> - <optgroup :label="$ts.darkThemes"> - <option v-for="x in darkThemes" :key="x.id" :value="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$ts.lightThemes"> - <option v-for="x in lightThemes" :key="x.id" :value="x.id">{{ x.name }}</option> - </optgroup> - </FormSelect> - <FormSelect v-model="lightThemeId" class="_formBlock"> + <div class="selects _formBlock"> + <FormSelect v-model="lightThemeId" large class="select"> <template #label>{{ $ts.themeForLightMode }}</template> <template #prefix><i class="fas fa-sun"></i></template> <optgroup :label="$ts.lightThemes"> @@ -47,19 +37,7 @@ <option v-for="x in darkThemes" :key="x.id" :value="x.id">{{ x.name }}</option> </optgroup> </FormSelect> - </template> - <template v-else> - <FormSelect v-model="lightThemeId" class="_formBlock"> - <template #label>{{ $ts.themeForLightMode }}</template> - <template #prefix><i class="fas fa-sun"></i></template> - <optgroup :label="$ts.lightThemes"> - <option v-for="x in lightThemes" :key="x.id" :value="x.id">{{ x.name }}</option> - </optgroup> - <optgroup :label="$ts.darkThemes"> - <option v-for="x in darkThemes" :key="x.id" :value="x.id">{{ x.name }}</option> - </optgroup> - </FormSelect> - <FormSelect v-model="darkThemeId" class="_formBlock"> + <FormSelect v-model="darkThemeId" large class="select"> <template #label>{{ $ts.themeForDarkMode }}</template> <template #prefix><i class="fas fa-moon"></i></template> <optgroup :label="$ts.darkThemes"> @@ -69,7 +47,7 @@ <option v-for="x in lightThemes" :key="x.id" :value="x.id">{{ x.name }}</option> </optgroup> </FormSelect> - </template> + </div> <FormSection> <div class="_formLinksGrid"> @@ -96,13 +74,12 @@ import FormButton from '@/components/ui/button.vue'; import { getBuiltinThemesRef } from '@/scripts/theme'; import { selectFile } from '@/scripts/select-file'; import { isDeviceDarkmode } from '@/scripts/is-device-darkmode'; -import { ColdDeviceStorage } from '@/store'; +import { ColdDeviceStorage , defaultStore } from '@/store'; import { i18n } from '@/i18n'; -import { defaultStore } from '@/store'; import { instance } from '@/instance'; import { uniqueBy } from '@/scripts/array'; import { fetchThemes, getThemes } from '@/theme-store'; -import * as symbols from '@/symbols'; +import { definePageMetadata } from '@/scripts/page-metadata'; const installedThemes = ref(getThemes()); const builtinThemes = getBuiltinThemesRef(); @@ -120,8 +97,11 @@ const darkThemeId = computed({ return darkTheme.value.id; }, set(id) { - ColdDeviceStorage.set('darkTheme', themes.value.find(x => x.id === id)); - } + const t = themes.value.find(x => x.id === id); + if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる + ColdDeviceStorage.set('darkTheme', t); + } + }, }); const lightTheme = ColdDeviceStorage.ref('lightTheme'); const lightThemeId = computed({ @@ -129,8 +109,11 @@ const lightThemeId = computed({ return lightTheme.value.id; }, set(id) { - ColdDeviceStorage.set('lightTheme', themes.value.find(x => x.id === id)); - } + const t = themes.value.find(x => x.id === id); + if (t) { // テーマエディタでテーマを作成したときなどは、themesに反映されないため undefined になる + ColdDeviceStorage.set('lightTheme', t); + } + }, }); const darkMode = computed(defaultStore.makeGetterSetter('darkMode')); const syncDeviceDarkMode = computed(ColdDeviceStorage.makeGetterSetter('syncDeviceDarkMode')); @@ -168,12 +151,13 @@ function setWallpaper(event) { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.theme, - icon: 'fas fa-palette', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.theme, + icon: 'fas fa-palette', }); </script> @@ -197,7 +181,7 @@ defineExpose({ > .toggleWrapper { display: inline-block; text-align: left; - overflow: clip; + overflow: hidden; overflow: clip; padding: 0 100px; input { @@ -405,4 +389,17 @@ defineExpose({ border-top: solid 0.5px var(--divider); } } + +.rsljpzjq { + > .selects { + display: flex; + gap: 1.5em var(--margin); + flex-wrap: wrap; + + > .select { + flex: 1; + min-width: 280px; + } + } +} </style> diff --git a/packages/client/src/pages/settings/webhook.edit.vue b/packages/client/src/pages/settings/webhook.edit.vue index 3690526b41..618250958b 100644 --- a/packages/client/src/pages/settings/webhook.edit.vue +++ b/packages/client/src/pages/settings/webhook.edit.vue @@ -40,19 +40,11 @@ import FormSection from '@/components/form/section.vue'; import FormSwitch from '@/components/form/switch.vue'; import FormButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; - -defineExpose({ - [symbols.PAGE_INFO]: { - title: 'Edit webhook', - icon: 'fas fa-bolt', - bg: 'var(--bg)', - }, -}); +import { definePageMetadata } from '@/scripts/page-metadata'; const webhook = await os.api('i/webhooks/show', { - webhookId: new URLSearchParams(window.location.search).get('id') + webhookId: new URLSearchParams(window.location.search).get('id'), }); let name = $ref(webhook.name); @@ -86,4 +78,13 @@ async function save(): Promise<void> { active, }); } + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'Edit webhook', + icon: 'fas fa-bolt', +}); </script> diff --git a/packages/client/src/pages/settings/webhook.new.vue b/packages/client/src/pages/settings/webhook.new.vue index 9bb492c49e..fa96c5fa4b 100644 --- a/packages/client/src/pages/settings/webhook.new.vue +++ b/packages/client/src/pages/settings/webhook.new.vue @@ -38,8 +38,8 @@ import FormSection from '@/components/form/section.vue'; import FormSwitch from '@/components/form/switch.vue'; import FormButton from '@/components/ui/button.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; let name = $ref(''); let url = $ref(''); @@ -71,11 +71,12 @@ async function create(): Promise<void> { }); } -defineExpose({ - [symbols.PAGE_INFO]: { - title: 'Create new webhook', - icon: 'fas fa-bolt', - bg: 'var(--bg)', - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'Create new webhook', + icon: 'fas fa-bolt', }); </script> diff --git a/packages/client/src/pages/settings/webhook.vue b/packages/client/src/pages/settings/webhook.vue index c9af8b6766..ef9b9b56f7 100644 --- a/packages/client/src/pages/settings/webhook.vue +++ b/packages/client/src/pages/settings/webhook.vue @@ -8,7 +8,7 @@ <FormSection> <MkPagination :pagination="pagination"> - <template v-slot="{items}"> + <template #default="{items}"> <FormLink v-for="webhook in items" :key="webhook.id" :to="`/settings/webhook/edit?id=${webhook.id}`" class="_formBlock"> <template #icon> <i v-if="webhook.active === false" class="fas fa-circle-pause"></i> @@ -34,19 +34,20 @@ import FormSection from '@/components/form/section.vue'; import FormLink from '@/components/form/link.vue'; import { userPage } from '@/filters/user'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const pagination = { endpoint: 'i/webhooks/list' as const, limit: 10, }; -defineExpose({ - [symbols.PAGE_INFO]: { - title: 'Webhook', - icon: 'fas fa-bolt', - bg: 'var(--bg)', - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: 'Webhook', + icon: 'fas fa-bolt', }); </script> diff --git a/packages/client/src/pages/settings/word-mute.vue b/packages/client/src/pages/settings/word-mute.vue index 6e1a4b2ccb..5fee7cd35a 100644 --- a/packages/client/src/pages/settings/word-mute.vue +++ b/packages/client/src/pages/settings/word-mute.vue @@ -29,7 +29,7 @@ </template> <script lang="ts" setup> -import { defineExpose, ref, watch } from 'vue'; +import { ref, watch } from 'vue'; import FormTextarea from '@/components/form/textarea.vue'; import MkKeyValue from '@/components/key-value.vue'; import MkButton from '@/components/ui/button.vue'; @@ -37,10 +37,10 @@ import MkInfo from '@/components/ui/info.vue'; import MkTab from '@/components/tab.vue'; import * as os from '@/os'; import number from '@/filters/number'; -import * as symbols from '@/symbols'; import { defaultStore } from '@/store'; import { $i } from '@/account'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const render = (mutedWords) => mutedWords.map(x => { if (Array.isArray(x)) { @@ -87,7 +87,7 @@ async function save() { os.alert({ type: 'error', title: i18n.ts.regexpError, - text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + "\n" + err.toString() + text: i18n.t('regexpErrorDescription', { tab, line: i + 1 }) + '\n' + err.toString(), }); // re-throw error so these invalid settings are not saved throw err; @@ -117,11 +117,12 @@ async function save() { changed.value = false; } -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.wordMute, - icon: 'fas fa-comment-slash', - bg: 'var(--bg)', - } +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.wordMute, + icon: 'fas fa-comment-slash', }); </script> diff --git a/packages/client/src/pages/share.vue b/packages/client/src/pages/share.vue index 1700944f82..8984823b60 100644 --- a/packages/client/src/pages/share.vue +++ b/packages/client/src/pages/share.vue @@ -22,158 +22,144 @@ </div> </template> -<script lang="ts"> +<script lang="ts" setup> // SPECIFICATION: https://misskey-hub.net/docs/features/share-form.html import { defineComponent } from 'vue'; -import MkButton from '@/components/ui/button.vue'; -import XPostForm from '@/components/post-form.vue'; -import * as os from '@/os'; import { noteVisibilities } from 'misskey-js'; import * as Acct from 'misskey-js/built/acct'; -import * as symbols from '@/symbols'; import * as Misskey from 'misskey-js'; +import MkButton from '@/components/ui/button.vue'; +import XPostForm from '@/components/post-form.vue'; +import * as os from '@/os'; +import { mainRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - XPostForm, - MkButton, - }, - - data() { - return { - [symbols.PAGE_INFO]: { - title: this.$ts.share, - icon: 'fas fa-share-alt' - }, - state: 'fetching' as 'fetching' | 'writing' | 'posted', +const urlParams = new URLSearchParams(window.location.search); +const localOnlyQuery = urlParams.get('localOnly'); +const visibilityQuery = urlParams.get('visibility'); - title: null as string | null, - initialText: null as string | null, - reply: null as Misskey.entities.Note | null, - renote: null as Misskey.entities.Note | null, - visibility: null as string | null, - localOnly: null as boolean | null, - files: [] as Misskey.entities.DriveFile[], - visibleUsers: [] as Misskey.entities.User[], - }; - }, +let state = $ref('fetching' as 'fetching' | 'writing' | 'posted'); +let title = $ref(urlParams.get('title')); +const text = urlParams.get('text'); +const url = urlParams.get('url'); +let initialText = $ref(null as string | null); +let reply = $ref(null as Misskey.entities.Note | null); +let renote = $ref(null as Misskey.entities.Note | null); +let visibility = $ref(noteVisibilities.includes(visibilityQuery) ? visibilityQuery : null); +let localOnly = $ref(localOnlyQuery === '0' ? false : localOnlyQuery === '1' ? true : null); +let files = $ref([] as Misskey.entities.DriveFile[]); +let visibleUsers = $ref([] as Misskey.entities.User[]); - async created() { - const urlParams = new URLSearchParams(window.location.search); +async function init() { + let noteText = ''; + if (title) noteText += `[ ${title} ]\n`; + // Googleニュース対策 + if (text?.startsWith(`${title}.\n`)) noteText += text.replace(`${title}.\n`, ''); + else if (text && title !== text) noteText += `${text}\n`; + if (url) noteText += `${url}`; + initialText = noteText.trim(); - this.title = urlParams.get('title'); - const text = urlParams.get('text'); - const url = urlParams.get('url'); + if (visibility === 'specified') { + const visibleUserIds = urlParams.get('visibleUserIds'); + const visibleAccts = urlParams.get('visibleAccts'); + await Promise.all( + [ + ...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []), + ...(visibleAccts ? visibleAccts.split(',').map(Acct.parse) : []), + ] + // TypeScriptの指示通りに変換する + .map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q) + .map(q => os.api('users/show', q) + .then(user => { + visibleUsers.push(user); + }, () => { + console.error(`Invalid user query: ${JSON.stringify(q)}`); + }), + ), + ); + } - let noteText = ''; - if (this.title) noteText += `[ ${this.title} ]\n`; - // Googleニュース対策 - if (text?.startsWith(`${this.title}.\n`)) noteText += text.replace(`${this.title}.\n`, ''); - else if (text && this.title !== text) noteText += `${text}\n`; - if (url) noteText += `${url}`; - this.initialText = noteText.trim(); + try { + //#region Reply + const replyId = urlParams.get('replyId'); + const replyUri = urlParams.get('replyUri'); + if (replyId) { + reply = await os.api('notes/show', { + noteId: replyId, + }); + } else if (replyUri) { + const obj = await os.api('ap/show', { + uri: replyUri, + }); + if (obj.type === 'Note') { + reply = obj.object; + } + } + //#endregion - const visibility = urlParams.get('visibility'); - if (noteVisibilities.includes(visibility)) { - this.visibility = visibility; + //#region Renote + const renoteId = urlParams.get('renoteId'); + const renoteUri = urlParams.get('renoteUri'); + if (renoteId) { + renote = await os.api('notes/show', { + noteId: renoteId, + }); + } else if (renoteUri) { + const obj = await os.api('ap/show', { + uri: renoteUri, + }); + if (obj.type === 'Note') { + renote = obj.object; + } } + //#endregion - if (this.visibility === 'specified') { - const visibleUserIds = urlParams.get('visibleUserIds'); - const visibleAccts = urlParams.get('visibleAccts'); + //#region Drive files + const fileIds = urlParams.get('fileIds'); + if (fileIds) { await Promise.all( - [ - ...(visibleUserIds ? visibleUserIds.split(',').map(userId => ({ userId })) : []), - ...(visibleAccts ? visibleAccts.split(',').map(Acct.parse) : []) - ] - // TypeScriptの指示通りに変換する - .map(q => 'username' in q ? { username: q.username, host: q.host === null ? undefined : q.host } : q) - .map(q => os.api('users/show', q) - .then(user => { - this.visibleUsers.push(user); + fileIds.split(',') + .map(fileId => os.api('drive/files/show', { fileId }) + .then(file => { + files.push(file); }, () => { - console.error(`Invalid user query: ${JSON.stringify(q)}`); - }) - ) + console.error(`Failed to fetch a file ${fileId}`); + }), + ), ); } + //#endregion + } catch (err) { + os.alert({ + type: 'error', + title: err.message, + text: err.name, + }); + } - const localOnly = urlParams.get('localOnly'); - if (localOnly === '0') this.localOnly = false; - else if (localOnly === '1') this.localOnly = true; + state = 'writing'; +} - try { - //#region Reply - const replyId = urlParams.get('replyId'); - const replyUri = urlParams.get('replyUri'); - if (replyId) { - this.reply = await os.api('notes/show', { - noteId: replyId - }); - } else if (replyUri) { - const obj = await os.api('ap/show', { - uri: replyUri - }); - if (obj.type === 'Note') { - this.reply = obj.object; - } - } - //#endregion +init(); - //#region Renote - const renoteId = urlParams.get('renoteId'); - const renoteUri = urlParams.get('renoteUri'); - if (renoteId) { - this.renote = await os.api('notes/show', { - noteId: renoteId - }); - } else if (renoteUri) { - const obj = await os.api('ap/show', { - uri: renoteUri - }); - if (obj.type === 'Note') { - this.renote = obj.object; - } - } - //#endregion +function close(): void { + window.close(); - //#region Drive files - const fileIds = urlParams.get('fileIds'); - if (fileIds) { - await Promise.all( - fileIds.split(',') - .map(fileId => os.api('drive/files/show', { fileId }) - .then(file => { - this.files.push(file); - }, () => { - console.error(`Failed to fetch a file ${fileId}`); - }) - ) - ); - } - //#endregion - } catch (err) { - os.alert({ - type: 'error', - title: err.message, - text: err.name - }); - } + // 閉じなければ100ms後タイムラインに + window.setTimeout(() => { + mainRouter.push('/'); + }, 100); +} - this.state = 'writing'; - }, +const headerActions = $computed(() => []); - methods: { - close() { - window.close(); +const headerTabs = $computed(() => []); - // 閉じなければ100ms後タイムラインに - window.setTimeout(() => { - this.$router.push('/'); - }, 100); - } - } +definePageMetadata({ + title: i18n.ts.share, + icon: 'fas fa-share-alt', }); </script> diff --git a/packages/client/src/pages/signup-complete.vue b/packages/client/src/pages/signup-complete.vue index 344c9195f7..a97990c129 100644 --- a/packages/client/src/pages/signup-complete.vue +++ b/packages/client/src/pages/signup-complete.vue @@ -7,9 +7,9 @@ <script lang="ts" setup> import { onMounted } from 'vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { login } from '@/account'; import { i18n } from '@/i18n'; +import { definePageMetadata } from '@/scripts/page-metadata'; const props = defineProps<{ code: string; @@ -26,11 +26,13 @@ onMounted(async () => { login(res.i, '/'); }); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.signup, - icon: 'fas fa-user', - }, +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.signup, + icon: 'fas fa-user', }); </script> diff --git a/packages/client/src/pages/tag.vue b/packages/client/src/pages/tag.vue index 045f1ef259..406eb1c988 100644 --- a/packages/client/src/pages/tag.vue +++ b/packages/client/src/pages/tag.vue @@ -7,7 +7,7 @@ <script lang="ts" setup> import { computed } from 'vue'; import XNotes from '@/components/notes.vue'; -import * as symbols from '@/symbols'; +import { definePageMetadata } from '@/scripts/page-metadata'; const props = defineProps<{ tag: string; @@ -21,11 +21,12 @@ const pagination = { })), }; -defineExpose({ - [symbols.PAGE_INFO]: computed(() => ({ - title: props.tag, - icon: 'fas fa-hashtag', - bg: 'var(--bg)', - })), -}); +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: props.tag, + icon: 'fas fa-hashtag', +}))); </script> diff --git a/packages/client/src/pages/theme-editor.vue b/packages/client/src/pages/theme-editor.vue index 2a11c07fd2..548e60614b 100644 --- a/packages/client/src/pages/theme-editor.vue +++ b/packages/client/src/pages/theme-editor.vue @@ -1,67 +1,70 @@ <template> -<MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> - <div class="cwepdizn _formRoot"> - <FormFolder :default-open="true" class="_formBlock"> - <template #label>{{ i18n.ts.backgroundColor }}</template> - <div class="cwepdizn-colors"> - <div class="row"> - <button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> - <div class="preview" :style="{ background: color.forPreview }"></div> - </button> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800" :margin-min="16" :margin-max="32"> + <div class="cwepdizn _formRoot"> + <FormFolder :default-open="true" class="_formBlock"> + <template #label>{{ i18n.ts.backgroundColor }}</template> + <div class="cwepdizn-colors"> + <div class="row"> + <button v-for="color in bgColors.filter(x => x.kind === 'light')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> + <div class="preview" :style="{ background: color.forPreview }"></div> + </button> + </div> + <div class="row"> + <button v-for="color in bgColors.filter(x => x.kind === 'dark')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> + <div class="preview" :style="{ background: color.forPreview }"></div> + </button> + </div> </div> - <div class="row"> - <button v-for="color in bgColors.filter(x => x.kind === 'dark')" :key="color.color" class="color _button" :class="{ active: theme.props.bg === color.color }" @click="setBgColor(color)"> - <div class="preview" :style="{ background: color.forPreview }"></div> - </button> - </div> - </div> - </FormFolder> + </FormFolder> - <FormFolder :default-open="true" class="_formBlock"> - <template #label>{{ i18n.ts.accentColor }}</template> - <div class="cwepdizn-colors"> - <div class="row"> - <button v-for="color in accentColors" :key="color" class="color rounded _button" :class="{ active: theme.props.accent === color }" @click="setAccentColor(color)"> - <div class="preview" :style="{ background: color }"></div> - </button> + <FormFolder :default-open="true" class="_formBlock"> + <template #label>{{ i18n.ts.accentColor }}</template> + <div class="cwepdizn-colors"> + <div class="row"> + <button v-for="color in accentColors" :key="color" class="color rounded _button" :class="{ active: theme.props.accent === color }" @click="setAccentColor(color)"> + <div class="preview" :style="{ background: color }"></div> + </button> + </div> </div> - </div> - </FormFolder> + </FormFolder> - <FormFolder :default-open="true" class="_formBlock"> - <template #label>{{ i18n.ts.textColor }}</template> - <div class="cwepdizn-colors"> - <div class="row"> - <button v-for="color in fgColors" :key="color" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }" @click="setFgColor(color)"> - <div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div> - </button> + <FormFolder :default-open="true" class="_formBlock"> + <template #label>{{ i18n.ts.textColor }}</template> + <div class="cwepdizn-colors"> + <div class="row"> + <button v-for="color in fgColors" :key="color" class="color char _button" :class="{ active: (theme.props.fg === color.forLight) || (theme.props.fg === color.forDark) }" @click="setFgColor(color)"> + <div class="preview" :style="{ color: color.forPreview ? color.forPreview : theme.base === 'light' ? '#5f5f5f' : '#dadada' }">A</div> + </button> + </div> </div> - </div> - </FormFolder> + </FormFolder> - <FormFolder :default-open="false" class="_formBlock"> - <template #icon><i class="fas fa-code"></i></template> - <template #label>{{ i18n.ts.editCode }}</template> + <FormFolder :default-open="false" class="_formBlock"> + <template #icon><i class="fas fa-code"></i></template> + <template #label>{{ i18n.ts.editCode }}</template> - <div class="_formRoot"> - <FormTextarea v-model="themeCode" tall class="_formBlock"> - <template #label>{{ i18n.ts._theme.code }}</template> - </FormTextarea> - <FormButton primary class="_formBlock" @click="applyThemeCode">{{ i18n.ts.apply }}</FormButton> - </div> - </FormFolder> + <div class="_formRoot"> + <FormTextarea v-model="themeCode" tall class="_formBlock"> + <template #label>{{ i18n.ts._theme.code }}</template> + </FormTextarea> + <FormButton primary class="_formBlock" @click="applyThemeCode">{{ i18n.ts.apply }}</FormButton> + </div> + </FormFolder> - <FormFolder :default-open="false" class="_formBlock"> - <template #label>{{ i18n.ts.addDescription }}</template> + <FormFolder :default-open="false" class="_formBlock"> + <template #label>{{ i18n.ts.addDescription }}</template> - <div class="_formRoot"> - <FormTextarea v-model="description"> - <template #label>{{ i18n.ts._theme.description }}</template> - </FormTextarea> - </div> - </FormFolder> - </div> -</MkSpacer> + <div class="_formRoot"> + <FormTextarea v-model="description"> + <template #label>{{ i18n.ts._theme.description }}</template> + </FormTextarea> + </div> + </FormFolder> + </div> + </MkSpacer> +</MkStickyContainer> </template> <script lang="ts" setup> @@ -75,6 +78,7 @@ import FormButton from '@/components/ui/button.vue'; import FormTextarea from '@/components/form/textarea.vue'; import FormFolder from '@/components/form/folder.vue'; +import { $i } from '@/account'; import { Theme, applyTheme } from '@/scripts/theme'; import lightTheme from '@/themes/_light.json5'; import darkTheme from '@/themes/_dark.json5'; @@ -82,9 +86,9 @@ import { host } from '@/config'; import * as os from '@/os'; import { ColdDeviceStorage, defaultStore } from '@/store'; import { addTheme } from '@/theme-store'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; import { useLeaveGuard } from '@/scripts/use-leave-guard'; +import { definePageMetadata } from '@/scripts/page-metadata'; const bgColors = [ { color: '#f5f5f5', kind: 'light', forPreview: '#f5f5f5' }, @@ -115,7 +119,7 @@ const fgColors = [ { color: 'pink', forLight: '#84667d', forDark: '#e4d1e0', forPreview: '#b12390' }, ]; -const theme = $ref<Partial<Theme>>({ +let theme = $ref<Partial<Theme>>({ base: 'light', props: lightTheme.props, }); @@ -188,7 +192,7 @@ async function saveAs() { theme.name = name; theme.author = `@${$i.username}@${toUnicode(host)}`; if (description) theme.desc = description; - addTheme(theme); + await addTheme(theme); applyTheme(theme); if (defaultStore.state.darkMode) { ColdDeviceStorage.set('darkTheme', theme); @@ -204,23 +208,23 @@ async function saveAs() { watch($$(theme), apply, { deep: true }); -defineExpose({ - [symbols.PAGE_INFO]: { - title: i18n.ts.themeEditor, - icon: 'fas fa-palette', - bg: 'var(--bg)', - actions: [{ - asFullButton: true, - icon: 'fas fa-eye', - text: i18n.ts.preview, - handler: showPreview, - }, { - asFullButton: true, - icon: 'fas fa-check', - text: i18n.ts.saveAs, - handler: saveAs, - }], - }, +const headerActions = $computed(() => [{ + asFullButton: true, + icon: 'fas fa-eye', + text: i18n.ts.preview, + handler: showPreview, +}, { + asFullButton: true, + icon: 'fas fa-check', + text: i18n.ts.saveAs, + handler: saveAs, +}]); + +const headerTabs = $computed(() => []); + +definePageMetadata({ + title: i18n.ts.themeEditor, + icon: 'fas fa-palette', }); </script> diff --git a/packages/client/src/pages/timeline.vue b/packages/client/src/pages/timeline.vue index fe3dbc3cff..8554a9aebc 100644 --- a/packages/client/src/pages/timeline.vue +++ b/packages/client/src/pages/timeline.vue @@ -1,39 +1,37 @@ <template> -<MkSpacer :content-max="800"> - <div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf"> - <XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/> - <XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="src" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="800"> + <div ref="rootEl" v-hotkey.global="keymap" class="cmuxhskf"> + <XTutorial v-if="$store.reactiveState.tutorial.value != -1" class="tutorial _block"/> + <XPostForm v-if="$store.reactiveState.showFixedPostForm.value" class="post-form _block" fixed/> - <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> - <div class="tl _block"> - <XTimeline ref="tl" :key="src" - class="tl" - :src="src" - :sound="true" - @queue="queueUpdated" - /> + <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline + ref="tl" :key="src" + class="tl" + :src="src" + :sound="true" + @queue="queueUpdated" + /> + </div> </div> - </div> -</MkSpacer> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -export default { - name: 'MkTimelinePage', -}; -</script> - <script lang="ts" setup> import { defineAsyncComponent, computed, watch } from 'vue'; import XTimeline from '@/components/timeline.vue'; import XPostForm from '@/components/post-form.vue'; import { scroll } from '@/scripts/scroll'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { defaultStore } from '@/store'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; import { $i } from '@/account'; +import { definePageMetadata } from '@/scripts/page-metadata'; const XTutorial = defineAsyncComponent(() => import('./timeline.tutorial.vue')); @@ -47,7 +45,7 @@ const tlComponent = $ref<InstanceType<typeof XTimeline>>(); const rootEl = $ref<HTMLElement>(); let queue = $ref(0); -const src = $computed(() => defaultStore.reactiveState.tl.value.src); +const src = $computed({ get: () => defaultStore.reactiveState.tl.value.src, set: (x) => saveSrc(x) }); watch ($$(src), () => queue = 0); @@ -111,55 +109,49 @@ function focus(): void { tlComponent.focus(); } -defineExpose({ - [symbols.PAGE_INFO]: computed(() => ({ - title: i18n.ts.timeline, - icon: src === 'local' ? 'fas fa-comments' : src === 'social' ? 'fas fa-share-alt' : src === 'global' ? 'fas fa-globe' : 'fas fa-home', - bg: 'var(--bg)', - actions: [{ - icon: 'fas fa-list-ul', - text: i18n.ts.lists, - handler: chooseList, - }, { - icon: 'fas fa-satellite', - text: i18n.ts.antennas, - handler: chooseAntenna, - }, { - icon: 'fas fa-satellite-dish', - text: i18n.ts.channel, - handler: chooseChannel, - }, { - icon: 'fas fa-calendar-alt', - text: i18n.ts.jumpToSpecifiedDate, - handler: timetravel, - }], - tabs: [{ - active: src === 'home', - title: i18n.ts._timelines.home, - icon: 'fas fa-home', - iconOnly: true, - onClick: () => { saveSrc('home'); }, - }, ...(isLocalTimelineAvailable ? [{ - active: src === 'local', - title: i18n.ts._timelines.local, - icon: 'fas fa-comments', - iconOnly: true, - onClick: () => { saveSrc('local'); }, - }, { - active: src === 'social', - title: i18n.ts._timelines.social, - icon: 'fas fa-share-alt', - iconOnly: true, - onClick: () => { saveSrc('social'); }, - }] : []), ...(isGlobalTimelineAvailable ? [{ - active: src === 'global', - title: i18n.ts._timelines.global, - icon: 'fas fa-globe', - iconOnly: true, - onClick: () => { saveSrc('global'); }, - }] : [])], - })), -}); +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => [{ + key: 'home', + title: i18n.ts._timelines.home, + icon: 'fas fa-home', + iconOnly: true, +}, ...(isLocalTimelineAvailable ? [{ + key: 'local', + title: i18n.ts._timelines.local, + icon: 'fas fa-comments', + iconOnly: true, +}, { + key: 'social', + title: i18n.ts._timelines.social, + icon: 'fas fa-share-alt', + iconOnly: true, +}] : []), ...(isGlobalTimelineAvailable ? [{ + key: 'global', + title: i18n.ts._timelines.global, + icon: 'fas fa-globe', + iconOnly: true, +}] : []), { + icon: 'fas fa-list-ul', + title: i18n.ts.lists, + iconOnly: true, + onClick: chooseList, +}, { + icon: 'fas fa-satellite', + title: i18n.ts.antennas, + iconOnly: true, + onClick: chooseAntenna, +}, { + icon: 'fas fa-satellite-dish', + title: i18n.ts.channel, + iconOnly: true, + onClick: chooseChannel, +}]); + +definePageMetadata(computed(() => ({ + title: i18n.ts.timeline, + icon: src === 'local' ? 'fas fa-comments' : src === 'social' ? 'fas fa-share-alt' : src === 'global' ? 'fas fa-globe' : 'fas fa-home', +}))); </script> <style lang="scss" scoped> @@ -185,7 +177,7 @@ defineExpose({ > .tl { background: var(--bg); border-radius: var(--radius); - overflow: clip; + overflow: hidden; overflow: clip; } } </style> diff --git a/packages/client/src/pages/user-info.vue b/packages/client/src/pages/user-info.vue index 54e1f13021..fd24ec2848 100644 --- a/packages/client/src/pages/user-info.vue +++ b/packages/client/src/pages/user-info.vue @@ -1,258 +1,485 @@ <template> -<MkSpacer :content-max="500" :margin-min="16" :margin-max="32"> - <FormSuspense :p="init"> - <div class="_formRoot"> - <div class="_formBlock aeakzknw"> - <MkAvatar class="avatar" :user="user" :show-indicator="true"/> - </div> - - <FormLink :to="userPage(user)">Profile</FormLink> - - <div class="_formBlock"> - <MkKeyValue :copy="acct(user)" oneline style="margin: 1em 0;"> - <template #key>Acct</template> - <template #value><span class="_monospace">{{ acct(user) }}</span></template> - </MkKeyValue> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="600" :margin-min="16" :margin-max="32"> + <FormSuspense :p="init"> + <div v-if="tab === 'overview'" class="_formRoot"> + <div class="_formBlock aeakzknw"> + <MkAvatar class="avatar" :user="user" :show-indicator="true"/> + <div class="body"> + <span class="name"><MkUserName class="name" :user="user"/></span> + <span class="sub"><span class="acct _monospace">@{{ acct(user) }}</span></span> + <span class="state"> + <span v-if="suspended" class="suspended">Suspended</span> + <span v-if="silenced" class="silenced">Silenced</span> + <span v-if="moderator" class="moderator">Moderator</span> + </span> + </div> + </div> - <MkKeyValue :copy="user.id" oneline style="margin: 1em 0;"> - <template #key>ID</template> - <template #value><span class="_monospace">{{ user.id }}</span></template> - </MkKeyValue> - </div> + <MkInfo v-if="user.username.includes('.')" class="_formBlock">{{ i18n.ts.isSystemAccount }}</MkInfo> - <FormSection v-if="iAmModerator"> - <template #label>Moderation</template> - <FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:modelValue="toggleModerator">{{ $ts.moderator }}</FormSwitch> - <FormSwitch v-model="silenced" class="_formBlock" @update:modelValue="toggleSilence">{{ $ts.silence }}</FormSwitch> - <FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.suspend }}</FormSwitch> - {{ $ts.reflectMayTakeTime }} - <FormButton v-if="user.host == null && iAmModerator" class="_formBlock" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton> - </FormSection> + <div v-if="user.url" class="_formLinksGrid _formBlock"> + <FormLink :to="userPage(user)">Profile</FormLink> + <FormLink :to="user.url" :external="true">Profile (remote)</FormLink> + </div> + <FormLink v-else class="_formBlock" :to="userPage(user)">Profile</FormLink> - <FormSection> - <template #label>ActivityPub</template> + <FormLink v-if="user.host" class="_formBlock" :to="`/instance-info/${user.host}`">{{ i18n.ts.instanceInfo }}</FormLink> <div class="_formBlock"> - <MkKeyValue v-if="user.host" oneline style="margin: 1em 0;"> - <template #key>{{ $ts.instanceInfo }}</template> - <template #value><MkA :to="`/instance-info/${user.host}`" class="_link">{{ user.host }} <i class="fas fa-angle-right"></i></MkA></template> + <MkKeyValue :copy="user.id" oneline style="margin: 1em 0;"> + <template #key>ID</template> + <template #value><span class="_monospace">{{ user.id }}</span></template> </MkKeyValue> - <MkKeyValue v-else oneline style="margin: 1em 0;"> - <template #key>{{ $ts.instanceInfo }}</template> - <template #value>(Local user)</template> + <!-- 要る? + <MkKeyValue v-if="ips.length > 0" :copy="user.id" oneline style="margin: 1em 0;"> + <template #key>IP (recent)</template> + <template #value><span class="_monospace">{{ ips[0].ip }}</span></template> </MkKeyValue> + --> <MkKeyValue oneline style="margin: 1em 0;"> - <template #key>{{ $ts.updatedAt }}</template> - <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template> + <template #key>{{ i18n.ts.createdAt }}</template> + <template #value><span class="_monospace"><MkTime :time="user.createdAt" :mode="'detail'"/></span></template> + </MkKeyValue> + <MkKeyValue v-if="info" oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.lastActiveDate }}</template> + <template #value><span class="_monospace"><MkTime :time="info.lastActiveDate" :mode="'detail'"/></span></template> </MkKeyValue> - <MkKeyValue v-if="ap" oneline style="margin: 1em 0;"> - <template #key>Type</template> - <template #value><span class="_monospace">{{ ap.type }}</span></template> + <MkKeyValue v-if="info" oneline style="margin: 1em 0;"> + <template #key>{{ i18n.ts.email }}</template> + <template #value><span class="_monospace">{{ info.email }}</span></template> </MkKeyValue> </div> - <FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton> - </FormSection> + <FormSection> + <template #label>ActivityPub</template> + + <div class="_formBlock"> + <MkKeyValue v-if="user.host" oneline style="margin: 1em 0;"> + <template #key>{{ $ts.instanceInfo }}</template> + <template #value><MkA :to="`/instance-info/${user.host}`" class="_link">{{ user.host }} <i class="fas fa-angle-right"></i></MkA></template> + </MkKeyValue> + <MkKeyValue v-else oneline style="margin: 1em 0;"> + <template #key>{{ $ts.instanceInfo }}</template> + <template #value>(Local user)</template> + </MkKeyValue> + <MkKeyValue oneline style="margin: 1em 0;"> + <template #key>{{ $ts.updatedAt }}</template> + <template #value><MkTime v-if="user.lastFetchedAt" mode="detail" :time="user.lastFetchedAt"/><span v-else>N/A</span></template> + </MkKeyValue> + <MkKeyValue v-if="ap" oneline style="margin: 1em 0;"> + <template #key>Type</template> + <template #value><span class="_monospace">{{ ap.type }}</span></template> + </MkKeyValue> + </div> + + <FormButton v-if="user.host != null" class="_formBlock" @click="updateRemoteUser"><i class="fas fa-sync"></i> {{ $ts.updateRemoteUser }}</FormButton> + + <FormFolder class="_formBlock"> + <template #label>Raw</template> + + <MkObjectView v-if="ap" tall :value="ap"> + </MkObjectView> + </FormFolder> + </FormSection> + </div> + <div v-else-if="tab === 'moderation'" class="_formRoot"> + <FormSwitch v-if="user.host == null && $i.isAdmin && (moderator || !user.isAdmin)" v-model="moderator" class="_formBlock" @update:modelValue="toggleModerator">{{ $ts.moderator }}</FormSwitch> + <FormSwitch v-model="silenced" class="_formBlock" @update:modelValue="toggleSilence">{{ $ts.silence }}</FormSwitch> + <FormSwitch v-model="suspended" class="_formBlock" @update:modelValue="toggleSuspend">{{ $ts.suspend }}</FormSwitch> + {{ $ts.reflectMayTakeTime }} + <div class="_formBlock"> + <FormButton v-if="user.host == null && iAmModerator" inline style="margin-right: 8px;" @click="resetPassword"><i class="fas fa-key"></i> {{ $ts.resetPassword }}</FormButton> + <FormButton v-if="$i.isAdmin" inline danger @click="deleteAccount">{{ $ts.deleteAccount }}</FormButton> + </div> + <FormTextarea v-model="moderationNote" manual-save class="_formBlock"> + <template #label>Moderation note</template> + </FormTextarea> + <FormFolder class="_formBlock"> + <template #label>IP</template> + <MkInfo v-if="!iAmAdmin" warn>{{ i18n.ts.requireAdminForView }}</MkInfo> + <MkInfo v-else>The date is the IP address was first acknowledged.</MkInfo> + <template v-if="iAmAdmin && ips"> + <div v-for="record in ips" :key="record.ip" class="_monospace" :class="$style.ip" style="margin: 1em 0;"> + <span class="date">{{ record.createdAt }}</span> + <span class="ip">{{ record.ip }}</span> + </div> + </template> + </FormFolder> + <FormFolder class="_formBlock"> + <template #label>{{ i18n.ts.files }}</template> + + <MkFileListForAdmin :pagination="filesPagination" view-mode="grid"/> + </FormFolder> + <FormSection> + <template #label>Drive Capacity Override</template> - <MkObjectView v-if="info && $i.isAdmin" tall :value="info"> - </MkObjectView> + <FormInput v-if="user.host == null" v-model="driveCapacityOverrideMb" inline :manual-save="true" type="number" :placeholder="i18n.t('defaultValueIs', { value: instance.driveCapacityPerLocalUserMb })" @update:model-value="applyDriveCapacityOverride"> + <template #label>{{ i18n.ts.driveCapOverrideLabel }}</template> + <template #suffix>MB</template> + <template #caption> + {{ i18n.ts.driveCapOverrideCaption }} + </template> + </FormInput> + </FormSection> + </div> + <div v-else-if="tab === 'chart'" class="_formRoot"> + <div class="cmhjzshm"> + <div class="selects"> + <MkSelect v-model="chartSrc" style="margin: 0 10px 0 0; flex: 1;"> + <option value="per-user-notes">{{ $ts.notes }}</option> + </MkSelect> + </div> + <div class="charts"> + <div class="label">{{ i18n.t('recentNHours', { n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="hour" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> + <div class="label">{{ i18n.t('recentNDays', { n: 90 }) }}</div> + <MkChart class="chart" :src="chartSrc" span="day" :limit="90" :args="{ user, withoutAll: true }" :detailed="true"></MkChart> + </div> + </div> + </div> + <div v-else-if="tab === 'raw'" class="_formRoot"> + <MkObjectView v-if="info && $i.isAdmin" tall :value="info"> + </MkObjectView> - <MkObjectView tall :value="user"> - </MkObjectView> - </div> - </FormSuspense> -</MkSpacer> + <MkObjectView tall :value="user"> + </MkObjectView> + </div> + </FormSuspense> + </MkSpacer> +</MkStickyContainer> </template> -<script lang="ts"> -import { computed, defineAsyncComponent, defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import MkChart from '@/components/chart.vue'; import MkObjectView from '@/components/object-view.vue'; import FormTextarea from '@/components/form/textarea.vue'; import FormSwitch from '@/components/form/switch.vue'; import FormLink from '@/components/form/link.vue'; import FormSection from '@/components/form/section.vue'; import FormButton from '@/components/ui/button.vue'; +import FormInput from '@/components/form/input.vue'; +import FormSplit from '@/components/form/split.vue'; +import FormFolder from '@/components/form/folder.vue'; import MkKeyValue from '@/components/key-value.vue'; +import MkSelect from '@/components/form/select.vue'; import FormSuspense from '@/components/form/suspense.vue'; +import MkFileListForAdmin from '@/components/file-list-for-admin.vue'; +import MkInfo from '@/components/ui/info.vue'; import * as os from '@/os'; import number from '@/filters/number'; import bytes from '@/filters/bytes'; -import * as symbols from '@/symbols'; import { url } from '@/config'; import { userPage, acct } from '@/filters/user'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { iAmAdmin, iAmModerator } from '@/account'; +import { instance } from '@/instance'; -export default defineComponent({ - components: { - FormSection, - FormTextarea, - FormSwitch, - MkObjectView, - FormButton, - FormLink, - MkKeyValue, - FormSuspense, - }, +const props = defineProps<{ + userId: string; +}>(); - props: { - userId: { - type: String, - required: true - } - }, - - data() { - return { - [symbols.PAGE_INFO]: computed(() => ({ - title: this.user ? acct(this.user) : this.$ts.userInfo, - icon: 'fas fa-info-circle', - bg: 'var(--bg)', - actions: this.user ? [this.user.url ? { - text: this.user.url, - icon: 'fas fa-external-link-alt', - handler: () => { - window.open(this.user.url, '_blank'); - } - } : undefined].filter(x => x !== undefined) : [], - })), - init: null, - user: null, - info: null, - ap: null, - moderator: false, - silenced: false, - suspended: false, - }; - }, +let tab = $ref('overview'); +let chartSrc = $ref('per-user-notes'); +let user = $ref<null | misskey.entities.UserDetailed>(); +let init = $ref<ReturnType<typeof createFetcher>>(); +let info = $ref(); +let ips = $ref(null); +let ap = $ref(null); +let moderator = $ref(false); +let silenced = $ref(false); +let suspended = $ref(false); +let driveCapacityOverrideMb: number | null = $ref(0); +let moderationNote = $ref(''); +const filesPagination = { + endpoint: 'admin/drive/files' as const, + limit: 10, + params: computed(() => ({ + userId: props.userId, + })), +}; - computed: { - iAmModerator(): boolean { - return this.$i && (this.$i.isAdmin || this.$i.isModerator); - } - }, +function createFetcher() { + if (iAmModerator) { + return () => Promise.all([os.api('users/show', { + userId: props.userId, + }), os.api('admin/show-user', { + userId: props.userId, + }), iAmAdmin ? os.api('admin/get-user-ips', { + userId: props.userId, + }) : Promise.resolve(null)]).then(([_user, _info, _ips]) => { + user = _user; + info = _info; + ips = _ips; + moderator = info.isModerator; + silenced = info.isSilenced; + suspended = info.isSuspended; + driveCapacityOverrideMb = user.driveCapacityOverrideMb; + moderationNote = info.moderationNote; - watch: { - userId: { - handler() { - this.init = this.createFetcher(); - }, - immediate: true - }, - user() { - os.api('ap/get', { - uri: this.user.uri || `${url}/users/${this.user.id}` - }).then(res => { - this.ap = res; + watch($$(moderationNote), async () => { + await os.api('admin/update-user-note', { userId: user.id, text: moderationNote }); + await refreshUser(); }); - } - }, + }); + } else { + return () => os.api('users/show', { + userId: props.userId, + }).then((res) => { + user = res; + }); + } +} - methods: { - number, - bytes, - userPage, - acct, +function refreshUser() { + init = createFetcher(); +} - createFetcher() { - if (this.iAmModerator) { - return () => Promise.all([os.api('users/show', { - userId: this.userId - }), os.api('admin/show-user', { - userId: this.userId - })]).then(([user, info]) => { - this.user = user; - this.info = info; - this.moderator = this.info.isModerator; - this.silenced = this.info.isSilenced; - this.suspended = this.info.isSuspended; - }); - } else { - return () => os.api('users/show', { - userId: this.userId - }).then((user) => { - this.user = user; - }); - } - }, +async function updateRemoteUser() { + await os.apiWithDialog('federation/update-remote-user', { userId: user.id }); + refreshUser(); +} - refreshUser() { - this.init = this.createFetcher(); - }, +async function resetPassword() { + const { password } = await os.api('admin/reset-password', { + userId: user.id, + }); - async updateRemoteUser() { - await os.apiWithDialog('federation/update-remote-user', { userId: this.user.id }); - this.refreshUser(); - }, + os.alert({ + type: 'success', + text: i18n.t('newPasswordIs', { password }), + }); +} - async resetPassword() { - const { password } = await os.api('admin/reset-password', { - userId: this.user.id, - }); +async function toggleSilence(v) { + const confirm = await os.confirm({ + type: 'warning', + text: v ? i18n.ts.silenceConfirm : i18n.ts.unsilenceConfirm, + }); + if (confirm.canceled) { + silenced = !v; + } else { + await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: user.id }); + await refreshUser(); + } +} - os.alert({ - type: 'success', - text: this.$t('newPasswordIs', { password }) - }); - }, +async function toggleSuspend(v) { + const confirm = await os.confirm({ + type: 'warning', + text: v ? i18n.ts.suspendConfirm : i18n.ts.unsuspendConfirm, + }); + if (confirm.canceled) { + suspended = !v; + } else { + await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: user.id }); + await refreshUser(); + } +} - async toggleSilence(v) { - const confirm = await os.confirm({ - type: 'warning', - text: v ? this.$ts.silenceConfirm : this.$ts.unsilenceConfirm, - }); - if (confirm.canceled) { - this.silenced = !v; - } else { - await os.api(v ? 'admin/silence-user' : 'admin/unsilence-user', { userId: this.user.id }); - await this.refreshUser(); - } - }, +async function toggleModerator(v) { + await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: user.id }); + await refreshUser(); +} - async toggleSuspend(v) { - const confirm = await os.confirm({ - type: 'warning', - text: v ? this.$ts.suspendConfirm : this.$ts.unsuspendConfirm, - }); - if (confirm.canceled) { - this.suspended = !v; - } else { - await os.api(v ? 'admin/suspend-user' : 'admin/unsuspend-user', { userId: this.user.id }); - await this.refreshUser(); - } - }, +async function deleteAllFiles() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteAllFilesConfirm, + }); + if (confirm.canceled) return; + const process = async () => { + await os.api('admin/delete-all-files-of-a-user', { userId: user.id }); + os.success(); + }; + await process().catch(err => { + os.alert({ + type: 'error', + text: err.toString(), + }); + }); + await refreshUser(); +} - async toggleModerator(v) { - await os.api(v ? 'admin/moderators/add' : 'admin/moderators/remove', { userId: this.user.id }); - await this.refreshUser(); - }, +async function applyDriveCapacityOverride() { + let driveCapOrMb = driveCapacityOverrideMb; + if (driveCapacityOverrideMb && driveCapacityOverrideMb < 0) { + driveCapOrMb = null; + } + try { + await os.apiWithDialog('admin/drive-capacity-override', { userId: user.id, overrideMb: driveCapOrMb }); + await refreshUser(); + } catch (err) { + os.alert({ + type: 'error', + text: err.toString(), + }); + } +} - async deleteAllFiles() { - const confirm = await os.confirm({ - type: 'warning', - text: this.$ts.deleteAllFilesConfirm, - }); - if (confirm.canceled) return; - const process = async () => { - await os.api('admin/delete-all-files-of-a-user', { userId: this.user.id }); - os.success(); - }; - await process().catch(err => { - os.alert({ - type: 'error', - text: err.toString(), - }); - }); - await this.refreshUser(); - }, +async function deleteAccount() { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.ts.deleteAccountConfirm, + }); + if (confirm.canceled) return; + + const typed = await os.inputText({ + text: i18n.t('typeToConfirm', { x: user?.username }), + }); + if (typed.canceled) return; + + if (typed.result === user?.username) { + await os.apiWithDialog('admin/delete-account', { + userId: user.id, + }); + } else { + os.alert({ + type: 'error', + text: 'input not match', + }); } +} + +watch(() => props.userId, () => { + init = createFetcher(); +}, { + immediate: true, +}); + +watch($$(user), () => { + os.api('ap/get', { + uri: user.uri ?? `${url}/users/${user.id}`, + }).then(res => { + ap = res; + }); }); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => [{ + key: 'overview', + title: i18n.ts.overview, + icon: 'fas fa-info-circle', +}, iAmModerator ? { + key: 'moderation', + title: i18n.ts.moderation, + icon: 'fas fa-shield-halved', +} : null, { + key: 'chart', + title: i18n.ts.charts, + icon: 'fas fa-chart-simple', +}, { + key: 'raw', + title: 'Raw', + icon: 'fas fa-code', +}].filter(x => x != null)); + +definePageMetadata(computed(() => ({ + title: user ? acct(user) : i18n.ts.userInfo, + icon: 'fas fa-info-circle', +}))); </script> <style lang="scss" scoped> .aeakzknw { + display: flex; + align-items: center; + > .avatar { display: block; width: 64px; height: 64px; + margin-right: 16px; + } + + > .body { + flex: 1; + overflow: hidden; + + > .name { + display: block; + width: 100%; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > .sub { + display: block; + width: 100%; + font-size: 85%; + opacity: 0.7; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + > .state { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-top: 4px; + + &:empty { + display: none; + } + + > .suspended, > .silenced, > .moderator { + display: inline-block; + border: solid 1px; + border-radius: 6px; + padding: 2px 6px; + font-size: 85%; + } + + > .suspended { + color: var(--error); + border-color: var(--error); + } + + > .silenced { + color: var(--warn); + border-color: var(--warn); + } + + > .moderator { + color: var(--success); + border-color: var(--success); + } + } + } +} + +.cmhjzshm { + > .selects { + display: flex; + margin: 0 0 16px 0; + } + + > .charts { + > .label { + margin-bottom: 12px; + font-weight: bold; + } + } +} +</style> + +<style lang="scss" module> +.ip { + display: flex; + + > :global(.date) { + opacity: 0.7; + } + + > :global(.ip) { + margin-left: auto; } } </style> diff --git a/packages/client/src/pages/user-list-timeline.vue b/packages/client/src/pages/user-list-timeline.vue index 4476567cfb..3fca6f1416 100644 --- a/packages/client/src/pages/user-list-timeline.vue +++ b/packages/client/src/pages/user-list-timeline.vue @@ -1,104 +1,85 @@ <template> -<div v-hotkey.global="keymap" v-size="{ min: [800] }" class="eqqrhokj"> - <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> - <div class="tl _block"> - <XTimeline ref="tl" :key="listId" - class="tl" - src="list" - :list="listId" - :sound="true" - @queue="queueUpdated" - /> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <div ref="rootEl" v-size="{ min: [800] }" class="eqqrhokj"> + <div v-if="queue > 0" class="new"><button class="_buttonPrimary" @click="top()">{{ $ts.newNoteRecived }}</button></div> + <div class="tl _block"> + <XTimeline + ref="tlEl" :key="listId" + class="tl" + src="list" + :list="listId" + :sound="true" + @queue="queueUpdated" + /> + </div> </div> -</div> +</MkStickyContainer> </template> -<script lang="ts"> -import { defineComponent, defineAsyncComponent, computed } from 'vue'; +<script lang="ts" setup> +import { computed, watch, inject } from 'vue'; import XTimeline from '@/components/timeline.vue'; import { scroll } from '@/scripts/scroll'; import * as os from '@/os'; -import * as symbols from '@/symbols'; +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - XTimeline, - }, +const router = useRouter(); - props: { - listId: { - type: String, - required: true - } - }, +const props = defineProps<{ + listId: string; +}>(); - data() { - return { - list: null, - queue: 0, - [symbols.PAGE_INFO]: computed(() => this.list ? { - title: this.list.name, - icon: 'fas fa-list-ul', - bg: 'var(--bg)', - actions: [{ - icon: 'fas fa-calendar-alt', - text: this.$ts.jumpToSpecifiedDate, - handler: this.timetravel - }, { - icon: 'fas fa-cog', - text: this.$ts.settings, - handler: this.settings - }], - } : null), - }; - }, +let list = $ref(null); +let queue = $ref(0); +let tlEl = $ref<InstanceType<typeof XTimeline>>(); +let rootEl = $ref<HTMLElement>(); - computed: { - keymap(): any { - return { - 't': this.focus - }; - }, - }, +watch(() => props.listId, async () => { + list = await os.api('users/lists/show', { + listId: props.listId, + }); +}, { immediate: true }); - watch: { - listId: { - async handler() { - this.list = await os.api('users/lists/show', { - listId: this.listId - }); - }, - immediate: true - } - }, +function queueUpdated(q) { + queue = q; +} - methods: { - queueUpdated(q) { - this.queue = q; - }, +function top() { + scroll(rootEl, { top: 0 }); +} - top() { - scroll(this.$el, { top: 0 }); - }, +function settings() { + router.push(`/my/lists/${props.listId}`); +} - settings() { - this.$router.push(`/my/lists/${this.listId}`); - }, +async function timetravel() { + const { canceled, result: date } = await os.inputDate({ + title: i18n.ts.date, + }); + if (canceled) return; - async timetravel() { - const { canceled, result: date } = await os.inputDate({ - title: this.$ts.date, - }); - if (canceled) return; + tlEl.timetravel(date); +} - this.$refs.tl.timetravel(date); - }, +const headerActions = $computed(() => list ? [{ + icon: 'fas fa-calendar-alt', + text: i18n.ts.jumpToSpecifiedDate, + handler: timetravel, +}, { + icon: 'fas fa-cog', + text: i18n.ts.settings, + handler: settings, +}] : []); - focus() { - (this.$refs.tl as any).focus(); - } - } -}); +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => list ? { + title: list.name, + icon: 'fas fa-list-ul', +} : null)); </script> <style lang="scss" scoped> @@ -122,7 +103,7 @@ export default defineComponent({ > .tl { background: var(--bg); border-radius: var(--radius); - overflow: clip; + overflow: hidden; overflow: clip; } &.min-width_800px { diff --git a/packages/client/src/pages/user/follow-list.vue b/packages/client/src/pages/user/follow-list.vue index 98a1fc0f86..e84b7ff57e 100644 --- a/packages/client/src/pages/user/follow-list.vue +++ b/packages/client/src/pages/user/follow-list.vue @@ -1,7 +1,7 @@ <template> <div> <MkPagination v-slot="{items}" ref="list" :pagination="type === 'following' ? followingPagination : followersPagination" class="mk-following-or-followers"> - <div class="users _isolated"> + <div class="users"> <MkUserInfo v-for="user in items.map(x => type === 'following' ? x.followee : x.follower)" :key="user.id" class="user" :user="user"/> </div> </MkPagination> diff --git a/packages/client/src/pages/user/followers.vue b/packages/client/src/pages/user/followers.vue new file mode 100644 index 0000000000..296a4b7b4d --- /dev/null +++ b/packages/client/src/pages/user/followers.vue @@ -0,0 +1,61 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1000"> + <transition name="fade" mode="out-in"> + <div v-if="user"> + <XFollowList :user="user" type="followers"/> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; +import XFollowList from './follow-list.vue'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + acct: string; +}>(), { +}); + +let user = $ref<null | misskey.entities.UserDetailed>(null); +let error = $ref(null); + +function fetchUser(): void { + if (props.acct == null) return; + user = null; + os.api('users/show', Acct.parse(props.acct)).then(u => { + user = u; + }).catch(err => { + error = err; + }); +} + +watch(() => props.acct, fetchUser, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => user ? { + icon: 'fas fa-user', + title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`, + subtitle: i18n.ts.followers, + userName: user, + avatar: user, +} : null)); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/client/src/pages/user/following.vue b/packages/client/src/pages/user/following.vue new file mode 100644 index 0000000000..d1753fe7d5 --- /dev/null +++ b/packages/client/src/pages/user/following.vue @@ -0,0 +1,61 @@ +<template> +<MkStickyContainer> + <template #header><MkPageHeader :actions="headerActions" :tabs="headerTabs"/></template> + <MkSpacer :content-max="1000"> + <transition name="fade" mode="out-in"> + <div v-if="user"> + <XFollowList :user="user" type="following"/> + </div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> + </MkSpacer> +</MkStickyContainer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; +import XFollowList from './follow-list.vue'; +import * as os from '@/os'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; + +const props = withDefaults(defineProps<{ + acct: string; +}>(), { +}); + +let user = $ref<null | misskey.entities.UserDetailed>(null); +let error = $ref(null); + +function fetchUser(): void { + if (props.acct == null) return; + user = null; + os.api('users/show', Acct.parse(props.acct)).then(u => { + user = u; + }).catch(err => { + error = err; + }); +} + +watch(() => props.acct, fetchUser, { + immediate: true, +}); + +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => user ? { + icon: 'fas fa-user', + title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`, + subtitle: i18n.ts.following, + userName: user, + avatar: user, +} : null)); +</script> + +<style lang="scss" scoped> +</style> diff --git a/packages/client/src/pages/user/gallery.vue b/packages/client/src/pages/user/gallery.vue index 07dda4a292..6af28d455b 100644 --- a/packages/client/src/pages/user/gallery.vue +++ b/packages/client/src/pages/user/gallery.vue @@ -8,36 +8,24 @@ </div> </template> -<script lang="ts"> -import { computed, defineComponent } from 'vue'; +<script lang="ts" setup> +import { computed } from 'vue'; +import * as misskey from 'misskey-js'; import MkGalleryPostPreview from '@/components/gallery-post-preview.vue'; import MkPagination from '@/components/ui/pagination.vue'; -export default defineComponent({ - components: { - MkPagination, - MkGalleryPostPreview, - }, - - props: { - user: { - type: Object, - required: true - }, - }, - - data() { - return { - pagination: { - endpoint: 'users/gallery/posts' as const, - limit: 6, - params: computed(() => ({ - userId: this.user.id - })), - }, - }; - }, +const props = withDefaults(defineProps<{ + user: misskey.entities.User; +}>(), { }); + +const pagination = { + endpoint: 'users/gallery/posts' as const, + limit: 6, + params: computed(() => ({ + userId: props.user.id, + })), +}; </script> <style lang="scss" scoped> diff --git a/packages/client/src/pages/user/home.vue b/packages/client/src/pages/user/home.vue new file mode 100644 index 0000000000..f7c25f077c --- /dev/null +++ b/packages/client/src/pages/user/home.vue @@ -0,0 +1,478 @@ +<template> +<MkSpacer :content-max="narrow ? 800 : 1100"> + <div ref="rootEl" v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }"> + <div class="main"> + <!-- TODO --> + <!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> --> + <!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSilenced }}</div> --> + + <div class="profile"> + <MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/> + + <div :key="user.id" class="_block main"> + <div class="banner-container" :style="style"> + <div ref="bannerEl" class="banner" :style="style"></div> + <div class="fade"></div> + <div class="title"> + <MkUserName class="name" :user="user" :nowrap="true"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true"/></span> + <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span> + <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span> + <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span> + <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span> + </div> + </div> + <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span> + <div v-if="$i" class="actions"> + <button class="menu _button" @click="menu"><i class="fas fa-ellipsis-h"></i></button> + <MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> + </div> + </div> + <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> + <div class="title"> + <MkUserName :user="user" :nowrap="false" class="name"/> + <div class="bottom"> + <span class="username"><MkAcct :user="user" :detail="true"/></span> + <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span> + <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span> + <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span> + <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span> + </div> + </div> + <div class="description"> + <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/> + <p v-else class="empty">{{ $ts.noAccountDescription }}</p> + </div> + <div class="fields system"> + <dl v-if="user.location" class="field"> + <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt> + <dd class="value">{{ user.location }}</dd> + </dl> + <dl v-if="user.birthday" class="field"> + <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt> + <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> + </dl> + <dl class="field"> + <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt> + <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd> + </dl> + </div> + <div v-if="user.fields.length > 0" class="fields"> + <dl v-for="(field, i) in user.fields" :key="i" class="field"> + <dt class="name"> + <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/> + </dt> + <dd class="value"> + <Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/> + </dd> + </dl> + </div> + <div class="status"> + <MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }"> + <b>{{ number(user.notesCount) }}</b> + <span>{{ $ts.notes }}</span> + </MkA> + <MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }"> + <b>{{ number(user.followingCount) }}</b> + <span>{{ $ts.following }}</span> + </MkA> + <MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }"> + <b>{{ number(user.followersCount) }}</b> + <span>{{ $ts.followers }}</span> + </MkA> + </div> + </div> + </div> + + <div class="contents"> + <div v-if="user.pinnedNotes.length > 0" class="_gap"> + <XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/> + </div> + <MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo> + <template v-if="narrow"> + <XPhotos :key="user.id" :user="user"/> + <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/> + </template> + </div> + <div> + <XUserTimeline :user="user"/> + </div> + </div> + <div v-if="!narrow" class="sub"> + <XPhotos :key="user.id" :user="user"/> + <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/> + </div> + </div> +</MkSpacer> +</template> + +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import calcAge from 's-age'; +import * as misskey from 'misskey-js'; +import XUserTimeline from './index.timeline.vue'; +import XNote from '@/components/note.vue'; +import MkFollowButton from '@/components/follow-button.vue'; +import MkContainer from '@/components/ui/container.vue'; +import MkFolder from '@/components/ui/folder.vue'; +import MkRemoteCaution from '@/components/remote-caution.vue'; +import MkTab from '@/components/tab.vue'; +import MkInfo from '@/components/ui/info.vue'; +import { getScrollPosition } from '@/scripts/scroll'; +import { getUserMenu } from '@/scripts/get-user-menu'; +import number from '@/filters/number'; +import { userPage, acct as getAcct } from '@/filters/user'; +import * as os from '@/os'; +import { useRouter } from '@/router'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; + +const XPhotos = defineAsyncComponent(() => import('./index.photos.vue')); +const XActivity = defineAsyncComponent(() => import('./index.activity.vue')); + +const props = withDefaults(defineProps<{ + user: misskey.entities.UserDetailed; +}>(), { +}); + +const router = useRouter(); + +let parallaxAnimationId = $ref<null | number>(null); +let narrow = $ref<null | boolean>(null); +let rootEl = $ref<null | HTMLElement>(null); +let bannerEl = $ref<null | HTMLElement>(null); + +const style = $computed(() => { + if (props.user.bannerUrl == null) return {}; + return { + backgroundImage: `url(${ props.user.bannerUrl })`, + }; +}); + +const age = $computed(() => { + return calcAge(props.user.birthday); +}); + +function menu(ev) { + os.popupMenu(getUserMenu(props.user), ev.currentTarget ?? ev.target); +} + +function parallaxLoop() { + parallaxAnimationId = window.requestAnimationFrame(parallaxLoop); + parallax(); +} + +function parallax() { + const banner = bannerEl as any; + if (banner == null) return; + + const top = getScrollPosition(rootEl); + + if (top < 0) return; + + const z = 1.75; // 奥行き(小さいほど奥) + const pos = -(top / z); + banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; +} + +onMounted(() => { + window.requestAnimationFrame(parallaxLoop); + narrow = rootEl!.clientWidth < 1000; +}); + +onUnmounted(() => { + if (parallaxAnimationId) { + window.cancelAnimationFrame(parallaxAnimationId); + } +}); +</script> + +<style lang="scss" scoped> +.ftskorzw { + + > .main { + + > .punished { + font-size: 0.8em; + padding: 16px; + } + + > .profile { + + > .main { + position: relative; + overflow: hidden; + + > .banner-container { + position: relative; + height: 250px; + overflow: hidden; + background-size: cover; + background-position: center; + + > .banner { + height: 100%; + background-color: #4c5e6d; + background-size: cover; + background-position: center; + box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; + will-change: background-position; + } + + > .fade { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + height: 78px; + background: linear-gradient(transparent, rgba(#000, 0.7)); + } + + > .followed { + position: absolute; + top: 12px; + left: 12px; + padding: 4px 8px; + color: #fff; + background: rgba(0, 0, 0, 0.7); + font-size: 0.7em; + border-radius: 6px; + } + + > .actions { + position: absolute; + top: 12px; + right: 12px; + -webkit-backdrop-filter: var(--blur, blur(8px)); + backdrop-filter: var(--blur, blur(8px)); + background: rgba(0, 0, 0, 0.2); + padding: 8px; + border-radius: 24px; + + > .menu { + vertical-align: bottom; + height: 31px; + width: 31px; + color: #fff; + text-shadow: 0 0 8px #000; + font-size: 16px; + } + + > .koudoku { + margin-left: 4px; + vertical-align: bottom; + } + } + + > .title { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + padding: 0 0 8px 154px; + box-sizing: border-box; + color: #fff; + + > .name { + display: block; + margin: 0; + line-height: 32px; + font-weight: bold; + font-size: 1.8em; + text-shadow: 0 0 8px #000; + } + + > .bottom { + > * { + display: inline-block; + margin-right: 16px; + line-height: 20px; + opacity: 0.8; + + &.username { + font-weight: bold; + } + } + } + } + } + + > .title { + display: none; + text-align: center; + padding: 50px 8px 16px 8px; + font-weight: bold; + border-bottom: solid 0.5px var(--divider); + + > .bottom { + > * { + display: inline-block; + margin-right: 8px; + opacity: 0.8; + } + } + } + + > .avatar { + display: block; + position: absolute; + top: 170px; + left: 16px; + z-index: 2; + width: 120px; + height: 120px; + box-shadow: 1px 1px 3px rgba(#000, 0.2); + } + + > .description { + padding: 24px 24px 24px 154px; + font-size: 0.95em; + + > .empty { + margin: 0; + opacity: 0.5; + } + } + + > .fields { + padding: 24px; + font-size: 0.9em; + border-top: solid 0.5px var(--divider); + + > .field { + display: flex; + padding: 0; + margin: 0; + align-items: center; + + &:not(:last-child) { + margin-bottom: 8px; + } + + > .name { + width: 30%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + font-weight: bold; + text-align: center; + } + + > .value { + width: 70%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + margin: 0; + } + } + + &.system > .field > .name { + } + } + + > .status { + display: flex; + padding: 24px; + border-top: solid 0.5px var(--divider); + + > a { + flex: 1; + text-align: center; + + &.active { + color: var(--accent); + } + + &:hover { + text-decoration: none; + } + + > b { + display: block; + line-height: 16px; + } + + > span { + font-size: 70%; + } + } + } + } + } + + > .contents { + > .content { + margin-bottom: var(--margin); + } + } + } + + &.max-width_500px { + > .main { + > .profile > .main { + > .banner-container { + height: 140px; + + > .fade { + display: none; + } + + > .title { + display: none; + } + } + + > .title { + display: block; + } + + > .avatar { + top: 90px; + left: 0; + right: 0; + width: 92px; + height: 92px; + margin: auto; + } + + > .description { + padding: 16px; + text-align: center; + } + + > .fields { + padding: 16px; + } + + > .status { + padding: 16px; + } + } + + > .contents { + > .nav { + font-size: 80%; + } + } + } + } + + &.wide { + display: flex; + width: 100%; + + > .main { + width: 100%; + min-width: 0; + } + + > .sub { + max-width: 350px; + min-width: 350px; + margin-left: var(--margin); + } + } +} +</style> diff --git a/packages/client/src/pages/user/index.activity.vue b/packages/client/src/pages/user/index.activity.vue index aecd25d6b0..8a7a86e0f1 100644 --- a/packages/client/src/pages/user/index.activity.vue +++ b/packages/client/src/pages/user/index.activity.vue @@ -1,6 +1,6 @@ <template> <MkContainer> - <template #header><i class="fas fa-chart-bar" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template> + <template #header><i class="fas fa-chart-simple" style="margin-right: 0.5em;"></i>{{ $ts.activity }}</template> <template #func> <button class="_button" @click="showMenu"> <i class="fas fa-ellipsis-h"></i> @@ -36,8 +36,8 @@ function showMenu(ev: MouseEvent) { active: true, action: () => { chartSrc = 'per-user-notes'; - } - }/*, { + }, + },/*, { text: i18n.ts.following, action: () => { chartSrc = 'per-user-following'; diff --git a/packages/client/src/pages/user/index.photos.vue b/packages/client/src/pages/user/index.photos.vue index 79dd1726e1..cedb0e05f3 100644 --- a/packages/client/src/pages/user/index.photos.vue +++ b/packages/client/src/pages/user/index.photos.vue @@ -90,7 +90,7 @@ export default defineComponent({ > .img { height: 128px; border-radius: 6px; - overflow: clip; + overflow: hidden; overflow: clip; } } diff --git a/packages/client/src/pages/user/index.vue b/packages/client/src/pages/user/index.vue index a024dd28bc..99c3413882 100644 --- a/packages/client/src/pages/user/index.vue +++ b/packages/client/src/pages/user/index.vue @@ -1,297 +1,109 @@ <template> -<div> - <transition name="fade" mode="out-in"> - <MkSpacer v-if="user" :content-max="narrow ? 800 : 1100"> - <div v-size="{ max: [500] }" class="ftskorzw" :class="{ wide: !narrow }"> - <div class="main"> - <!-- TODO --> - <!-- <div class="punished" v-if="user.isSuspended"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSuspended }}</div> --> - <!-- <div class="punished" v-if="user.isSilenced"><i class="fas fa-exclamation-triangle" style="margin-right: 8px;"></i> {{ $ts.userSilenced }}</div> --> - - <div class="profile"> - <MkRemoteCaution v-if="user.host != null" :href="user.url" class="warn"/> - - <div :key="user.id" class="_block main"> - <div class="banner-container" :style="style"> - <div ref="banner" class="banner" :style="style"></div> - <div class="fade"></div> - <div class="title"> - <MkUserName class="name" :user="user" :nowrap="true"/> - <div class="bottom"> - <span class="username"><MkAcct :user="user" :detail="true" /></span> - <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span> - <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span> - <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span> - <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span> - </div> - </div> - <span v-if="$i && $i.id != user.id && user.isFollowed" class="followed">{{ $ts.followsYou }}</span> - <div v-if="$i" class="actions"> - <button class="menu _button" @click="menu"><i class="fas fa-ellipsis-h"></i></button> - <MkFollowButton v-if="$i.id != user.id" :user="user" :inline="true" :transparent="false" :full="true" class="koudoku"/> - </div> - </div> - <MkAvatar class="avatar" :user="user" :disable-preview="true" :show-indicator="true"/> - <div class="title"> - <MkUserName :user="user" :nowrap="false" class="name"/> - <div class="bottom"> - <span class="username"><MkAcct :user="user" :detail="true" /></span> - <span v-if="user.isAdmin" :title="$ts.isAdmin" style="color: var(--badge);"><i class="fas fa-bookmark"></i></span> - <span v-if="!user.isAdmin && user.isModerator" :title="$ts.isModerator" style="color: var(--badge);"><i class="far fa-bookmark"></i></span> - <span v-if="user.isLocked" :title="$ts.isLocked"><i class="fas fa-lock"></i></span> - <span v-if="user.isBot" :title="$ts.isBot"><i class="fas fa-robot"></i></span> - </div> - </div> - <div class="description"> - <Mfm v-if="user.description" :text="user.description" :is-note="false" :author="user" :i="$i" :custom-emojis="user.emojis"/> - <p v-else class="empty">{{ $ts.noAccountDescription }}</p> - </div> - <div class="fields system"> - <dl v-if="user.location" class="field"> - <dt class="name"><i class="fas fa-map-marker fa-fw"></i> {{ $ts.location }}</dt> - <dd class="value">{{ user.location }}</dd> - </dl> - <dl v-if="user.birthday" class="field"> - <dt class="name"><i class="fas fa-birthday-cake fa-fw"></i> {{ $ts.birthday }}</dt> - <dd class="value">{{ user.birthday.replace('-', '/').replace('-', '/') }} ({{ $t('yearsOld', { age }) }})</dd> - </dl> - <dl class="field"> - <dt class="name"><i class="fas fa-calendar-alt fa-fw"></i> {{ $ts.registeredDate }}</dt> - <dd class="value">{{ new Date(user.createdAt).toLocaleString() }} (<MkTime :time="user.createdAt"/>)</dd> - </dl> - </div> - <div v-if="user.fields.length > 0" class="fields"> - <dl v-for="(field, i) in user.fields" :key="i" class="field"> - <dt class="name"> - <Mfm :text="field.name" :plain="true" :custom-emojis="user.emojis" :colored="false"/> - </dt> - <dd class="value"> - <Mfm :text="field.value" :author="user" :i="$i" :custom-emojis="user.emojis" :colored="false"/> - </dd> - </dl> - </div> - <div class="status"> - <MkA v-click-anime :to="userPage(user)" :class="{ active: page === 'index' }"> - <b>{{ number(user.notesCount) }}</b> - <span>{{ $ts.notes }}</span> - </MkA> - <MkA v-click-anime :to="userPage(user, 'following')" :class="{ active: page === 'following' }"> - <b>{{ number(user.followingCount) }}</b> - <span>{{ $ts.following }}</span> - </MkA> - <MkA v-click-anime :to="userPage(user, 'followers')" :class="{ active: page === 'followers' }"> - <b>{{ number(user.followersCount) }}</b> - <span>{{ $ts.followers }}</span> - </MkA> - </div> - </div> - </div> - - <div class="contents"> - <template v-if="page === 'index'"> - <div> - <div v-if="user.pinnedNotes.length > 0" class="_gap"> - <XNote v-for="note in user.pinnedNotes" :key="note.id" class="note _block" :note="note" :pinned="true"/> - </div> - <MkInfo v-else-if="$i && $i.id === user.id">{{ $ts.userPagePinTip }}</MkInfo> - <template v-if="narrow"> - <XPhotos :key="user.id" :user="user"/> - <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/> - </template> - </div> - <div> - <XUserTimeline :user="user"/> - </div> - </template> - <XFollowList v-else-if="page === 'following'" type="following" :user="user" class="_content _gap"/> - <XFollowList v-else-if="page === 'followers'" type="followers" :user="user" class="_content _gap"/> - <XReactions v-else-if="page === 'reactions'" :user="user" class="_gap"/> - <XClips v-else-if="page === 'clips'" :user="user" class="_gap"/> - <XPages v-else-if="page === 'pages'" :user="user" class="_gap"/> - <XGallery v-else-if="page === 'gallery'" :user="user" class="_gap"/> - </div> - </div> - <div v-if="!narrow" class="sub"> - <XPhotos :key="user.id" :user="user"/> - <XActivity :key="user.id" :user="user" style="margin-top: var(--margin);"/> - </div> +<MkStickyContainer> + <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> + <div> + <transition name="fade" mode="out-in"> + <div v-if="user"> + <XHome v-if="tab === 'home'" :user="user"/> + <XReactions v-else-if="tab === 'reactions'" :user="user"/> + <XClips v-else-if="tab === 'clips'" :user="user"/> + <XPages v-else-if="tab === 'pages'" :user="user"/> + <XGallery v-else-if="tab === 'gallery'" :user="user"/> </div> - </MkSpacer> - <MkError v-else-if="error" @retry="fetch()"/> - <MkLoading v-else/> - </transition> -</div> + <MkError v-else-if="error" @retry="fetch()"/> + <MkLoading v-else/> + </transition> + </div> +</MkStickyContainer> </template> -<script lang="ts"> -import { defineComponent, defineAsyncComponent, computed } from 'vue'; -import age from 's-age'; -import XUserTimeline from './index.timeline.vue'; -import XNote from '@/components/note.vue'; -import MkFollowButton from '@/components/follow-button.vue'; -import MkContainer from '@/components/ui/container.vue'; -import MkFolder from '@/components/ui/folder.vue'; -import MkRemoteCaution from '@/components/remote-caution.vue'; -import MkTab from '@/components/tab.vue'; -import MkInfo from '@/components/ui/info.vue'; +<script lang="ts" setup> +import { defineAsyncComponent, computed, inject, onMounted, onUnmounted, watch } from 'vue'; +import calcAge from 's-age'; import * as Acct from 'misskey-js/built/acct'; +import * as misskey from 'misskey-js'; import { getScrollPosition } from '@/scripts/scroll'; import { getUserMenu } from '@/scripts/get-user-menu'; import number from '@/filters/number'; import { userPage, acct as getAcct } from '@/filters/user'; import * as os from '@/os'; -import * as symbols from '@/symbols'; -import { MisskeyNavigator } from '@/scripts/navigate'; - -export default defineComponent({ - components: { - XUserTimeline, - XNote, - MkFollowButton, - MkContainer, - MkRemoteCaution, - MkFolder, - MkTab, - MkInfo, - XFollowList: defineAsyncComponent(() => import('./follow-list.vue')), - XReactions: defineAsyncComponent(() => import('./reactions.vue')), - XClips: defineAsyncComponent(() => import('./clips.vue')), - XPages: defineAsyncComponent(() => import('./pages.vue')), - XGallery: defineAsyncComponent(() => import('./gallery.vue')), - XPhotos: defineAsyncComponent(() => import('./index.photos.vue')), - XActivity: defineAsyncComponent(() => import('./index.activity.vue')), - }, - - props: { - acct: { - type: String, - required: true - }, - page: { - type: String, - required: false, - default: 'index' - } - }, - - data() { - return { - [symbols.PAGE_INFO]: computed(() => this.user ? { - icon: 'fas fa-user', - title: this.user.name ? `${this.user.name} (@${this.user.username})` : `@${this.user.username}`, - subtitle: `@${getAcct(this.user)}`, - userName: this.user, - avatar: this.user, - path: `/@${this.user.username}`, - share: { - title: this.user.name, - }, - bg: 'var(--bg)', - tabs: [{ - active: this.page === 'index', - title: this.$ts.overview, - icon: 'fas fa-home', - onClick: () => { this.mkNav.push('/@' + getAcct(this.user)); }, - }, ...(this.$i && (this.$i.id === this.user.id)) || this.user.publicReactions ? [{ - active: this.page === 'reactions', - title: this.$ts.reaction, - icon: 'fas fa-laugh', - onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/reactions'); }, - }] : [], { - active: this.page === 'clips', - title: this.$ts.clips, - icon: 'fas fa-paperclip', - onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/clips'); }, - }, { - active: this.page === 'pages', - title: this.$ts.pages, - icon: 'fas fa-file-alt', - onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/pages'); }, - }, { - active: this.page === 'gallery', - title: this.$ts.gallery, - icon: 'fas fa-icons', - onClick: () => { this.mkNav.push('/@' + getAcct(this.user) + '/gallery'); }, - }], - } : null), - user: null, - error: null, - parallaxAnimationId: null, - narrow: null, - mkNav: new MisskeyNavigator(), - }; - }, - - computed: { - style(): any { - if (this.user.bannerUrl == null) return {}; - return { - backgroundImage: `url(${ this.user.bannerUrl })` - }; - }, +import { useRouter } from '@/router'; +import { definePageMetadata } from '@/scripts/page-metadata'; +import { i18n } from '@/i18n'; +import { $i } from '@/account'; - age(): number { - return age(this.user.birthday); - } - }, - - watch: { - acct: 'fetch' - }, - - created() { - this.fetch(); - }, - - mounted() { - window.requestAnimationFrame(this.parallaxLoop); - this.narrow = this.$el.clientWidth < 1000; - }, - - beforeUnmount() { - window.cancelAnimationFrame(this.parallaxAnimationId); - }, +const XHome = defineAsyncComponent(() => import('./home.vue')); +const XReactions = defineAsyncComponent(() => import('./reactions.vue')); +const XClips = defineAsyncComponent(() => import('./clips.vue')); +const XPages = defineAsyncComponent(() => import('./pages.vue')); +const XGallery = defineAsyncComponent(() => import('./gallery.vue')); - methods: { - getAcct, - - fetch() { - if (this.acct == null) return; - this.user = null; - os.api('users/show', Acct.parse(this.acct)).then(user => { - this.user = user; - }).catch(err => { - this.error = err; - }); - }, +const props = withDefaults(defineProps<{ + acct: string; + page?: string; +}>(), { + page: 'home', +}); - menu(ev) { - os.popupMenu(getUserMenu(this.user), ev.currentTarget ?? ev.target); - }, +const router = useRouter(); - parallaxLoop() { - this.parallaxAnimationId = window.requestAnimationFrame(this.parallaxLoop); - this.parallax(); - }, +let tab = $ref(props.page); +let user = $ref<null | misskey.entities.UserDetailed>(null); +let error = $ref(null); - parallax() { - const banner = this.$refs.banner as any; - if (banner == null) return; +function fetchUser(): void { + if (props.acct == null) return; + user = null; + os.api('users/show', Acct.parse(props.acct)).then(u => { + user = u; + }).catch(err => { + error = err; + }); +} - const top = getScrollPosition(this.$el); +watch(() => props.acct, fetchUser, { + immediate: true, +}); - if (top < 0) return; +function menu(ev) { + os.popupMenu(getUserMenu(user), ev.currentTarget ?? ev.target); +} - const z = 1.75; // 奥行き(小さいほど奥) - const pos = -(top / z); - banner.style.backgroundPosition = `center calc(50% - ${pos}px)`; - }, +const headerActions = $computed(() => []); - number, +const headerTabs = $computed(() => user ? [{ + key: 'home', + title: i18n.ts.overview, + icon: 'fas fa-home', +}, ...($i && ($i.id === user.id)) || user.publicReactions ? [{ + key: 'reactions', + title: i18n.ts.reaction, + icon: 'fas fa-laugh', +}] : [], { + key: 'clips', + title: i18n.ts.clips, + icon: 'fas fa-paperclip', +}, { + key: 'pages', + title: i18n.ts.pages, + icon: 'fas fa-file-alt', +}, { + key: 'gallery', + title: i18n.ts.gallery, + icon: 'fas fa-icons', +}] : null); - userPage - } -}); +definePageMetadata(computed(() => user ? { + icon: 'fas fa-user', + title: user.name ? `${user.name} (@${user.username})` : `@${user.username}`, + subtitle: `@${getAcct(user)}`, + userName: user, + avatar: user, + path: `/@${user.username}`, + share: { + title: user.name, + }, +} : null)); </script> <style lang="scss" scoped> @@ -303,291 +115,4 @@ export default defineComponent({ .fade-leave-to { opacity: 0; } - -.ftskorzw { - - > .main { - - > .punished { - font-size: 0.8em; - padding: 16px; - } - - > .profile { - - > .main { - position: relative; - overflow: hidden; - - > .banner-container { - position: relative; - height: 250px; - overflow: hidden; - background-size: cover; - background-position: center; - - > .banner { - height: 100%; - background-color: #4c5e6d; - background-size: cover; - background-position: center; - box-shadow: 0 0 128px rgba(0, 0, 0, 0.5) inset; - will-change: background-position; - } - - > .fade { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 78px; - background: linear-gradient(transparent, rgba(#000, 0.7)); - } - - > .followed { - position: absolute; - top: 12px; - left: 12px; - padding: 4px 8px; - color: #fff; - background: rgba(0, 0, 0, 0.7); - font-size: 0.7em; - border-radius: 6px; - } - - > .actions { - position: absolute; - top: 12px; - right: 12px; - -webkit-backdrop-filter: var(--blur, blur(8px)); - backdrop-filter: var(--blur, blur(8px)); - background: rgba(0, 0, 0, 0.2); - padding: 8px; - border-radius: 24px; - - > .menu { - vertical-align: bottom; - height: 31px; - width: 31px; - color: #fff; - text-shadow: 0 0 8px #000; - font-size: 16px; - } - - > .koudoku { - margin-left: 4px; - vertical-align: bottom; - } - } - - > .title { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - padding: 0 0 8px 154px; - box-sizing: border-box; - color: #fff; - - > .name { - display: block; - margin: 0; - line-height: 32px; - font-weight: bold; - font-size: 1.8em; - text-shadow: 0 0 8px #000; - } - - > .bottom { - > * { - display: inline-block; - margin-right: 16px; - line-height: 20px; - opacity: 0.8; - - &.username { - font-weight: bold; - } - } - } - } - } - - > .title { - display: none; - text-align: center; - padding: 50px 8px 16px 8px; - font-weight: bold; - border-bottom: solid 0.5px var(--divider); - - > .bottom { - > * { - display: inline-block; - margin-right: 8px; - opacity: 0.8; - } - } - } - - > .avatar { - display: block; - position: absolute; - top: 170px; - left: 16px; - z-index: 2; - width: 120px; - height: 120px; - box-shadow: 1px 1px 3px rgba(#000, 0.2); - } - - > .description { - padding: 24px 24px 24px 154px; - font-size: 0.95em; - - > .empty { - margin: 0; - opacity: 0.5; - } - } - - > .fields { - padding: 24px; - font-size: 0.9em; - border-top: solid 0.5px var(--divider); - - > .field { - display: flex; - padding: 0; - margin: 0; - align-items: center; - - &:not(:last-child) { - margin-bottom: 8px; - } - - > .name { - width: 30%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - font-weight: bold; - text-align: center; - } - - > .value { - width: 70%; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; - margin: 0; - } - } - - &.system > .field > .name { - } - } - - > .status { - display: flex; - padding: 24px; - border-top: solid 0.5px var(--divider); - - > a { - flex: 1; - text-align: center; - - &.active { - color: var(--accent); - } - - &:hover { - text-decoration: none; - } - - > b { - display: block; - line-height: 16px; - } - - > span { - font-size: 70%; - } - } - } - } - } - - > .contents { - > .content { - margin-bottom: var(--margin); - } - } - } - - &.max-width_500px { - > .main { - > .profile > .main { - > .banner-container { - height: 140px; - - > .fade { - display: none; - } - - > .title { - display: none; - } - } - - > .title { - display: block; - } - - > .avatar { - top: 90px; - left: 0; - right: 0; - width: 92px; - height: 92px; - margin: auto; - } - - > .description { - padding: 16px; - text-align: center; - } - - > .fields { - padding: 16px; - } - - > .status { - padding: 16px; - } - } - - > .contents { - > .nav { - font-size: 80%; - } - } - } - } - - &.wide { - display: flex; - width: 100%; - - > .main { - width: 100%; - min-width: 0; - } - - > .sub { - max-width: 350px; - min-width: 350px; - margin-left: var(--margin); - } - } -} </style> diff --git a/packages/client/src/pages/welcome.entrance.a.vue b/packages/client/src/pages/welcome.entrance.a.vue index 47e1f12342..f9d5852212 100644 --- a/packages/client/src/pages/welcome.entrance.a.vue +++ b/packages/client/src/pages/welcome.entrance.a.vue @@ -13,10 +13,9 @@ <MkEmoji :normal="true" :no-style="true" emoji="🎉"/> <MkEmoji :normal="true" :no-style="true" emoji="🍮"/> </div> - <div class="main _panel"> - <div class="bg"> - <div class="fade"></div> - </div> + <div class="main"> + <img :src="$instance.iconUrl || $instance.faviconUrl || '/favicon.ico'" alt="" class="icon"/> + <button class="_button _acrylic menu" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button> <div class="fg"> <h1> <!-- 背景色によってはロゴが見えなくなるのでとりあえず無効に --> @@ -24,123 +23,108 @@ <span class="text">{{ instanceName }}</span> </h1> <div class="about"> - <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> + <!-- eslint-disable-next-line vue/no-v-html --> + <div class="desc" v-html="meta.description || i18n.ts.headlineMisskey"></div> </div> <div class="action"> - <MkButton inline gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ $ts.signup }}</MkButton> - <MkButton inline data-cy-signin @click="signin()">{{ $ts.login }}</MkButton> - </div> - <div v-if="onlineUsersCount && stats" class="status"> - <div> - <I18n :src="$ts.nUsers" text-tag="span" class="users"> - <template #n><b>{{ number(stats.originalUsersCount) }}</b></template> - </I18n> - <I18n :src="$ts.nNotes" text-tag="span" class="notes"> - <template #n><b>{{ number(stats.originalNotesCount) }}</b></template> - </I18n> - </div> - <I18n :src="$ts.onlineUsersCount" text-tag="span" class="online"> - <template #n><b>{{ onlineUsersCount }}</b></template> - </I18n> + <MkButton inline rounded gradate data-cy-signup style="margin-right: 12px;" @click="signup()">{{ i18n.ts.signup }}</MkButton> + <MkButton inline rounded data-cy-signin @click="signin()">{{ i18n.ts.login }}</MkButton> </div> - <button class="_button _acrylic menu" @click="showMenu"><i class="fas fa-ellipsis-h"></i></button> </div> </div> + <div v-if="instances" class="federation"> + <MarqueeText :duration="40"> + <MkA v-for="instance in instances" :key="instance.id" :class="$style.federationInstance" :to="`/instance-info/${instance.host}`" behavior="window"> + <!--<MkInstanceCardMini :instance="instance"/>--> + <img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/> + <span class="name _monospace">{{ instance.host }}</span> + </MkA> + </MarqueeText> + </div> </div> </div> </template> -<script lang="ts"> -import { defineComponent } from 'vue'; +<script lang="ts" setup> +import { } from 'vue'; import { toUnicode } from 'punycode/'; +import XTimeline from './welcome.timeline.vue'; +import MarqueeText from '@/components/marquee.vue'; import XSigninDialog from '@/components/signin-dialog.vue'; import XSignupDialog from '@/components/signup-dialog.vue'; import MkButton from '@/components/ui/button.vue'; import XNote from '@/components/note.vue'; import MkFeaturedPhotos from '@/components/featured-photos.vue'; -import XTimeline from './welcome.timeline.vue'; import { host, instanceName } from '@/config'; import * as os from '@/os'; import number from '@/filters/number'; +import { i18n } from '@/i18n'; -export default defineComponent({ - components: { - MkButton, - XNote, - MkFeaturedPhotos, - XTimeline, - }, +let meta = $ref(); +let stats = $ref(); +let tags = $ref(); +let onlineUsersCount = $ref(); +let instances = $ref(); - data() { - return { - host: toUnicode(host), - instanceName, - meta: null, - stats: null, - tags: [], - onlineUsersCount: null, - }; - }, +os.api('meta', { detail: true }).then(_meta => { + meta = _meta; +}); - created() { - os.api('meta', { detail: true }).then(meta => { - this.meta = meta; - }); +os.api('stats').then(_stats => { + stats = _stats; +}); - os.api('stats').then(stats => { - this.stats = stats; - }); +os.api('get-online-users-count').then(res => { + onlineUsersCount = res.count; +}); - os.api('get-online-users-count').then(res => { - this.onlineUsersCount = res.count; - }); +os.api('hashtags/list', { + sort: '+mentionedLocalUsers', + limit: 8, +}).then(_tags => { + tags = _tags; +}); - os.api('hashtags/list', { - sort: '+mentionedLocalUsers', - limit: 8 - }).then(tags => { - this.tags = tags; - }); - }, +os.api('federation/instances', { + sort: '+pubSub', + limit: 20, +}).then(_instances => { + instances = _instances; +}); - methods: { - signin() { - os.popup(XSigninDialog, { - autoSet: true - }, {}, 'closed'); - }, +function signin() { + os.popup(XSigninDialog, { + autoSet: true, + }, {}, 'closed'); +} - signup() { - os.popup(XSignupDialog, { - autoSet: true - }, {}, 'closed'); - }, +function signup() { + os.popup(XSignupDialog, { + autoSet: true, + }, {}, 'closed'); +} - showMenu(ev) { - os.popupMenu([{ - text: this.$t('aboutX', { x: instanceName }), - icon: 'fas fa-info-circle', - action: () => { - os.pageWindow('/about'); - } - }, { - text: this.$ts.aboutMisskey, - icon: 'fas fa-info-circle', - action: () => { - os.pageWindow('/about-misskey'); - } - }, null, { - text: this.$ts.help, - icon: 'fas fa-question-circle', - action: () => { - window.open(`https://misskey-hub.net/help.md`, '_blank'); - } - }], ev.currentTarget ?? ev.target); +function showMenu(ev) { + os.popupMenu([{ + text: i18n.ts.instanceInfo, + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about'); }, - - number - } -}); + }, { + text: i18n.ts.aboutMisskey, + icon: 'fas fa-info-circle', + action: () => { + os.pageWindow('/about-misskey'); + }, + }, null, { + text: i18n.ts.help, + icon: 'fas fa-question-circle', + action: () => { + window.open('https://misskey-hub.net/help.md', '_blank'); + }, + }], ev.currentTarget ?? ev.target); +} </script> <style lang="scss" scoped> @@ -201,7 +185,7 @@ export default defineComponent({ position: absolute; top: 42px; left: 42px; - width: 160px; + width: 140px; @media (max-width: 450px) { width: 130px; @@ -226,30 +210,29 @@ export default defineComponent({ position: relative; width: min(480px, 100%); margin: auto auto auto 128px; + background: var(--panel); + border-radius: var(--radius); box-shadow: 0 12px 32px rgb(0 0 0 / 25%); @media (max-width: 1200px) { margin: auto; } - > .bg { - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 128px; - background-position: center; - background-size: cover; - opacity: 0.75; + > .icon { + width: 85px; + margin-top: -47px; + border-radius: 100%; + vertical-align: bottom; + } - > .fade { - position: absolute; - bottom: 0; - left: 0; - width: 100%; - height: 128px; - background: linear-gradient(0deg, var(--panel), var(--X15)); - } + > .menu { + position: absolute; + top: 16px; + right: 16px; + width: 32px; + height: 32px; + border-radius: 8px; + font-size: 18px; } > .fg { @@ -259,8 +242,8 @@ export default defineComponent({ > h1 { display: block; margin: 0; - padding: 32px 32px 24px 32px; - font-size: 1.5em; + padding: 16px 32px 24px 32px; + font-size: 1.4em; > .logo { vertical-align: bottom; @@ -280,41 +263,47 @@ export default defineComponent({ line-height: 28px; } } + } + } - > .status { - border-top: solid 0.5px var(--divider); - padding: 32px; - font-size: 90%; - - > div { - > span:not(:last-child) { - padding-right: 1em; - margin-right: 1em; - border-right: solid 0.5px var(--divider); - } - } - - > .online { - ::v-deep(b) { - color: #41b781; - } - - ::v-deep(span) { - opacity: 0.7; - } - } - } + > .federation { + position: absolute; + bottom: 16px; + left: 0; + right: 0; + margin: auto; + background: var(--acrylicPanel); + -webkit-backdrop-filter: var(--blur, blur(15px)); + backdrop-filter: var(--blur, blur(15px)); + border-radius: 999px; + overflow: hidden; overflow: clip; + width: 800px; + padding: 8px 0; - > .menu { - position: absolute; - top: 16px; - right: 16px; - width: 32px; - height: 32px; - border-radius: 8px; - } + @media (max-width: 900px) { + display: none; } } } } </style> + +<style lang="scss" module> +.federationInstance { + display: inline-flex; + align-items: center; + vertical-align: bottom; + padding: 6px 12px 6px 6px; + margin: 0 10px 0 0; + background: var(--panel); + border-radius: 999px; + + > :global(.icon) { + display: inline-block; + width: 20px; + height: 20px; + margin-right: 5px; + border-radius: 999px; + } +} +</style> diff --git a/packages/client/src/pages/welcome.entrance.b.vue b/packages/client/src/pages/welcome.entrance.b.vue index 053087fda0..344dc9aed9 100644 --- a/packages/client/src/pages/welcome.entrance.b.vue +++ b/packages/client/src/pages/welcome.entrance.b.vue @@ -9,6 +9,7 @@ <img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> </h1> <div class="about"> + <!-- eslint-disable-next-line vue/no-v-html --> <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> </div> <div class="action"> diff --git a/packages/client/src/pages/welcome.entrance.c.vue b/packages/client/src/pages/welcome.entrance.c.vue index 6bf487e16e..d583c5df35 100644 --- a/packages/client/src/pages/welcome.entrance.c.vue +++ b/packages/client/src/pages/welcome.entrance.c.vue @@ -21,6 +21,7 @@ <img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span> </h1> <div class="about"> + <!-- eslint-disable-next-line vue/no-v-html --> <div class="desc" v-html="meta.description || $ts.headlineMisskey"></div> </div> <div class="action"> diff --git a/packages/client/src/pages/welcome.setup.vue b/packages/client/src/pages/welcome.setup.vue index 1a2f460283..4892ab6ea2 100644 --- a/packages/client/src/pages/welcome.setup.vue +++ b/packages/client/src/pages/welcome.setup.vue @@ -3,7 +3,7 @@ <h1>Welcome to Misskey!</h1> <div class="_formRoot"> <p>{{ $ts.intro }}</p> - <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" spellcheck="false" required data-cy-admin-username class="_formBlock"> + <MkInput v-model="username" pattern="^[a-zA-Z0-9_]{1,20}$" :spellcheck="false" required data-cy-admin-username class="_formBlock"> <template #label>{{ $ts.username }}</template> <template #prefix>@</template> <template #suffix>@{{ host }}</template> diff --git a/packages/client/src/pages/welcome.vue b/packages/client/src/pages/welcome.vue index 98808229da..a1c3fc2abb 100644 --- a/packages/client/src/pages/welcome.vue +++ b/packages/client/src/pages/welcome.vue @@ -11,7 +11,7 @@ import XSetup from './welcome.setup.vue'; import XEntrance from './welcome.entrance.a.vue'; import { instanceName } from '@/config'; import * as os from '@/os'; -import * as symbols from '@/symbols'; +import { definePageMetadata } from '@/scripts/page-metadata'; let meta = $ref(null); @@ -19,10 +19,12 @@ os.api('meta', { detail: true }).then(res => { meta = res; }); -defineExpose({ - [symbols.PAGE_INFO]: computed(() => ({ - title: instanceName, - icon: null, - })), -}); +const headerActions = $computed(() => []); + +const headerTabs = $computed(() => []); + +definePageMetadata(computed(() => ({ + title: instanceName, + icon: null, +}))); </script> diff --git a/packages/client/src/pizzax.ts b/packages/client/src/pizzax.ts index fa35df5511..89679123ba 100644 --- a/packages/client/src/pizzax.ts +++ b/packages/client/src/pizzax.ts @@ -1,3 +1,5 @@ +// PIZZAX --- A lightweight store + import { onUnmounted, Ref, ref, watch } from 'vue'; import { $i } from './account'; import { api } from './os'; @@ -116,7 +118,7 @@ export class Storage<T extends StateDef> { api('i/registry/set', { scope: ['client', this.key], key: key, - value: value + value: value, }); break; } @@ -161,7 +163,7 @@ export class Storage<T extends StateDef> { const val = setter ? setter(value) : value; this.set(key, val); valueRef.value = val; - } + }, }; } } diff --git a/packages/client/src/plugin.ts b/packages/client/src/plugin.ts index ca7b4b73d3..de1c955675 100644 --- a/packages/client/src/plugin.ts +++ b/packages/client/src/plugin.ts @@ -38,7 +38,7 @@ export function install(plugin) { function createPluginEnv(opts) { const config = new Map(); for (const [k, v] of Object.entries(opts.plugin.config || {})) { - config.set(k, jsToVal(opts.plugin.configData.hasOwnProperty(k) ? opts.plugin.configData[k] : v.default)); + config.set(k, jsToVal(typeof opts.plugin.configData[k] !== 'undefined' ? opts.plugin.configData[k] : v.default)); } return { diff --git a/packages/client/src/router.ts b/packages/client/src/router.ts index 96c793166c..2ff41e9723 100644 --- a/packages/client/src/router.ts +++ b/packages/client/src/router.ts @@ -1,146 +1,276 @@ -import { AsyncComponentLoader, defineAsyncComponent, markRaw } from 'vue'; -import { createRouter, createWebHistory } from 'vue-router'; +import { AsyncComponentLoader, defineAsyncComponent, inject } from 'vue'; +import { Router } from '@/nirax'; +import { $i, iAmModerator } from '@/account'; import MkLoading from '@/pages/_loading_.vue'; import MkError from '@/pages/_error_.vue'; -import MkTimeline from '@/pages/timeline.vue'; -import { $i, iAmModerator } from './account'; import { ui } from '@/config'; -// pathに/が入るとrollupが解決してくれないので、() => import('*.vue')を指定すること -const page = (path: string | AsyncComponentLoader<any>, uiName?: string) => defineAsyncComponent({ - loader: typeof path === 'string' ? uiName ? () => import(`./ui/${ui}/pages/${path}.vue`) : () => import(`./pages/${path}.vue`) : path, +const page = (loader: AsyncComponentLoader<any>) => defineAsyncComponent({ + loader: loader, loadingComponent: MkLoading, errorComponent: MkError, }); -let indexScrollPos = 0; +export const routes = [{ + path: '/@:initUser/pages/:initPageName/view-source', + component: page(() => import('./pages/page-editor/page-editor.vue')), +}, { + path: '/@:username/pages/:pageName', + component: page(() => import('./pages/page.vue')), +}, { + path: '/@:acct/following', + component: page(() => import('./pages/user/following.vue')), +}, { + path: '/@:acct/followers', + component: page(() => import('./pages/user/followers.vue')), +}, { + name: 'user', + path: '/@:acct/:page?', + component: page(() => import('./pages/user/index.vue')), +}, { + name: 'note', + path: '/notes/:noteId', + component: page(() => import('./pages/note.vue')), +}, { + path: '/clips/:clipId', + component: page(() => import('./pages/clip.vue')), +}, { + path: '/user-info/:userId', + component: page(() => import('./pages/user-info.vue')), +}, { + path: '/instance-info/:host', + component: page(() => import('./pages/instance-info.vue')), +}, { + name: 'settings', + path: '/settings/:initialPage(*)?', + component: page(() => import('./pages/settings/index.vue')), + loginRequired: true, +}, { + path: '/reset-password/:token?', + component: page(() => import('./pages/reset-password.vue')), +}, { + path: '/signup-complete/:code', + component: page(() => import('./pages/signup-complete.vue')), +}, { + path: '/announcements', + component: page(() => import('./pages/announcements.vue')), +}, { + path: '/about', + component: page(() => import('./pages/about.vue')), + hash: 'initialTab', +}, { + path: '/about-misskey', + component: page(() => import('./pages/about-misskey.vue')), +}, { + path: '/theme-editor', + component: page(() => import('./pages/theme-editor.vue')), + loginRequired: true, +}, { + path: '/explore/tags/:tag', + component: page(() => import('./pages/explore.vue')), +}, { + path: '/explore', + component: page(() => import('./pages/explore.vue')), +}, { + path: '/search', + component: page(() => import('./pages/search.vue')), + query: { + q: 'query', + channel: 'channel', + }, +}, { + path: '/authorize-follow', + component: page(() => import('./pages/follow.vue')), + loginRequired: true, +}, { + path: '/share', + component: page(() => import('./pages/share.vue')), + loginRequired: true, +}, { + path: '/api-console', + component: page(() => import('./pages/api-console.vue')), + loginRequired: true, +}, { + path: '/mfm-cheat-sheet', + component: page(() => import('./pages/mfm-cheat-sheet.vue')), +}, { + path: '/scratchpad', + component: page(() => import('./pages/scratchpad.vue')), +}, { + path: '/preview', + component: page(() => import('./pages/preview.vue')), +}, { + path: '/auth/:token', + component: page(() => import('./pages/auth.vue')), +}, { + path: '/miauth/:session', + component: page(() => import('./pages/miauth.vue')), + query: { + callback: 'callback', + name: 'name', + icon: 'icon', + permission: 'permission', + }, +}, { + path: '/tags/:tag', + component: page(() => import('./pages/tag.vue')), +}, { + path: '/pages/new', + component: page(() => import('./pages/page-editor/page-editor.vue')), + loginRequired: true, +}, { + path: '/pages/edit/:initPageId', + component: page(() => import('./pages/page-editor/page-editor.vue')), + loginRequired: true, +}, { + path: '/pages', + component: page(() => import('./pages/pages.vue')), +}, { + path: '/gallery/:postId/edit', + component: page(() => import('./pages/gallery/edit.vue')), + loginRequired: true, +}, { + path: '/gallery/new', + component: page(() => import('./pages/gallery/edit.vue')), + loginRequired: true, +}, { + path: '/gallery/:postId', + component: page(() => import('./pages/gallery/post.vue')), +}, { + path: '/gallery', + component: page(() => import('./pages/gallery/index.vue')), +}, { + path: '/channels/:channelId/edit', + component: page(() => import('./pages/channel-editor.vue')), + loginRequired: true, +}, { + path: '/channels/new', + component: page(() => import('./pages/channel-editor.vue')), + loginRequired: true, +}, { + path: '/channels/:channelId', + component: page(() => import('./pages/channel.vue')), +}, { + path: '/channels', + component: page(() => import('./pages/channels.vue')), +}, { + path: '/admin/file/:fileId', + component: iAmModerator ? page(() => import('./pages/admin-file.vue')) : page(() => import('./pages/not-found.vue')), +}, { + path: '/admin/:initialPage(*)?', + component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page(() => import('./pages/not-found.vue')), +}, { + path: '/my/notifications', + component: page(() => import('./pages/notifications.vue')), + loginRequired: true, +}, { + path: '/my/favorites', + component: page(() => import('./pages/favorites.vue')), + loginRequired: true, +}, { + name: 'messaging', + path: '/my/messaging', + component: page(() => import('./pages/messaging/index.vue')), + loginRequired: true, +}, { + path: '/my/messaging/:userAcct', + component: page(() => import('./pages/messaging/messaging-room.vue')), + loginRequired: true, +}, { + path: '/my/messaging/group/:groupId', + component: page(() => import('./pages/messaging/messaging-room.vue')), + loginRequired: true, +}, { + path: '/my/drive/folder/:folder', + component: page(() => import('./pages/drive.vue')), + loginRequired: true, +}, { + path: '/my/drive', + component: page(() => import('./pages/drive.vue')), + loginRequired: true, +}, { + path: '/my/follow-requests', + component: page(() => import('./pages/follow-requests.vue')), + loginRequired: true, +}, { + path: '/my/lists/:listId', + component: page(() => import('./pages/my-lists/list.vue')), + loginRequired: true, +}, { + path: '/my/lists', + component: page(() => import('./pages/my-lists/index.vue')), + loginRequired: true, +}, { + path: '/my/clips', + component: page(() => import('./pages/my-clips/index.vue')), + loginRequired: true, +}, { + path: '/my/antennas/create', + component: page(() => import('./pages/my-antennas/create.vue')), + loginRequired: true, +}, { + path: '/my/antennas/:antennaId', + component: page(() => import('./pages/my-antennas/edit.vue')), + loginRequired: true, +}, { + path: '/my/antennas', + component: page(() => import('./pages/my-antennas/index.vue')), + loginRequired: true, +}, { + path: '/timeline/list/:listId', + component: page(() => import('./pages/user-list-timeline.vue')), + loginRequired: true, +}, { + path: '/timeline/antenna/:antennaId', + component: page(() => import('./pages/antenna-timeline.vue')), + loginRequired: true, +}, { + name: 'index', + path: '/', + component: $i ? page(() => import('./pages/timeline.vue')) : page(() => import('./pages/welcome.vue')), + globalCacheKey: 'index', +}, { + path: '/:(*)', + component: page(() => import('./pages/not-found.vue')), +}]; -const defaultRoutes = [ - // NOTE: MkTimelineをdynamic importするとAsyncComponentWrapperが間に入るせいでkeep-aliveのコンポーネント指定が効かなくなる - { path: '/', name: 'index', component: $i ? MkTimeline : page('welcome') }, - { path: '/@:acct/:page?', name: 'user', component: page(() => import('./pages/user/index.vue')), props: route => ({ acct: route.params.acct, page: route.params.page || 'index' }) }, - { path: '/@:user/pages/:page', component: page('page'), props: route => ({ pageName: route.params.page, username: route.params.user }) }, - { path: '/@:user/pages/:pageName/view-source', component: page(() => import('./pages/page-editor/page-editor.vue')), props: route => ({ initUser: route.params.user, initPageName: route.params.pageName }) }, - { path: '/settings/:page(.*)?', name: 'settings', component: page(() => import('./pages/settings/index.vue')), props: route => ({ initialPage: route.params.page || null }) }, - { path: '/reset-password/:token?', component: page('reset-password'), props: route => ({ token: route.params.token }) }, - { path: '/signup-complete/:code', component: page('signup-complete'), props: route => ({ code: route.params.code }) }, - { path: '/announcements', component: page('announcements') }, - { path: '/about', component: page('about') }, - { path: '/about-misskey', component: page('about-misskey') }, - { path: '/featured', component: page('featured') }, - { path: '/theme-editor', component: page('theme-editor') }, - { path: '/advanced-theme-editor', component: page('advanced-theme-editor') }, - { path: '/explore', component: page('explore') }, - { path: '/explore/tags/:tag', props: true, component: page('explore') }, - { path: '/federation', component: page('federation') }, - { path: '/emojis', component: page('emojis') }, - { path: '/search', component: page('search'), props: route => ({ query: route.query.q, channel: route.query.channel }) }, - { path: '/pages', name: 'pages', component: page('pages') }, - { path: '/pages/new', component: page(() => import('./pages/page-editor/page-editor.vue')) }, - { path: '/pages/edit/:pageId', component: page(() => import('./pages/page-editor/page-editor.vue')), props: route => ({ initPageId: route.params.pageId }) }, - { path: '/gallery', component: page(() => import('./pages/gallery/index.vue')) }, - { path: '/gallery/new', component: page(() => import('./pages/gallery/edit.vue')) }, - { path: '/gallery/:postId/edit', component: page(() => import('./pages/gallery/edit.vue')), props: route => ({ postId: route.params.postId }) }, - { path: '/gallery/:postId', component: page(() => import('./pages/gallery/post.vue')), props: route => ({ postId: route.params.postId }) }, - { path: '/channels', component: page('channels') }, - { path: '/channels/new', component: page('channel-editor') }, - { path: '/channels/:channelId/edit', component: page('channel-editor'), props: true }, - { path: '/channels/:channelId', component: page('channel'), props: route => ({ channelId: route.params.channelId }) }, - { path: '/clips/:clipId', component: page('clip'), props: route => ({ clipId: route.params.clipId }) }, - { path: '/timeline/list/:listId', component: page('user-list-timeline'), props: route => ({ listId: route.params.listId }) }, - { path: '/timeline/antenna/:antennaId', component: page('antenna-timeline'), props: route => ({ antennaId: route.params.antennaId }) }, - { path: '/my/notifications', component: page('notifications') }, - { path: '/my/favorites', component: page('favorites') }, - { path: '/my/messages', component: page('messages') }, - { path: '/my/mentions', component: page('mentions') }, - { path: '/my/messaging', name: 'messaging', component: page(() => import('./pages/messaging/index.vue')) }, - { path: '/my/messaging/:user', component: page(() => import('./pages/messaging/messaging-room.vue')), props: route => ({ userAcct: route.params.user }) }, - { path: '/my/messaging/group/:group', component: page(() => import('./pages/messaging/messaging-room.vue')), props: route => ({ groupId: route.params.group }) }, - { path: '/my/drive', name: 'drive', component: page('drive') }, - { path: '/my/drive/folder/:folder', component: page('drive') }, - { path: '/my/follow-requests', component: page('follow-requests') }, - { path: '/my/lists', component: page(() => import('./pages/my-lists/index.vue')) }, - { path: '/my/lists/:list', component: page(() => import('./pages/my-lists/list.vue')) }, - { path: '/my/groups', component: page(() => import('./pages/my-groups/index.vue')) }, - { path: '/my/groups/:group', component: page(() => import('./pages/my-groups/group.vue')), props: route => ({ groupId: route.params.group }) }, - { path: '/my/antennas', component: page(() => import('./pages/my-antennas/index.vue')) }, - { path: '/my/antennas/create', component: page(() => import('./pages/my-antennas/create.vue')) }, - { path: '/my/antennas/:antennaId', component: page(() => import('./pages/my-antennas/edit.vue')), props: true }, - { path: '/my/clips', component: page(() => import('./pages/my-clips/index.vue')) }, - { path: '/scratchpad', component: page('scratchpad') }, - { path: '/admin/:page(.*)?', component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page('not-found'), props: route => ({ initialPage: route.params.page || null }) }, - { path: '/admin', component: iAmModerator ? page(() => import('./pages/admin/index.vue')) : page('not-found') }, - { path: '/notes/:note', name: 'note', component: page('note'), props: route => ({ noteId: route.params.note }) }, - { path: '/tags/:tag', component: page('tag'), props: route => ({ tag: route.params.tag }) }, - { path: '/user-info/:user', component: page('user-info'), props: route => ({ userId: route.params.user }) }, - { path: '/instance-info/:host', component: page('instance-info'), props: route => ({ host: route.params.host }) }, - { path: '/mfm-cheat-sheet', component: page('mfm-cheat-sheet') }, - { path: '/api-console', component: page('api-console') }, - { path: '/preview', component: page('preview') }, - { path: '/test', component: page('test') }, - { path: '/auth/:token', component: page('auth') }, - { path: '/miauth/:session', component: page('miauth') }, - { path: '/authorize-follow', component: page('follow') }, - { path: '/share', component: page('share') }, - { path: '/:catchAll(.*)', component: page('not-found') } -]; +export const mainRouter = new Router(routes, location.pathname + location.search + location.hash); -const chatRoutes = [ - { path: '/timeline', component: page('timeline', 'chat'), props: route => ({ src: 'home' }) }, - { path: '/timeline/home', component: page('timeline', 'chat'), props: route => ({ src: 'home' }) }, - { path: '/timeline/local', component: page('timeline', 'chat'), props: route => ({ src: 'local' }) }, - { path: '/timeline/social', component: page('timeline', 'chat'), props: route => ({ src: 'social' }) }, - { path: '/timeline/global', component: page('timeline', 'chat'), props: route => ({ src: 'global' }) }, - { path: '/channels/:channelId', component: page('channel', 'chat'), props: route => ({ channelId: route.params.channelId }) }, -]; +window.history.replaceState({ key: mainRouter.getCurrentKey() }, '', location.href); -function margeRoutes(routes: any[]) { - const result = defaultRoutes; - for (const route of routes) { - const found = result.findIndex(x => x.path === route.path); - if (found > -1) { - result[found] = route; - } else { - result.unshift(route); - } - } - return result; -} +// TODO: このファイルでスクロール位置も管理する設計だとdeckに対応できないのでなんとかする +// スクロール位置取得+スクロール位置設定関数をprovideする感じでも良いかも + +const scrollPosStore = new Map<string, number>(); + +window.setInterval(() => { + scrollPosStore.set(window.history.state?.key, window.scrollY); +}, 1000); -export const router = createRouter({ - history: createWebHistory(), - routes: margeRoutes(ui === 'chat' ? chatRoutes : []), - // なんかHacky - // 通常の使い方をすると scroll メソッドの behavior を設定できないため、自前で window.scroll するようにする - scrollBehavior(to) { - window._scroll = () => { // さらにHacky - if (to.name === 'index') { - window.scroll({ top: indexScrollPos, behavior: 'instant' }); - const i = window.setInterval(() => { - window.scroll({ top: indexScrollPos, behavior: 'instant' }); - }, 10); - window.setTimeout(() => { - window.clearInterval(i); - }, 500); - } else { - window.scroll({ top: 0, behavior: 'instant' }); - } - }; +mainRouter.addListener('push', ctx => { + window.history.pushState({ key: ctx.key }, '', ctx.path); + const scrollPos = scrollPosStore.get(ctx.key) ?? 0; + window.scroll({ top: scrollPos, behavior: 'instant' }); + if (scrollPos !== 0) { + window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール + window.scroll({ top: scrollPos, behavior: 'instant' }); + }, 1000); } }); -router.afterEach((to, from) => { - if (from.name === 'index') { - indexScrollPos = window.scrollY; - } +mainRouter.addListener('same', () => { + window.scroll({ top: 0, behavior: 'smooth' }); +}); + +window.addEventListener('popstate', (event) => { + mainRouter.change(location.pathname + location.search + location.hash, event.state?.key); + const scrollPos = scrollPosStore.get(event.state?.key) ?? 0; + window.scroll({ top: scrollPos, behavior: 'instant' }); + window.setTimeout(() => { // 遷移直後はタイミングによってはコンポーネントが復元し切ってない可能性も考えられるため少し時間を空けて再度スクロール + window.scroll({ top: scrollPos, behavior: 'instant' }); + }, 1000); }); -export function resolve(path: string) { - const resolved = router.resolve(path); - const route = resolved.matched[0]; - return { - component: markRaw(route.components.default), - // TODO: route.propsには関数以外も入る可能性があるのでよしなにハンドリングする - props: route.props?.default ? route.props.default(resolved) : resolved.params - }; +export function useRouter(): Router { + return inject<Router | null>('router', null) ?? mainRouter; } diff --git a/packages/client/src/scripts/array.ts b/packages/client/src/scripts/array.ts index 29d027de14..26c6195d66 100644 --- a/packages/client/src/scripts/array.ts +++ b/packages/client/src/scripts/array.ts @@ -98,7 +98,7 @@ export function groupOn<T, S>(f: (x: T) => S, xs: T[]): T[][] { export function groupByX<T>(collections: T[], keySelector: (x: T) => string) { return collections.reduce((obj: Record<string, T[]>, item: T) => { const key = keySelector(item); - if (!obj.hasOwnProperty(key)) { + if (typeof obj[key] === 'undefined') { obj[key] = []; } diff --git a/packages/client/src/scripts/autocomplete.ts b/packages/client/src/scripts/autocomplete.ts index 8d9bdee8f5..3ef6224175 100644 --- a/packages/client/src/scripts/autocomplete.ts +++ b/packages/client/src/scripts/autocomplete.ts @@ -8,7 +8,7 @@ export class Autocomplete { x: Ref<number>; y: Ref<number>; q: Ref<string | null>; - close: Function; + close: () => void; } | null; private textarea: HTMLInputElement | HTMLTextAreaElement; private currentType: string; diff --git a/packages/client/src/scripts/check-word-mute.ts b/packages/client/src/scripts/check-word-mute.ts index fa74c09939..35d40a6e08 100644 --- a/packages/client/src/scripts/check-word-mute.ts +++ b/packages/client/src/scripts/check-word-mute.ts @@ -3,7 +3,9 @@ export function checkWordMute(note: Record<string, any>, me: Record<string, any> if (me && (note.userId === me.id)) return false; if (mutedWords.length > 0) { - if (note.text == null) return false; + const text = ((note.cw ?? '') + '\n' + (note.text ?? '')).trim(); + + if (text === '') return false; const matched = mutedWords.some(filter => { if (Array.isArray(filter)) { @@ -11,7 +13,7 @@ export function checkWordMute(note: Record<string, any>, me: Record<string, any> const filteredFilter = filter.filter(keyword => keyword !== ''); if (filteredFilter.length === 0) return false; - return filteredFilter.every(keyword => note.text!.includes(keyword)); + return filteredFilter.every(keyword => text.includes(keyword)); } else { // represents RegExp const regexp = filter.match(/^\/(.+)\/(.*)$/); @@ -20,7 +22,7 @@ export function checkWordMute(note: Record<string, any>, me: Record<string, any> if (!regexp) return false; try { - return new RegExp(regexp[1], regexp[2]).test(note.text!); + return new RegExp(regexp[1], regexp[2]).test(text); } catch (err) { // This should never happen due to input sanitisation. return false; diff --git a/packages/client/src/scripts/gen-search-query.ts b/packages/client/src/scripts/gen-search-query.ts index 57a06c280c..b413cbbab1 100644 --- a/packages/client/src/scripts/gen-search-query.ts +++ b/packages/client/src/scripts/gen-search-query.ts @@ -21,7 +21,6 @@ export async function genSearchQuery(v: any, q: string) { } } } - } return { query: q.split(' ').filter(x => !x.startsWith('/') && !x.startsWith('@')).join(' '), diff --git a/packages/client/src/scripts/get-note-menu.ts b/packages/client/src/scripts/get-note-menu.ts index 78749ad6bb..632143f514 100644 --- a/packages/client/src/scripts/get-note-menu.ts +++ b/packages/client/src/scripts/get-note-menu.ts @@ -1,5 +1,6 @@ -import { defineAsyncComponent, Ref } from 'vue'; +import { defineAsyncComponent, Ref, inject } from 'vue'; import * as misskey from 'misskey-js'; +import { pleaseLogin } from './please-login'; import { $i } from '@/account'; import { i18n } from '@/i18n'; import { instance } from '@/instance'; @@ -7,13 +8,14 @@ import * as os from '@/os'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import { url } from '@/config'; import { noteActions } from '@/store'; -import { pleaseLogin } from './please-login'; export function getNoteMenu(props: { note: misskey.entities.Note; menuButton: Ref<HTMLElement>; translation: Ref<any>; translating: Ref<boolean>; + isDeleted: Ref<boolean>; + currentClipPage?: Ref<misskey.entities.Clip>; }) { const isRenote = ( props.note.renote != null && @@ -32,7 +34,7 @@ export function getNoteMenu(props: { if (canceled) return; os.api('notes/delete', { - noteId: appearNote.id + noteId: appearNote.id, }); }); } @@ -45,7 +47,7 @@ export function getNoteMenu(props: { if (canceled) return; os.api('notes/delete', { - noteId: appearNote.id + noteId: appearNote.id, }); os.post({ initialNote: appearNote, renote: appearNote.renote, reply: appearNote.reply, channel: appearNote.channel }); @@ -54,19 +56,19 @@ export function getNoteMenu(props: { function toggleFavorite(favorite: boolean): void { os.apiWithDialog(favorite ? 'notes/favorites/create' : 'notes/favorites/delete', { - noteId: appearNote.id + noteId: appearNote.id, }); } function toggleWatch(watch: boolean): void { os.apiWithDialog(watch ? 'notes/watching/create' : 'notes/watching/delete', { - noteId: appearNote.id + noteId: appearNote.id, }); } function toggleThreadMute(mute: boolean): void { os.apiWithDialog(mute ? 'notes/thread-muting/create' : 'notes/thread-muting/delete', { - noteId: appearNote.id + noteId: appearNote.id, }); } @@ -82,12 +84,12 @@ export function getNoteMenu(props: { function togglePin(pin: boolean): void { os.apiWithDialog(pin ? 'i/pin' : 'i/unpin', { - noteId: appearNote.id + noteId: appearNote.id, }, undefined, null, res => { if (res.id === '72dab508-c64d-498f-8740-a8eec1ba385a') { os.alert({ type: 'error', - text: i18n.ts.pinLimitExceeded + text: i18n.ts.pinLimitExceeded, }); } }); @@ -102,35 +104,60 @@ export function getNoteMenu(props: { const { canceled, result } = await os.form(i18n.ts.createNewClip, { name: { type: 'string', - label: i18n.ts.name + label: i18n.ts.name, }, description: { type: 'string', required: false, multiline: true, - label: i18n.ts.description + label: i18n.ts.description, }, isPublic: { type: 'boolean', label: i18n.ts.public, - default: false - } + default: false, + }, }); if (canceled) return; const clip = await os.apiWithDialog('clips/create', result); os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); - } + }, }, null, ...clips.map(clip => ({ text: clip.name, action: () => { - os.apiWithDialog('clips/add-note', { clipId: clip.id, noteId: appearNote.id }); - } + os.promiseDialog( + os.api('clips/add-note', { clipId: clip.id, noteId: appearNote.id }), + null, + async (err) => { + if (err.id === '734806c4-542c-463a-9311-15c512803965') { + const confirm = await os.confirm({ + type: 'warning', + text: i18n.t('confirmToUnclipAlreadyClippedNote', { name: clip.name }), + }); + if (!confirm.canceled) { + os.apiWithDialog('clips/remove-note', { clipId: clip.id, noteId: appearNote.id }); + if (props.currentClipPage?.value.id === clip.id) props.isDeleted.value = true; + } + } else { + os.alert({ + type: 'error', + text: err.message + '\n' + err.id, + }); + } + }, + ); + }, }))], props.menuButton.value, { }).then(focus); } + async function unclip(): Promise<void> { + os.apiWithDialog('clips/remove-note', { clipId: props.currentClipPage.value.id, noteId: appearNote.id }); + props.isDeleted.value = true; + } + async function promote(): Promise<void> { const { canceled, result: days } = await os.inputNumber({ title: i18n.ts.numberOfDays, @@ -166,77 +193,86 @@ export function getNoteMenu(props: { let menu; if ($i) { const statePromise = os.api('notes/state', { - noteId: appearNote.id + noteId: appearNote.id, }); - menu = [{ - icon: 'fas fa-copy', - text: i18n.ts.copyContent, - action: copyContent - }, { - icon: 'fas fa-link', - text: i18n.ts.copyLink, - action: copyLink - }, (appearNote.url || appearNote.uri) ? { - icon: 'fas fa-external-link-square-alt', - text: i18n.ts.showOnRemote, - action: () => { - window.open(appearNote.url || appearNote.uri, '_blank'); - } - } : undefined, - { - icon: 'fas fa-share-alt', - text: i18n.ts.share, - action: share - }, - instance.translatorAvailable ? { - icon: 'fas fa-language', - text: i18n.ts.translate, - action: translate - } : undefined, - null, - statePromise.then(state => state.isFavorited ? { - icon: 'fas fa-star', - text: i18n.ts.unfavorite, - action: () => toggleFavorite(false) - } : { - icon: 'fas fa-star', - text: i18n.ts.favorite, - action: () => toggleFavorite(true) - }), - { - icon: 'fas fa-paperclip', - text: i18n.ts.clip, - action: () => clip() - }, - (appearNote.userId !== $i.id) ? statePromise.then(state => state.isWatching ? { - icon: 'fas fa-eye-slash', - text: i18n.ts.unwatch, - action: () => toggleWatch(false) - } : { - icon: 'fas fa-eye', - text: i18n.ts.watch, - action: () => toggleWatch(true) - }) : undefined, - statePromise.then(state => state.isMutedThread ? { - icon: 'fas fa-comment-slash', - text: i18n.ts.unmuteThread, - action: () => toggleThreadMute(false) - } : { - icon: 'fas fa-comment-slash', - text: i18n.ts.muteThread, - action: () => toggleThreadMute(true) - }), - appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? { - icon: 'fas fa-thumbtack', - text: i18n.ts.unpin, - action: () => togglePin(false) - } : { - icon: 'fas fa-thumbtack', - text: i18n.ts.pin, - action: () => togglePin(true) - } : undefined, - /* + menu = [ + ...( + props.currentClipPage?.value.userId === $i.id ? [{ + icon: 'fas fa-circle-minus', + text: i18n.ts.unclip, + danger: true, + action: unclip, + }, null] : [] + ), + { + icon: 'fas fa-copy', + text: i18n.ts.copyContent, + action: copyContent, + }, { + icon: 'fas fa-link', + text: i18n.ts.copyLink, + action: copyLink, + }, (appearNote.url || appearNote.uri) ? { + icon: 'fas fa-external-link-square-alt', + text: i18n.ts.showOnRemote, + action: () => { + window.open(appearNote.url || appearNote.uri, '_blank'); + }, + } : undefined, + { + icon: 'fas fa-share-alt', + text: i18n.ts.share, + action: share, + }, + instance.translatorAvailable ? { + icon: 'fas fa-language', + text: i18n.ts.translate, + action: translate, + } : undefined, + null, + statePromise.then(state => state.isFavorited ? { + icon: 'fas fa-star', + text: i18n.ts.unfavorite, + action: () => toggleFavorite(false), + } : { + icon: 'fas fa-star', + text: i18n.ts.favorite, + action: () => toggleFavorite(true), + }), + { + icon: 'fas fa-paperclip', + text: i18n.ts.clip, + action: () => clip(), + }, + (appearNote.userId !== $i.id) ? statePromise.then(state => state.isWatching ? { + icon: 'fas fa-eye-slash', + text: i18n.ts.unwatch, + action: () => toggleWatch(false), + } : { + icon: 'fas fa-eye', + text: i18n.ts.watch, + action: () => toggleWatch(true), + }) : undefined, + statePromise.then(state => state.isMutedThread ? { + icon: 'fas fa-comment-slash', + text: i18n.ts.unmuteThread, + action: () => toggleThreadMute(false), + } : { + icon: 'fas fa-comment-slash', + text: i18n.ts.muteThread, + action: () => toggleThreadMute(true), + }), + appearNote.userId === $i.id ? ($i.pinnedNoteIds || []).includes(appearNote.id) ? { + icon: 'fas fa-thumbtack', + text: i18n.ts.unpin, + action: () => togglePin(false), + } : { + icon: 'fas fa-thumbtack', + text: i18n.ts.pin, + action: () => togglePin(true), + } : undefined, + /* ...($i.isModerator || $i.isAdmin ? [ null, { @@ -246,52 +282,52 @@ export function getNoteMenu(props: { }] : [] ),*/ - ...(appearNote.userId !== $i.id ? [ - null, - { - icon: 'fas fa-exclamation-circle', - text: i18n.ts.reportAbuse, - action: () => { - const u = appearNote.url || appearNote.uri || `${url}/notes/${appearNote.id}`; - os.popup(defineAsyncComponent(() => import('@/components/abuse-report-window.vue')), { - user: appearNote.user, - initialComment: `Note: ${u}\n-----\n` - }, {}, 'closed'); - } - }] + ...(appearNote.userId !== $i.id ? [ + null, + { + icon: 'fas fa-exclamation-circle', + text: i18n.ts.reportAbuse, + action: () => { + const u = appearNote.url || appearNote.uri || `${url}/notes/${appearNote.id}`; + os.popup(defineAsyncComponent(() => import('@/components/abuse-report-window.vue')), { + user: appearNote.user, + initialComment: `Note: ${u}\n-----\n`, + }, {}, 'closed'); + }, + }] : [] - ), - ...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [ - null, - appearNote.userId === $i.id ? { - icon: 'fas fa-edit', - text: i18n.ts.deleteAndEdit, - action: delEdit - } : undefined, - { - icon: 'fas fa-trash-alt', - text: i18n.ts.delete, - danger: true, - action: del - }] + ), + ...(appearNote.userId === $i.id || $i.isModerator || $i.isAdmin ? [ + null, + appearNote.userId === $i.id ? { + icon: 'fas fa-edit', + text: i18n.ts.deleteAndEdit, + action: delEdit, + } : undefined, + { + icon: 'fas fa-trash-alt', + text: i18n.ts.delete, + danger: true, + action: del, + }] : [] - )] + )] .filter(x => x !== undefined); } else { menu = [{ icon: 'fas fa-copy', text: i18n.ts.copyContent, - action: copyContent + action: copyContent, }, { icon: 'fas fa-link', text: i18n.ts.copyLink, - action: copyLink + action: copyLink, }, (appearNote.url || appearNote.uri) ? { icon: 'fas fa-external-link-square-alt', text: i18n.ts.showOnRemote, action: () => { window.open(appearNote.url || appearNote.uri, '_blank'); - } + }, } : undefined] .filter(x => x !== undefined); } @@ -302,7 +338,7 @@ export function getNoteMenu(props: { text: action.title, action: () => { action.handler(appearNote); - } + }, }))]); } diff --git a/packages/client/src/scripts/get-user-menu.ts b/packages/client/src/scripts/get-user-menu.ts index 091338efd6..25bcd90e9f 100644 --- a/packages/client/src/scripts/get-user-menu.ts +++ b/packages/client/src/scripts/get-user-menu.ts @@ -1,12 +1,12 @@ +import * as Acct from 'misskey-js/built/acct'; +import { defineAsyncComponent } from 'vue'; import { i18n } from '@/i18n'; import copyToClipboard from '@/scripts/copy-to-clipboard'; import { host } from '@/config'; -import * as Acct from 'misskey-js/built/acct'; import * as os from '@/os'; import { userActions } from '@/store'; -import { router } from '@/router'; import { $i, iAmModerator } from '@/account'; -import { defineAsyncComponent } from 'vue'; +import { mainRouter } from '@/router'; export function getUserMenu(user) { const meId = $i ? $i.id : null; @@ -17,20 +17,20 @@ export function getUserMenu(user) { if (lists.length === 0) { os.alert({ type: 'error', - text: i18n.ts.youHaveNoLists + text: i18n.ts.youHaveNoLists, }); return; } const { canceled, result: listId } = await os.select({ title: t, items: lists.map(list => ({ - value: list.id, text: list.name - })) + value: list.id, text: list.name, + })), }); if (canceled) return; os.apiWithDialog('users/lists/push', { listId: listId, - userId: user.id + userId: user.id, }); } @@ -39,20 +39,20 @@ export function getUserMenu(user) { if (groups.length === 0) { os.alert({ type: 'error', - text: i18n.ts.youHaveNoGroups + text: i18n.ts.youHaveNoGroups, }); return; } const { canceled, result: groupId } = await os.select({ title: i18n.ts.group, items: groups.map(group => ({ - value: group.id, text: group.name - })) + value: group.id, text: group.name, + })), }); if (canceled) return; os.apiWithDialog('users/groups/invite', { groupId: groupId, - userId: user.id + userId: user.id, }); } @@ -101,7 +101,7 @@ export function getUserMenu(user) { if (!await getConfirmed(user.isBlocking ? i18n.ts.unblockConfirm : i18n.ts.blockConfirm)) return; os.apiWithDialog(user.isBlocking ? 'blocking/delete' : 'blocking/create', { - userId: user.id + userId: user.id, }).then(() => { user.isBlocking = !user.isBlocking; }); @@ -111,7 +111,7 @@ export function getUserMenu(user) { if (!await getConfirmed(i18n.t(user.isSilenced ? 'unsilenceConfirm' : 'silenceConfirm'))) return; os.apiWithDialog(user.isSilenced ? 'admin/unsilence-user' : 'admin/silence-user', { - userId: user.id + userId: user.id, }).then(() => { user.isSilenced = !user.isSilenced; }); @@ -121,7 +121,7 @@ export function getUserMenu(user) { if (!await getConfirmed(i18n.t(user.isSuspended ? 'unsuspendConfirm' : 'suspendConfirm'))) return; os.apiWithDialog(user.isSuspended ? 'admin/unsuspend-user' : 'admin/suspend-user', { - userId: user.id + userId: user.id, }).then(() => { user.isSuspended = !user.isSuspended; }); @@ -145,7 +145,7 @@ export function getUserMenu(user) { async function invalidateFollow() { os.apiWithDialog('following/invalidate', { - userId: user.id + userId: user.id, }).then(() => { user.isFollowed = !user.isFollowed; }); @@ -156,19 +156,19 @@ export function getUserMenu(user) { text: i18n.ts.copyUsername, action: () => { copyToClipboard(`@${user.username}@${user.host || host}`); - } + }, }, { icon: 'fas fa-info-circle', text: i18n.ts.info, action: () => { os.pageWindow(`/user-info/${user.id}`); - } + }, }, { icon: 'fas fa-envelope', text: i18n.ts.sendMessage, action: () => { os.post({ specified: user }); - } + }, }, meId !== user.id ? { type: 'link', icon: 'fas fa-comments', @@ -177,47 +177,47 @@ export function getUserMenu(user) { } : undefined, null, { icon: 'fas fa-list-ul', text: i18n.ts.addToList, - action: pushList + action: pushList, }, meId !== user.id ? { icon: 'fas fa-users', text: i18n.ts.inviteToGroup, - action: inviteGroup + action: inviteGroup, } : undefined] as any; if ($i && meId !== user.id) { menu = menu.concat([null, { icon: user.isMuted ? 'fas fa-eye' : 'fas fa-eye-slash', text: user.isMuted ? i18n.ts.unmute : i18n.ts.mute, - action: toggleMute + action: toggleMute, }, { icon: 'fas fa-ban', text: user.isBlocking ? i18n.ts.unblock : i18n.ts.block, - action: toggleBlock + action: toggleBlock, }]); if (user.isFollowed) { menu = menu.concat([{ icon: 'fas fa-unlink', text: i18n.ts.breakFollow, - action: invalidateFollow + action: invalidateFollow, }]); } menu = menu.concat([null, { icon: 'fas fa-exclamation-circle', text: i18n.ts.reportAbuse, - action: reportAbuse + action: reportAbuse, }]); if (iAmModerator) { menu = menu.concat([null, { icon: 'fas fa-microphone-slash', text: user.isSilenced ? i18n.ts.unsilence : i18n.ts.silence, - action: toggleSilence + action: toggleSilence, }, { icon: 'fas fa-snowflake', text: user.isSuspended ? i18n.ts.unsuspend : i18n.ts.suspend, - action: toggleSuspend + action: toggleSuspend, }]); } } @@ -227,8 +227,8 @@ export function getUserMenu(user) { icon: 'fas fa-pencil-alt', text: i18n.ts.editProfile, action: () => { - router.push('/settings/profile'); - } + mainRouter.push('/settings/profile'); + }, }]); } @@ -238,7 +238,7 @@ export function getUserMenu(user) { text: action.title, action: () => { action.handler(user); - } + }, }))]); } diff --git a/packages/client/src/scripts/hotkey.ts b/packages/client/src/scripts/hotkey.ts index fd9c74f6c8..bd8c3b6cab 100644 --- a/packages/client/src/scripts/hotkey.ts +++ b/packages/client/src/scripts/hotkey.ts @@ -1,6 +1,8 @@ import keyCode from './keycode'; -type Keymap = Record<string, Function>; +type Callback = (ev: KeyboardEvent) => void; + +type Keymap = Record<string, Callback>; type Pattern = { which: string[]; @@ -11,14 +13,14 @@ type Pattern = { type Action = { patterns: Pattern[]; - callback: Function; + callback: Callback; allowRepeat: boolean; }; const parseKeymap = (keymap: Keymap) => Object.entries(keymap).map(([patterns, callback]): Action => { const result = { patterns: [], - callback: callback, + callback, allowRepeat: true } as Action; diff --git a/packages/client/src/scripts/hpml/evaluator.ts b/packages/client/src/scripts/hpml/evaluator.ts index 8106687b61..10023edffb 100644 --- a/packages/client/src/scripts/hpml/evaluator.ts +++ b/packages/client/src/scripts/hpml/evaluator.ts @@ -159,7 +159,6 @@ export class Hpml { @autobind private evaluate(expr: Expr, scope: HpmlScope): any { - if (isLiteralValue(expr)) { if (expr.type === null) { return null; diff --git a/packages/client/src/scripts/hpml/index.ts b/packages/client/src/scripts/hpml/index.ts index ac81eac2d9..7cf88d5961 100644 --- a/packages/client/src/scripts/hpml/index.ts +++ b/packages/client/src/scripts/hpml/index.ts @@ -14,13 +14,13 @@ export type Fn = { export type Type = 'string' | 'number' | 'boolean' | 'stringArray' | null; export const literalDefs: Record<string, { out: any; category: string; icon: any; }> = { - text: { out: 'string', category: 'value', icon: 'fas fa-quote-right', }, - multiLineText: { out: 'string', category: 'value', icon: 'fas fa-align-left', }, - textList: { out: 'stringArray', category: 'value', icon: 'fas fa-list', }, - number: { out: 'number', category: 'value', icon: 'fas fa-sort-numeric-up', }, - ref: { out: null, category: 'value', icon: 'fas fa-magic', }, - aiScriptVar: { out: null, category: 'value', icon: 'fas fa-magic', }, - fn: { out: 'function', category: 'value', icon: 'fas fa-square-root-alt', }, + text: { out: 'string', category: 'value', icon: 'fas fa-quote-right', }, + multiLineText: { out: 'string', category: 'value', icon: 'fas fa-align-left', }, + textList: { out: 'stringArray', category: 'value', icon: 'fas fa-list', }, + number: { out: 'number', category: 'value', icon: 'fas fa-sort-numeric-up', }, + ref: { out: null, category: 'value', icon: 'fas fa-magic', }, + aiScriptVar: { out: null, category: 'value', icon: 'fas fa-magic', }, + fn: { out: 'function', category: 'value', icon: 'fas fa-square-root-alt', }, }; export const blockDefs = [ diff --git a/packages/client/src/scripts/hpml/lib.ts b/packages/client/src/scripts/hpml/lib.ts index 01a44ffcdf..cab467a920 100644 --- a/packages/client/src/scripts/hpml/lib.ts +++ b/packages/client/src/scripts/hpml/lib.ts @@ -125,55 +125,56 @@ export function initAiLib(hpml: Hpml) { } }); */ - }) + }), }; } export const funcDefs: Record<string, { in: any[]; out: any; category: string; icon: any; }> = { - if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'fas fa-share-alt', }, - for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'fas fa-recycle', }, - not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag', }, - or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag', }, - and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag', }, - add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-plus', }, - subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-minus', }, - multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-times', }, - divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide', }, - mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide', }, - round: { in: ['number'], out: 'number', category: 'operation', icon: 'fas fa-calculator', }, - eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-equals', }, - notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-not-equal', }, - gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than', }, - lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than', }, - gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than-equal', }, - ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than-equal', }, - strLen: { in: ['string'], out: 'number', category: 'text', icon: 'fas fa-quote-right', }, - strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'fas fa-quote-right', }, - strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', }, - strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', }, - join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right', }, - stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'fas fa-exchange-alt', }, - numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'fas fa-exchange-alt', }, - splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'fas fa-exchange-alt', }, - pick: { in: [null, 'number'], out: null, category: 'list', icon: 'fas fa-indent', }, - listLen: { in: [null], out: 'number', category: 'list', icon: 'fas fa-indent', }, - rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice', }, - dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice', }, - seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice', }, - random: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice', }, - dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice', }, - seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'fas fa-dice', }, - randomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice', }, - dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice', }, - seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'fas fa-dice', }, - DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'fas fa-dice', }, // dailyRandomPickWithProbabilityMapping + if: { in: ['boolean', 0, 0], out: 0, category: 'flow', icon: 'fas fa-share-alt' }, + for: { in: ['number', 'function'], out: null, category: 'flow', icon: 'fas fa-recycle' }, + not: { in: ['boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + or: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + and: { in: ['boolean', 'boolean'], out: 'boolean', category: 'logical', icon: 'fas fa-flag' }, + add: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-plus' }, + subtract: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-minus' }, + multiply: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-times' }, + divide: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' }, + mod: { in: ['number', 'number'], out: 'number', category: 'operation', icon: 'fas fa-divide' }, + round: { in: ['number'], out: 'number', category: 'operation', icon: 'fas fa-calculator' }, + eq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-equals' }, + notEq: { in: [0, 0], out: 'boolean', category: 'comparison', icon: 'fas fa-not-equal' }, + gt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than' }, + lt: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than' }, + gtEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-greater-than-equal' }, + ltEq: { in: ['number', 'number'], out: 'boolean', category: 'comparison', icon: 'fas fa-less-than-equal' }, + strLen: { in: ['string'], out: 'number', category: 'text', icon: 'fas fa-quote-right' }, + strPick: { in: ['string', 'number'], out: 'string', category: 'text', icon: 'fas fa-quote-right' }, + strReplace: { in: ['string', 'string', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right' }, + strReverse: { in: ['string'], out: 'string', category: 'text', icon: 'fas fa-quote-right' }, + join: { in: ['stringArray', 'string'], out: 'string', category: 'text', icon: 'fas fa-quote-right' }, + stringToNumber: { in: ['string'], out: 'number', category: 'convert', icon: 'fas fa-exchange-alt' }, + numberToString: { in: ['number'], out: 'string', category: 'convert', icon: 'fas fa-exchange-alt' }, + splitStrByLine: { in: ['string'], out: 'stringArray', category: 'convert', icon: 'fas fa-exchange-alt' }, + pick: { in: [null, 'number'], out: null, category: 'list', icon: 'fas fa-indent' }, + listLen: { in: [null], out: 'number', category: 'list', icon: 'fas fa-indent' }, + rannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + dailyRannum: { in: ['number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + seedRannum: { in: [null, 'number', 'number'], out: 'number', category: 'random', icon: 'fas fa-dice' }, + random: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + dailyRandom: { in: ['number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + seedRandom: { in: [null, 'number'], out: 'boolean', category: 'random', icon: 'fas fa-dice' }, + randomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' }, + dailyRandomPick: { in: [0], out: 0, category: 'random', icon: 'fas fa-dice' }, + seedRandomPick: { in: [null, 0], out: 0, category: 'random', icon: 'fas fa-dice' }, + DRPWPM: { in: ['stringArray'], out: 'string', category: 'random', icon: 'fas fa-dice' }, // dailyRandomPickWithProbabilityMapping }; export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, visitor?: any) { - const date = new Date(); const day = `${visitor ? visitor.id : ''} ${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`; + // SHOULD be fine to ignore since it's intended + function shape isn't defined + // eslint-disable-next-line @typescript-eslint/ban-types const funcs: Record<string, Function> = { not: (a: boolean) => !a, or: (a: boolean, b: boolean) => a || b, @@ -189,7 +190,7 @@ export function initHpmlLib(expr: Expr, scope: HpmlScope, randomSeed: string, vi const result: any[] = []; for (let i = 0; i < times; i++) { result.push(fn.exec({ - [fn.slots[0]]: i + 1 + [fn.slots[0]]: i + 1, })); } return result; diff --git a/packages/client/src/scripts/i18n.ts b/packages/client/src/scripts/i18n.ts index 3fe88e5514..54184386da 100644 --- a/packages/client/src/scripts/i18n.ts +++ b/packages/client/src/scripts/i18n.ts @@ -11,13 +11,13 @@ export class I18n<T extends Record<string, any>> { // string にしているのは、ドット区切りでのパス指定を許可するため // なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも - public t(key: string, args?: Record<string, string>): string { + public t(key: string, args?: Record<string, string | number>): string { try { let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string; if (args) { for (const [k, v] of Object.entries(args)) { - str = str.replace(`{${k}}`, v); + str = str.replace(`{${k}}`, v.toString()); } } return str; diff --git a/packages/client/src/scripts/navigate.ts b/packages/client/src/scripts/navigate.ts deleted file mode 100644 index 08b891ec5b..0000000000 --- a/packages/client/src/scripts/navigate.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { inject } from 'vue'; -import { router } from '@/router'; -import { defaultStore } from '@/store'; - -export type Navigate = (path: string, record?: boolean) => void; - -export class MisskeyNavigator { - public readonly navHook: Navigate | null = null; - public readonly sideViewHook: Navigate | null = null; - - // It should be constructed during vue creating in order for inject function to work - constructor() { - this.navHook = inject<Navigate | null>('navHook', null); - this.sideViewHook = inject<Navigate | null>('sideViewHook', null); - } - - // Use this method instead of router.push() - public push(path: string, record = true) { - if (this.navHook) { - this.navHook(path, record); - } else { - if (defaultStore.state.defaultSideView && this.sideViewHook && path !== '/') { - return this.sideViewHook(path, record); - } - - if (router.currentRoute.value.path === path) { - window.scroll({ top: 0, behavior: 'smooth' }); - } else { - if (record) router.push(path); - else router.replace(path); - } - } - } -} diff --git a/packages/client/src/scripts/page-metadata.ts b/packages/client/src/scripts/page-metadata.ts new file mode 100644 index 0000000000..0db8369f9d --- /dev/null +++ b/packages/client/src/scripts/page-metadata.ts @@ -0,0 +1,41 @@ +import * as misskey from 'misskey-js'; +import { ComputedRef, inject, isRef, onActivated, onMounted, provide, ref, Ref } from 'vue'; + +export const setPageMetadata = Symbol('setPageMetadata'); +export const pageMetadataProvider = Symbol('pageMetadataProvider'); + +export type PageMetadata = { + title: string; + subtitle?: string; + icon?: string | null; + avatar?: misskey.entities.User | null; + userName?: misskey.entities.User | null; + bg?: string; +}; + +export function definePageMetadata(metadata: PageMetadata | null | Ref<PageMetadata | null> | ComputedRef<PageMetadata | null>): void { + const _metadata = isRef(metadata) ? metadata : ref(metadata); + + provide(pageMetadataProvider, _metadata); + + const set = inject(setPageMetadata) as any; + if (set) { + set(_metadata); + + onMounted(() => { + set(_metadata); + }); + + onActivated(() => { + set(_metadata); + }); + } +} + +export function provideMetadataReceiver(callback: (info: ComputedRef<PageMetadata>) => void): void { + provide(setPageMetadata, callback); +} + +export function injectPageMetadata(): PageMetadata | undefined { + return inject(pageMetadataProvider); +} diff --git a/packages/client/src/scripts/please-login.ts b/packages/client/src/scripts/please-login.ts index e21a6d2ed3..1f38061841 100644 --- a/packages/client/src/scripts/please-login.ts +++ b/packages/client/src/scripts/please-login.ts @@ -17,5 +17,5 @@ export function pleaseLogin(path?: string) { }, }, 'closed'); - throw new Error('signin required'); + if (!path) throw new Error('signin required'); } diff --git a/packages/client/src/scripts/scroll.ts b/packages/client/src/scripts/scroll.ts index 621fe88105..f5bc6bf9ce 100644 --- a/packages/client/src/scripts/scroll.ts +++ b/packages/client/src/scripts/scroll.ts @@ -1,9 +1,9 @@ type ScrollBehavior = 'auto' | 'smooth' | 'instant'; -export function getScrollContainer(el: Element | null): Element | null { - if (el == null || el.tagName === 'BODY') return null; - const overflow = window.getComputedStyle(el).getPropertyValue('overflow'); - if (overflow.endsWith('auto')) { // xとyを個別に指定している場合、hidden auto みたいな値になる +export function getScrollContainer(el: HTMLElement | null): HTMLElement | null { + if (el == null || el.tagName === 'HTML') return null; + const overflow = window.getComputedStyle(el).getPropertyValue('overflow-y'); + if (overflow === 'scroll' || overflow === 'auto') { return el; } else { return getScrollContainer(el.parentElement); @@ -22,6 +22,11 @@ export function isTopVisible(el: Element | null): boolean { return scrollTop <= topPosition; } +export function isBottomVisible(el: HTMLElement, tolerance = 1, container = getScrollContainer(el)) { + if (container) return el.scrollHeight <= container.clientHeight + Math.abs(container.scrollTop) + tolerance; + return el.scrollHeight <= window.innerHeight + window.scrollY + tolerance; +} + export function onScrollTop(el: Element, cb) { const container = getScrollContainer(el) || window; const onScroll = ev => { diff --git a/packages/client/src/scripts/search.ts b/packages/client/src/scripts/search.ts index 0aedee9c98..64914d3d65 100644 --- a/packages/client/src/scripts/search.ts +++ b/packages/client/src/scripts/search.ts @@ -1,6 +1,6 @@ import * as os from '@/os'; import { i18n } from '@/i18n'; -import { router } from '@/router'; +import { mainRouter } from '@/router'; export async function search() { const { canceled, result: query } = await os.inputText({ @@ -11,12 +11,12 @@ export async function search() { const q = query.trim(); if (q.startsWith('@') && !q.includes(' ')) { - router.push(`/${q}`); + mainRouter.push(`/${q}`); return; } if (q.startsWith('#')) { - router.push(`/tags/${encodeURIComponent(q.substr(1))}`); + mainRouter.push(`/tags/${encodeURIComponent(q.substr(1))}`); return; } @@ -36,14 +36,14 @@ export async function search() { //v.$root.$emit('warp', date); os.alert({ icon: 'fas fa-history', - iconOnly: true, autoClose: true + iconOnly: true, autoClose: true, }); return; } if (q.startsWith('https://')) { const promise = os.api('ap/show', { - uri: q + uri: q, }); os.promiseDialog(promise, null, null, i18n.ts.fetchingAsApObject); @@ -51,13 +51,13 @@ export async function search() { const res = await promise; if (res.type === 'User') { - router.push(`/@${res.object.username}@${res.object.host}`); + mainRouter.push(`/@${res.object.username}@${res.object.host}`); } else if (res.type === 'Note') { - router.push(`/notes/${res.object.id}`); + mainRouter.push(`/notes/${res.object.id}`); } return; } - router.push(`/search?q=${encodeURIComponent(q)}`); + mainRouter.push(`/search?q=${encodeURIComponent(q)}`); } diff --git a/packages/client/src/scripts/select-file.ts b/packages/client/src/scripts/select-file.ts index 461d613b42..17e31d96f1 100644 --- a/packages/client/src/scripts/select-file.ts +++ b/packages/client/src/scripts/select-file.ts @@ -1,9 +1,9 @@ import { ref } from 'vue'; +import { DriveFile } from 'misskey-js/built/entities'; import * as os from '@/os'; import { stream } from '@/stream'; import { i18n } from '@/i18n'; import { defaultStore } from '@/store'; -import { DriveFile } from 'misskey-js/built/entities'; import { uploadFile } from '@/scripts/upload'; function select(src: any, label: string | null, multiple: boolean): Promise<DriveFile | DriveFile[]> { @@ -20,10 +20,7 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv Promise.all(promises).then(driveFiles => { res(multiple ? driveFiles : driveFiles[0]); }).catch(err => { - os.alert({ - type: 'error', - text: err - }); + // アップロードのエラーは uploadFile 内でハンドリングされているためアラートダイアログを出したりはしてはいけない }); // 一応廃棄 @@ -47,7 +44,7 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv os.inputText({ title: i18n.ts.uploadFromUrl, type: 'url', - placeholder: i18n.ts.uploadFromUrlDescription + placeholder: i18n.ts.uploadFromUrlDescription, }).then(({ canceled, result: url }) => { if (canceled) return; @@ -64,35 +61,35 @@ function select(src: any, label: string | null, multiple: boolean): Promise<Driv os.api('drive/files/upload-from-url', { url: url, folderId: defaultStore.state.uploadFolder, - marker + marker, }); os.alert({ title: i18n.ts.uploadFromUrlRequested, - text: i18n.ts.uploadFromUrlMayTakeTime + text: i18n.ts.uploadFromUrlMayTakeTime, }); }); }; os.popupMenu([label ? { text: label, - type: 'label' + type: 'label', } : undefined, { type: 'switch', text: i18n.ts.keepOriginalUploading, - ref: keepOriginal + ref: keepOriginal, }, { text: i18n.ts.upload, icon: 'fas fa-upload', - action: chooseFileFromPc + action: chooseFileFromPc, }, { text: i18n.ts.fromDrive, icon: 'fas fa-cloud', - action: chooseFileFromDrive + action: chooseFileFromDrive, }, { text: i18n.ts.fromUrl, icon: 'fas fa-link', - action: chooseFileFromUrl + action: chooseFileFromUrl, }], src); }); } diff --git a/packages/client/src/scripts/twemoji-base.ts b/packages/client/src/scripts/twemoji-base.ts index cd50311b15..638aae3284 100644 --- a/packages/client/src/scripts/twemoji-base.ts +++ b/packages/client/src/scripts/twemoji-base.ts @@ -1 +1,12 @@ export const twemojiSvgBase = '/twemoji'; + +export function char2fileName(char: string): string { + let codes = Array.from(char).map(x => x.codePointAt(0)?.toString(16)); + if (!codes.includes('200d')) codes = codes.filter(x => x !== 'fe0f'); + codes = codes.filter(x => x && x.length); + return codes.join('-'); +} + +export function char2filePath(char: string): string { + return `${twemojiSvgBase}/${char2fileName(char)}.svg`; +} diff --git a/packages/client/src/scripts/upload.ts b/packages/client/src/scripts/upload.ts index 2f7b30b58d..51f1c1b86f 100644 --- a/packages/client/src/scripts/upload.ts +++ b/packages/client/src/scripts/upload.ts @@ -1,10 +1,11 @@ import { reactive, ref } from 'vue'; +import * as Misskey from 'misskey-js'; +import { readAndCompressImage } from 'browser-image-resizer'; import { defaultStore } from '@/store'; import { apiUrl } from '@/config'; -import * as Misskey from 'misskey-js'; import { $i } from '@/account'; -import { readAndCompressImage } from 'browser-image-resizer'; import { alert } from '@/os'; +import { i18n } from '@/i18n'; type Uploading = { id: string; @@ -31,7 +32,7 @@ export function uploadFile( file: File, folder?: any, name?: string, - keepOriginal: boolean = defaultStore.state.keepOriginalUploading + keepOriginal: boolean = defaultStore.state.keepOriginalUploading, ): Promise<Misskey.entities.DriveFile> { if (folder && typeof folder === 'object') folder = folder.id; @@ -45,7 +46,7 @@ export function uploadFile( name: name || file.name || 'untitled', progressMax: undefined, progressValue: undefined, - img: window.URL.createObjectURL(file) + img: window.URL.createObjectURL(file), }); uploads.value.push(ctx); @@ -80,14 +81,37 @@ export function uploadFile( xhr.open('POST', apiUrl + '/drive/files/create', true); xhr.onload = (ev) => { if (xhr.status !== 200 || ev.target == null || ev.target.response == null) { - // TODO: 消すのではなくて再送できるようにしたい + // TODO: 消すのではなくて(ネットワーク的なエラーなら)再送できるようにしたい uploads.value = uploads.value.filter(x => x.id !== id); - alert({ - type: 'error', - title: 'Failed to upload', - text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}` - }); + if (ev.target?.response) { + const res = JSON.parse(ev.target.response); + if (res.error?.id === 'bec5bd69-fba3-43c9-b4fb-2894b66ad5d2') { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseInappropriate, + }); + } else if (res.error?.id === 'd08dbc37-a6a9-463a-8c47-96c32ab5f064') { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: i18n.ts.cannotUploadBecauseNoFreeSpace, + }); + } else { + alert({ + type: 'error', + title: i18n.ts.failedToUpload, + text: `${res.error?.message}\n${res.error?.code}\n${res.error?.id}`, + }); + } + } else { + alert({ + type: 'error', + title: 'Failed to upload', + text: `${JSON.stringify(ev.target?.response)}, ${JSON.stringify(xhr.response)}`, + }); + } reject(); return; diff --git a/packages/client/src/scripts/url.ts b/packages/client/src/scripts/url.ts index 542b00e0f0..86735de9f0 100644 --- a/packages/client/src/scripts/url.ts +++ b/packages/client/src/scripts/url.ts @@ -1,4 +1,4 @@ -export function query(obj: {}): string { +export function query(obj: Record<string, any>): string { const params = Object.entries(obj) .filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined) .reduce((a, [k, v]) => (a[k] = v, a), {} as Record<string, any>); diff --git a/packages/client/src/scripts/use-chart-tooltip.ts b/packages/client/src/scripts/use-chart-tooltip.ts new file mode 100644 index 0000000000..ab57165694 --- /dev/null +++ b/packages/client/src/scripts/use-chart-tooltip.ts @@ -0,0 +1,50 @@ +import { onUnmounted, ref } from 'vue'; +import * as os from '@/os'; +import MkChartTooltip from '@/components/chart-tooltip.vue'; + +export function useChartTooltip() { + const tooltipShowing = ref(false); + const tooltipX = ref(0); + const tooltipY = ref(0); + const tooltipTitle = ref(null); + const tooltipSeries = ref(null); + let disposeTooltipComponent; + + os.popup(MkChartTooltip, { + showing: tooltipShowing, + x: tooltipX, + y: tooltipY, + title: tooltipTitle, + series: tooltipSeries, + }, {}).then(({ dispose }) => { + disposeTooltipComponent = dispose; + }); + + onUnmounted(() => { + if (disposeTooltipComponent) disposeTooltipComponent(); + }); + + function handler(context) { + if (context.tooltip.opacity === 0) { + tooltipShowing.value = false; + return; + } + + tooltipTitle.value = context.tooltip.title[0]; + tooltipSeries.value = context.tooltip.body.map((b, i) => ({ + backgroundColor: context.tooltip.labelColors[i].backgroundColor, + borderColor: context.tooltip.labelColors[i].borderColor, + text: b.lines[0], + })); + + const rect = context.chart.canvas.getBoundingClientRect(); + + tooltipShowing.value = true; + tooltipX.value = rect.left + window.pageXOffset + context.tooltip.caretX; + tooltipY.value = rect.top + window.pageYOffset + context.tooltip.caretY; + } + + return { + handler, + }; +} diff --git a/packages/client/src/scripts/use-interval.ts b/packages/client/src/scripts/use-interval.ts new file mode 100644 index 0000000000..201ba417ef --- /dev/null +++ b/packages/client/src/scripts/use-interval.ts @@ -0,0 +1,24 @@ +import { onMounted, onUnmounted } from 'vue'; + +export function useInterval(fn: () => void, interval: number, options: { + immediate: boolean; + afterMounted: boolean; +}): void { + if (Number.isNaN(interval)) return; + + let intervalId: number | null = null; + + if (options.afterMounted) { + onMounted(() => { + if (options.immediate) fn(); + intervalId = window.setInterval(fn, interval); + }); + } else { + if (options.immediate) fn(); + intervalId = window.setInterval(fn, interval); + } + + onUnmounted(() => { + if (intervalId) window.clearInterval(intervalId); + }); +} diff --git a/packages/client/src/scripts/use-leave-guard.ts b/packages/client/src/scripts/use-leave-guard.ts index 33eea6b522..a93b84d1fe 100644 --- a/packages/client/src/scripts/use-leave-guard.ts +++ b/packages/client/src/scripts/use-leave-guard.ts @@ -1,9 +1,9 @@ import { inject, onUnmounted, Ref } from 'vue'; -import { onBeforeRouteLeave } from 'vue-router'; import { i18n } from '@/i18n'; import * as os from '@/os'; export function useLeaveGuard(enabled: Ref<boolean>) { + /* TODO const setLeaveGuard = inject('setLeaveGuard'); if (setLeaveGuard) { @@ -29,6 +29,7 @@ export function useLeaveGuard(enabled: Ref<boolean>) { return !canceled; }); } + */ /* function onBeforeLeave(ev: BeforeUnloadEvent) { diff --git a/packages/client/src/scripts/use-tooltip.ts b/packages/client/src/scripts/use-tooltip.ts index bc8f27a038..1f6e0fb6ce 100644 --- a/packages/client/src/scripts/use-tooltip.ts +++ b/packages/client/src/scripts/use-tooltip.ts @@ -3,6 +3,7 @@ import { Ref, ref, watch, onUnmounted } from 'vue'; export function useTooltip( elRef: Ref<HTMLElement | { $el: HTMLElement } | null | undefined>, onShow: (showing: Ref<boolean>) => void, + delay = 300, ): void { let isHovering = false; @@ -40,7 +41,7 @@ export function useTooltip( if (isHovering) return; if (shouldIgnoreMouseover) return; isHovering = true; - timeoutId = window.setTimeout(open, 300); + timeoutId = window.setTimeout(open, delay); }; const onMouseleave = () => { @@ -54,7 +55,7 @@ export function useTooltip( shouldIgnoreMouseover = true; if (isHovering) return; isHovering = true; - timeoutId = window.setTimeout(open, 300); + timeoutId = window.setTimeout(open, delay); }; const onTouchend = () => { diff --git a/packages/client/src/store.ts b/packages/client/src/store.ts index deee23951e..5033333313 100644 --- a/packages/client/src/store.ts +++ b/packages/client/src/store.ts @@ -13,55 +13,55 @@ export const notePostInterruptors = []; export const defaultStore = markRaw(new Storage('base', { tutorial: { where: 'account', - default: 0 + default: 0, }, keepCw: { where: 'account', - default: true + default: true, }, showFullAcct: { where: 'account', - default: false + default: false, }, rememberNoteVisibility: { where: 'account', - default: false + default: false, }, defaultNoteVisibility: { where: 'account', - default: 'public' + default: 'public', }, defaultNoteLocalOnly: { where: 'account', - default: false + default: false, }, uploadFolder: { where: 'account', - default: null as string | null + default: null as string | null, }, pastedFileName: { where: 'account', - default: 'yyyy-MM-dd HH-mm-ss [{{number}}]' + default: 'yyyy-MM-dd HH-mm-ss [{{number}}]', }, keepOriginalUploading: { where: 'account', - default: false + default: false, }, memo: { where: 'account', - default: null + default: null, }, reactions: { where: 'account', - default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'] + default: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], }, mutedWords: { where: 'account', - default: [] + default: [], }, mutedAds: { where: 'account', - default: [] as string[] + default: [] as string[], }, menu: { @@ -72,21 +72,31 @@ export const defaultStore = markRaw(new Storage('base', { 'drive', 'followRequests', '-', - 'featured', 'explore', 'announcements', 'search', '-', 'ui', - ] + ], }, visibility: { where: 'deviceAccount', - default: 'public' as 'public' | 'home' | 'followers' | 'specified' + default: 'public' as 'public' | 'home' | 'followers' | 'specified', }, localOnly: { where: 'deviceAccount', - default: false + default: false, + }, + statusbars: { + where: 'deviceAccount', + default: [] as { + name: string; + id: string; + type: string; + size: 'verySmall' | 'small' | 'medium' | 'large' | 'veryLarge'; + black: boolean; + props: Record<string, any>; + }[], }, widgets: { where: 'deviceAccount', @@ -95,14 +105,14 @@ export const defaultStore = markRaw(new Storage('base', { id: string; place: string | null; data: Record<string, any>; - }[] + }[], }, tl: { where: 'deviceAccount', default: { src: 'home' as 'home' | 'local' | 'social' | 'global', - arg: null - } + arg: null, + }, }, overridedDeviceKind: { @@ -111,87 +121,87 @@ export const defaultStore = markRaw(new Storage('base', { }, serverDisconnectedBehavior: { where: 'device', - default: 'quiet' as 'quiet' | 'reload' | 'dialog' + default: 'quiet' as 'quiet' | 'reload' | 'dialog', }, nsfw: { where: 'device', - default: 'respect' as 'respect' | 'force' | 'ignore' + default: 'respect' as 'respect' | 'force' | 'ignore', }, animation: { where: 'device', - default: true + default: true, }, animatedMfm: { where: 'device', - default: true + default: true, }, loadRawImages: { where: 'device', - default: false + default: false, }, imageNewTab: { where: 'device', - default: false + default: false, }, disableShowingAnimatedImages: { where: 'device', - default: false + default: false, }, disablePagesScript: { where: 'device', - default: false + default: false, }, useOsNativeEmojis: { where: 'device', - default: false + default: false, }, disableDrawer: { where: 'device', - default: false + default: false, }, useBlurEffectForModal: { where: 'device', - default: true + default: true, }, useBlurEffect: { where: 'device', - default: true + default: true, }, showFixedPostForm: { where: 'device', - default: false + default: false, }, enableInfiniteScroll: { where: 'device', - default: true + default: true, }, useReactionPickerForContextMenu: { where: 'device', - default: false + default: false, }, showGapBetweenNotesInTimeline: { where: 'device', - default: false + default: false, }, darkMode: { where: 'device', - default: false + default: false, }, instanceTicker: { where: 'device', - default: 'remote' as 'none' | 'remote' | 'always' + default: 'remote' as 'none' | 'remote' | 'always', }, reactionPickerSize: { where: 'device', - default: 1 + default: 1, }, reactionPickerWidth: { where: 'device', - default: 1 + default: 1, }, reactionPickerHeight: { where: 'device', - default: 2 + default: 2, }, reactionPickerUseDrawerForMobile: { where: 'device', @@ -199,43 +209,47 @@ export const defaultStore = markRaw(new Storage('base', { }, recentlyUsedEmojis: { where: 'device', - default: [] as string[] + default: [] as string[], }, recentlyUsedUsers: { where: 'device', - default: [] as string[] + default: [] as string[], }, defaultSideView: { where: 'device', - default: false + default: false, }, menuDisplay: { where: 'device', - default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top' + default: 'sideFull' as 'sideFull' | 'sideIcon' | 'top', }, reportError: { where: 'device', - default: false + default: false, }, squareAvatars: { where: 'device', - default: false + default: false, }, postFormWithHashtags: { where: 'device', - default: false + default: false, }, postFormHashtags: { where: 'device', - default: '' + default: '', }, themeInitial: { where: 'device', default: true, }, + numberOfPageCache: { + where: 'device', + default: 5, + }, aiChanMode: { where: 'device', - default: false + default: false, }, })); @@ -290,6 +304,14 @@ export class ColdDeviceStorage { } public static set<T extends keyof typeof ColdDeviceStorage.default>(key: T, value: typeof ColdDeviceStorage.default[T]): void { + // 呼び出し側のバグ等で undefined が来ることがある + // undefined を文字列として localStorage に入れると参照する際の JSON.parse でコケて不具合の元になるため無視 + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (value === undefined) { + console.error(`attempt to store undefined value for key '${key}'`); + return; + } + localStorage.setItem(PREFIX + key, JSON.stringify(value)); for (const watcher of this.watchers) { @@ -326,7 +348,7 @@ export class ColdDeviceStorage { set: (value: unknown) => { const val = value; ColdDeviceStorage.set(key, val); - } + }, }; } } diff --git a/packages/client/src/style.scss b/packages/client/src/style.scss index c1d47ffd08..9b30e64a31 100644 --- a/packages/client/src/style.scss +++ b/packages/client/src/style.scss @@ -39,14 +39,6 @@ html { scrollbar-color: var(--scrollbarHandle) inherit; scrollbar-width: thin; - &:hover { - scrollbar-color: var(--scrollbarHandleHover) inherit; - } - - &:active { - scrollbar-color: var(--accent) inherit; - } - &::-webkit-scrollbar { width: 6px; height: 6px; @@ -260,7 +252,7 @@ hr { ._panel { background: var(--panel); border-radius: var(--radius); - overflow: clip; + overflow: hidden; overflow: clip; } ._block { @@ -338,12 +330,6 @@ hr { } } -._window { - background: var(--panel); - border-radius: var(--radius); - contain: content; -} - ._popup { background: var(--popup); border-radius: var(--radius); @@ -413,6 +399,16 @@ hr { } } +._beta { + margin-left: 0.7em; + font-size: 65%; + padding: 2px 3px; + color: var(--accent); + border: solid 1px var(--accent); + border-radius: 4px; + vertical-align: top; +} + ._table { > ._row { display: flex; diff --git a/packages/client/src/symbols.ts b/packages/client/src/symbols.ts deleted file mode 100644 index 6913f29c28..0000000000 --- a/packages/client/src/symbols.ts +++ /dev/null @@ -1 +0,0 @@ -export const PAGE_INFO = Symbol('Page info'); diff --git a/packages/client/src/themes/_dark.json5 b/packages/client/src/themes/_dark.json5 index 1d87788794..88ec8a5459 100644 --- a/packages/client/src/themes/_dark.json5 +++ b/packages/client/src/themes/_dark.json5 @@ -30,6 +30,7 @@ panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelBorder: '" solid 1px var(--divider)', acrylicPanel: ':alpha<0.5<@panel', + windowHeader: ':alpha<0.85<@panel', popup: ':lighten<3<@panel', shadow: 'rgba(0, 0, 0, 0.3)', header: ':alpha<0.7<@panel', @@ -59,6 +60,10 @@ buttonHoverBg: 'rgba(255, 255, 255, 0.1)', buttonGradateA: '@accent', buttonGradateB: ':hue<20<@accent', + swutchOffBg: 'rgba(255, 255, 255, 0.1)', + swutchOffFg: '@fg', + swutchOnBg: '@accentedBg', + swutchOnFg: '@accent', inputBorder: 'rgba(255, 255, 255, 0.1)', inputBorderHover: 'rgba(255, 255, 255, 0.2)', listItemHoverBg: 'rgba(255, 255, 255, 0.03)', @@ -72,6 +77,7 @@ codeString: '#ffb675', codeNumber: '#cfff9e', codeBoolean: '#c59eff', + deckDivider: '#000', htmlThemeColor: '@bg', X2: ':darken<2<@panel', X3: 'rgba(255, 255, 255, 0.05)', diff --git a/packages/client/src/themes/_light.json5 b/packages/client/src/themes/_light.json5 index 359b560688..bad1291c83 100644 --- a/packages/client/src/themes/_light.json5 +++ b/packages/client/src/themes/_light.json5 @@ -30,6 +30,7 @@ panelHeaderDivider: 'rgba(0, 0, 0, 0)', panelBorder: '" solid 1px var(--divider)', acrylicPanel: ':alpha<0.5<@panel', + windowHeader: ':alpha<0.85<@panel', popup: ':lighten<3<@panel', shadow: 'rgba(0, 0, 0, 0.1)', header: ':alpha<0.7<@panel', @@ -59,6 +60,10 @@ buttonHoverBg: 'rgba(0, 0, 0, 0.1)', buttonGradateA: '@accent', buttonGradateB: ':hue<20<@accent', + swutchOffBg: 'rgba(0, 0, 0, 0.1)', + swutchOffFg: '@panel', + swutchOnBg: '@accent', + swutchOnFg: '@fgOnAccent', inputBorder: 'rgba(0, 0, 0, 0.1)', inputBorderHover: 'rgba(0, 0, 0, 0.2)', listItemHoverBg: 'rgba(0, 0, 0, 0.03)', @@ -72,6 +77,7 @@ codeString: '#b98710', codeNumber: '#0fbbbb', codeBoolean: '#62b70c', + deckDivider: ':darken<3<@bg', htmlThemeColor: '@bg', X2: ':darken<2<@panel', X3: 'rgba(0, 0, 0, 0.05)', diff --git a/packages/client/src/ui/_common_/sidebar-for-mobile.vue b/packages/client/src/ui/_common_/sidebar-for-mobile.vue index 41d0837233..e789ae5e06 100644 --- a/packages/client/src/ui/_common_/sidebar-for-mobile.vue +++ b/packages/client/src/ui/_common_/sidebar-for-mobile.vue @@ -1,32 +1,32 @@ <template> <div class="kmwsukvl"> - <div> + <div class="body"> <button v-click-anime class="item _button account" @click="openAccountMenu"> <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> </button> <MkA v-click-anime class="item index" active-class="active" to="/" exact> - <i class="fas fa-home fa-fw"></i><span class="text">{{ $ts.timeline }}</span> + <i class="icon fas fa-home fa-fw"></i><span class="text">{{ $ts.timeline }}</span> </MkA> <template v-for="item in menu"> <div v-if="item === '-'" class="divider"></div> <component :is="menuDef[item].to ? 'MkA' : 'button'" v-else-if="menuDef[item] && (menuDef[item].show !== false)" v-click-anime class="item _button" :class="[item, { active: menuDef[item].active }]" active-class="active" :to="menuDef[item].to" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}"> - <i class="fa-fw" :class="menuDef[item].icon"></i><span class="text">{{ $ts[menuDef[item].title] }}</span> - <span v-if="menuDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span> + <i class="icon fa-fw" :class="menuDef[item].icon"></i><span class="text">{{ $ts[menuDef[item].title] }}</span> + <span v-if="menuDef[item].indicated" class="indicator"><i class="icon fas fa-circle"></i></span> </component> </template> <div class="divider"></div> <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin"> - <i class="fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span> + <i class="icon fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span> </MkA> <button v-click-anime class="item _button" @click="more"> - <i class="fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> - <span v-if="otherMenuItemIndicated" class="indicator"><i class="fas fa-circle"></i></span> + <i class="icon fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> + <span v-if="otherMenuItemIndicated" class="indicator"><i class="icon fas fa-circle"></i></span> </button> <MkA v-click-anime class="item" active-class="active" to="/settings"> - <i class="fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span> + <i class="icon fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span> </MkA> <button class="item _button post" data-cy-open-post-form @click="post"> - <i class="fas fa-pencil-alt fa-fw"></i><span class="text">{{ $ts.note }}</span> + <i class="icon fas fa-pencil-alt fa-fw"></i><span class="text">{{ $ts.note }}</span> </button> </div> </div> @@ -81,7 +81,7 @@ export default defineComponent({ $avatar-size: 32px; $avatar-margin: 8px; - > div { + > .body { > .divider { margin: 16px 16px; @@ -102,12 +102,12 @@ export default defineComponent({ box-sizing: border-box; color: var(--navFg); - > i { + > .icon { position: relative; width: 32px; } - > i, + > .icon, > .avatar { margin-right: $avatar-margin; } diff --git a/packages/client/src/ui/_common_/sidebar.vue b/packages/client/src/ui/_common_/sidebar.vue index d65e776d86..a72bf786ad 100644 --- a/packages/client/src/ui/_common_/sidebar.vue +++ b/packages/client/src/ui/_common_/sidebar.vue @@ -1,32 +1,32 @@ <template> <div class="mvcprjjd" :class="{ iconOnly }"> - <div> + <div class="body"> <button v-click-anime class="item _button account" @click="openAccountMenu"> <MkAvatar :user="$i" class="avatar"/><MkAcct class="text" :user="$i"/> </button> <MkA v-click-anime class="item index" active-class="active" to="/" exact> - <i class="fas fa-home fa-fw"></i><span class="text">{{ $ts.timeline }}</span> + <i class="icon fas fa-home fa-fw"></i><span class="text">{{ $ts.timeline }}</span> </MkA> <template v-for="item in menu"> <div v-if="item === '-'" class="divider"></div> <component :is="menuDef[item].to ? 'MkA' : 'button'" v-else-if="menuDef[item] && (menuDef[item].show !== false)" v-click-anime class="item _button" :class="[item, { active: menuDef[item].active }]" active-class="active" :to="menuDef[item].to" v-on="menuDef[item].action ? { click: menuDef[item].action } : {}"> - <i class="fa-fw" :class="menuDef[item].icon"></i><span class="text">{{ $ts[menuDef[item].title] }}</span> - <span v-if="menuDef[item].indicated" class="indicator"><i class="fas fa-circle"></i></span> + <i class="icon fa-fw" :class="menuDef[item].icon"></i><span class="text">{{ $ts[menuDef[item].title] }}</span> + <span v-if="menuDef[item].indicated" class="indicator"><i class="icon fas fa-circle"></i></span> </component> </template> <div class="divider"></div> <MkA v-if="$i.isAdmin || $i.isModerator" v-click-anime class="item" active-class="active" to="/admin"> - <i class="fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span> + <i class="icon fas fa-door-open fa-fw"></i><span class="text">{{ $ts.controlPanel }}</span> </MkA> <button v-click-anime class="item _button" @click="more"> - <i class="fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> - <span v-if="otherMenuItemIndicated" class="indicator"><i class="fas fa-circle"></i></span> + <i class="icon fa fa-ellipsis-h fa-fw"></i><span class="text">{{ $ts.more }}</span> + <span v-if="otherMenuItemIndicated" class="indicator"><i class="icon fas fa-circle"></i></span> </button> <MkA v-click-anime class="item" active-class="active" to="/settings"> - <i class="fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span> + <i class="icon fas fa-cog fa-fw"></i><span class="text">{{ $ts.settings }}</span> </MkA> <button class="item _button post" data-cy-open-post-form @click="os.post"> - <i class="fas fa-pencil-alt fa-fw"></i><span class="text">{{ $ts.note }}</span> + <i class="icon fas fa-pencil-alt fa-fw"></i><span class="text">{{ $ts.note }}</span> </button> </div> </div> @@ -88,7 +88,7 @@ function more(ev: MouseEvent) { width: $nav-width; box-sizing: border-box; - > div { + > .body { position: fixed; top: 0; left: 0; @@ -100,6 +100,7 @@ function more(ev: MouseEvent) { overflow: auto; overflow-x: clip; background: var(--navBg); + contain: strict; > .divider { margin: 16px 16px; @@ -120,12 +121,12 @@ function more(ev: MouseEvent) { box-sizing: border-box; color: var(--navFg); - > i { + > .icon { position: relative; width: 32px; } - > i, + > .icon, > .avatar { margin-right: $avatar-margin; } @@ -230,7 +231,7 @@ function more(ev: MouseEvent) { flex: 0 0 $nav-icon-only-width; width: $nav-icon-only-width; - > div { + > .body { width: $nav-icon-only-width; > .divider { @@ -246,13 +247,13 @@ function more(ev: MouseEvent) { font-size: $ui-font-size * 1.1; line-height: initial; - > i, + > .icon, > .avatar { display: block; margin: 0 auto; } - > i { + > .icon { opacity: 0.7; } @@ -261,7 +262,7 @@ function more(ev: MouseEvent) { } &:hover, &.active { - > i, > .text { + > .icon, > .text { opacity: 1; } } @@ -284,7 +285,7 @@ function more(ev: MouseEvent) { &.post { height: $nav-icon-only-width; - > i { + > .icon { opacity: 1; } } diff --git a/packages/client/src/ui/_common_/statusbar-federation.vue b/packages/client/src/ui/_common_/statusbar-federation.vue new file mode 100644 index 0000000000..7cf6b1f973 --- /dev/null +++ b/packages/client/src/ui/_common_/statusbar-federation.vue @@ -0,0 +1,103 @@ +<template> +<span v-if="!fetching" class="nmidsaqw"> + <template v-if="display === 'marquee'"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <span v-for="instance in instances" :key="instance.id" class="item" :class="{ colored }" :style="{ background: colored ? instance.themeColor : null }"> + <img v-if="instance.iconUrl" class="icon" :src="instance.iconUrl" alt=""/> + <MkA :to="`/instance-info/${instance.host}`" class="host _monospace"> + {{ instance.host }} + </MkA> + <span class="divider"></span> + </span> + </MarqueeText> + </transition> + </template> + <template v-else-if="display === 'oneByOne'"> + <!-- TODO --> + </template> +</span> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import MarqueeText from '@/components/marquee.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { getNoteSummary } from '@/scripts/get-note-summary'; +import { notePage } from '@/filters/note'; + +const props = defineProps<{ + display?: 'marquee' | 'oneByOne'; + colored?: boolean; + marqueeDuration?: number; + marqueeReverse?: boolean; + oneByOneInterval?: number; + refreshIntervalSec?: number; +}>(); + +const instances = ref<misskey.entities.Instance[]>([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 30, + }).then(res => { + instances.value = res; + fetching.value = false; + key++; + }); +}; + +useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.nmidsaqw { + display: inline-block; + position: relative; + + ::v-deep(.item) { + display: inline-block; + vertical-align: bottom; + margin-right: 5em; + + > .icon { + display: inline-block; + height: var(--height); + aspect-ratio: 1; + vertical-align: bottom; + margin-right: 1em; + } + + > .host { + vertical-align: bottom; + } + + &.colored { + padding-right: 1em; + color: #fff; + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/statusbar-rss.vue b/packages/client/src/ui/_common_/statusbar-rss.vue new file mode 100644 index 0000000000..88604a38a7 --- /dev/null +++ b/packages/client/src/ui/_common_/statusbar-rss.vue @@ -0,0 +1,88 @@ +<template> +<span v-if="!fetching" class="xbhtxfms"> + <template v-if="display === 'marquee'"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <span v-for="item in items" class="item"> + <a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span> + </span> + </MarqueeText> + </transition> + </template> + <template v-else-if="display === 'oneByOne'"> + <!-- TODO --> + </template> +</span> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import MarqueeText from '@/components/marquee.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; + +const props = defineProps<{ + url?: string; + display?: 'marquee' | 'oneByOne'; + marqueeDuration?: number; + marqueeReverse?: boolean; + oneByOneInterval?: number; + refreshIntervalSec?: number; +}>(); + +const items = ref([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + fetch(`/api/fetch-rss?url=${props.url}`, {}).then(res => { + res.json().then(feed => { + items.value = feed.items; + fetching.value = false; + key++; + }); + }); +}; + +useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.xbhtxfms { + display: inline-block; + position: relative; + + ::v-deep(.item) { + display: inline-flex; + align-items: center; + vertical-align: bottom; + margin: 0; + + > .divider { + display: inline-block; + width: 0.5px; + height: var(--height); + margin: 0 3em; + background: currentColor; + opacity: 0.3; + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/statusbar-user-list.vue b/packages/client/src/ui/_common_/statusbar-user-list.vue new file mode 100644 index 0000000000..490c8a096e --- /dev/null +++ b/packages/client/src/ui/_common_/statusbar-user-list.vue @@ -0,0 +1,113 @@ +<template> +<span v-if="!fetching" class="osdsvwzy"> + <template v-if="display === 'marquee'"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="marqueeDuration" :reverse="marqueeReverse"> + <span v-for="note in notes" :key="note.id" class="item"> + <img class="avatar" :src="note.user.avatarUrl" decoding="async"/> + <MkA class="text" :to="notePage(note)"> + <Mfm class="text" :text="getNoteSummary(note)" :plain="true" :nowrap="true" :custom-emojis="note.emojis"/> + </MkA> + <span class="divider"></span> + </span> + </MarqueeText> + </transition> + </template> + <template v-else-if="display === 'oneByOne'"> + <!-- TODO --> + </template> +</span> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import * as misskey from 'misskey-js'; +import MarqueeText from '@/components/marquee.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; +import { getNoteSummary } from '@/scripts/get-note-summary'; +import { notePage } from '@/filters/note'; + +const props = defineProps<{ + userListId?: string; + display?: 'marquee' | 'oneByOne'; + marqueeDuration?: number; + marqueeReverse?: boolean; + oneByOneInterval?: number; + refreshIntervalSec?: number; +}>(); + +const notes = ref<misskey.entities.Note[]>([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + if (props.userListId == null) return; + os.api('notes/user-list-timeline', { + listId: props.userListId, + }).then(res => { + notes.value = res; + fetching.value = false; + key++; + }); +}; + +watch(() => props.userListId, tick); + +useInterval(tick, Math.max(5000, props.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.osdsvwzy { + display: inline-block; + position: relative; + + ::v-deep(.item) { + display: inline-flex; + align-items: center; + vertical-align: bottom; + margin: 0; + + > .avatar { + display: inline-block; + height: var(--height); + aspect-ratio: 1; + vertical-align: bottom; + margin-right: 8px; + } + + > .text { + > .text { + display: inline-block; + vertical-align: bottom; + } + } + + > .divider { + display: inline-block; + width: 0.5px; + height: 16px; + margin: 0 3em; + background: currentColor; + opacity: 0; + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/statusbars.vue b/packages/client/src/ui/_common_/statusbars.vue new file mode 100644 index 0000000000..80949665b3 --- /dev/null +++ b/packages/client/src/ui/_common_/statusbars.vue @@ -0,0 +1,91 @@ +<template> +<div class="dlrsnxqu"> + <div + v-for="x in defaultStore.reactiveState.statusbars.value" :key="x.id" class="item" :class="[{ black: x.black }, { + verySmall: x.size === 'verySmall', + small: x.size === 'small', + medium: x.size === 'medium', + large: x.size === 'large', + veryLarge: x.size === 'veryLarge', + }]" + > + <span class="name">{{ x.name }}</span> + <XRss v-if="x.type === 'rss'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :url="x.props.url"/> + <XFederation v-else-if="x.type === 'federation'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :colored="x.props.colored"/> + <XUserList v-else-if="x.type === 'userList'" class="body" :refresh-interval-sec="x.props.refreshIntervalSec" :marquee-duration="x.props.marqueeDuration" :marquee-reverse="x.props.marqueeReverse" :display="x.props.display" :user-list-id="x.props.userListId"/> + </div> +</div> +</template> + +<script lang="ts" setup> +import { computed, defineAsyncComponent, ref, toRef, watch } from 'vue'; +import * as os from '@/os'; +import { defaultStore } from '@/store'; +const XRss = defineAsyncComponent(() => import('./statusbar-rss.vue')); +const XFederation = defineAsyncComponent(() => import('./statusbar-federation.vue')); +const XUserList = defineAsyncComponent(() => import('./statusbar-user-list.vue')); +</script> + +<style lang="scss" scoped> +.dlrsnxqu { + background: var(--panel); + + > .item { + --height: 24px; + --nameMargin: 10px; + font-size: 0.85em; + + &.verySmall { + --nameMargin: 7px; + --height: 16px; + font-size: 0.75em; + } + + &.small { + --nameMargin: 8px; + --height: 20px; + font-size: 0.8em; + } + + &.large { + --nameMargin: 12px; + --height: 26px; + font-size: 0.875em; + } + + &.veryLarge { + --nameMargin: 14px; + --height: 30px; + font-size: 0.9em; + } + + display: flex; + vertical-align: bottom; + width: 100%; + line-height: var(--height); + height: var(--height); + overflow: hidden; overflow: clip; + contain: strict; + + > .name { + padding: 0 var(--nameMargin); + font-weight: bold; + color: var(--accent); + + &:empty { + display: none; + } + } + + > .body { + min-width: 0; + flex: 1; + } + + &.black { + background: #000; + color: #fff; + } + } +} +</style> diff --git a/packages/client/src/ui/_common_/sw-inject.ts b/packages/client/src/ui/_common_/sw-inject.ts index 371f80ca15..8676d2d48d 100644 --- a/packages/client/src/ui/_common_/sw-inject.ts +++ b/packages/client/src/ui/_common_/sw-inject.ts @@ -3,12 +3,9 @@ import { post } from '@/os'; import { $i, login } from '@/account'; import { defaultStore } from '@/store'; import { getAccountFromId } from '@/scripts/get-account-from-id'; -import { router } from '@/router'; +import { mainRouter } from '@/router'; export function swInject() { - const navHook = inject('navHook', null); - const sideViewHook = inject('sideViewHook', null); - navigator.serviceWorker.addEventListener('message', ev => { if (_DEV_) { console.log('sw msg', ev.data); @@ -27,16 +24,10 @@ export function swInject() { case 'post': return post(ev.data.options); case 'push': - if (router.currentRoute.value.path === ev.data.url) { + if (mainRouter.currentRoute.value.path === ev.data.url) { return window.scroll({ top: 0, behavior: 'smooth' }); } - if (navHook) { - return navHook(ev.data.url); - } - if (sideViewHook && defaultStore.state.defaultSideView && ev.data.url !== '/') { - return sideViewHook(ev.data.url); - } - return router.push(ev.data.url); + return mainRouter.push(ev.data.url); default: return; } diff --git a/packages/client/src/ui/classic.side.vue b/packages/client/src/ui/classic.side.vue deleted file mode 100644 index 6c2329194e..0000000000 --- a/packages/client/src/ui/classic.side.vue +++ /dev/null @@ -1,148 +0,0 @@ -<template> -<div v-if="component" class="qvzfzxam _narrow_"> - <div class="container"> - <header class="header" @contextmenu.prevent.stop="onContextmenu"> - <button v-if="history.length > 0" class="_button" @click="back()"><i class="fas fa-chevron-left"></i></button> - <button v-else class="_button" style="pointer-events: none;"><!-- マージンのバランスを取るためのダミー --></button> - <span class="title" v-text="pageInfo?.title" /> - <button class="_button" @click="close()"><i class="fas fa-times"></i></button> - </header> - <MkHeader class="pageHeader" :info="pageInfo"/> - <component :is="component" v-bind="props" :ref="changePage"/> - </div> -</div> -</template> - -<script lang="ts" setup> -import { provide } from 'vue'; -import * as os from '@/os'; -import copyToClipboard from '@/scripts/copy-to-clipboard'; -import { resolve, router } from '@/router'; -import { url as root } from '@/config'; -import * as symbols from '@/symbols'; -import { i18n } from '@/i18n'; - -provide('navHook', navigate); - -let path: string | null = $ref(null); -let component: ReturnType<typeof resolve>['component'] | null = $ref(null); -let props: any | null = $ref(null); -let pageInfo: any | null = $ref(null); -let history: string[] = $ref([]); - -let url = $computed(() => `${root}${path}`); - -function changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - pageInfo = page[symbols.PAGE_INFO]; - } -} - -function navigate(_path: string, record = true) { - if (record && path) history.push($$(path).value); - path = _path; - const resolved = resolve(path); - component = resolved.component; - props = resolved.props; -} - -function back() { - const prev = history.pop(); - if (prev) navigate(prev, false); -} - -function close() { - path = null; - component = null; - props = {}; -} - -function onContextmenu(ev: MouseEvent) { - os.contextMenu([{ - type: 'label', - text: path || '', - }, { - icon: 'fas fa-expand-alt', - text: i18n.ts.showInPage, - action: () => { - if (path) router.push(path); - close(); - } - }, { - icon: 'fas fa-window-maximize', - text: i18n.ts.openInWindow, - action: () => { - if (path) os.pageWindow(path); - close(); - } - }, null, { - icon: 'fas fa-external-link-alt', - text: i18n.ts.openInNewTab, - action: () => { - window.open(url, '_blank'); - close(); - } - }, { - icon: 'fas fa-link', - text: i18n.ts.copyLink, - action: () => { - copyToClipboard(url); - } - }], ev); -} - -defineExpose({ - navigate, - back, - close, -}); -</script> - -<style lang="scss" scoped> -.qvzfzxam { - $header-height: 58px; // TODO: どこかに集約したい - - --root-margin: 16px; - --margin: var(--marginHalf); - - > .container { - position: fixed; - width: 370px; - height: 100vh; - overflow: auto; - box-sizing: border-box; - - > .header { - display: flex; - position: sticky; - z-index: 1000; - top: 0; - height: $header-height; - width: 100%; - line-height: $header-height; - text-align: center; - font-weight: bold; - //background-color: var(--panel); - -webkit-backdrop-filter: var(--blur, blur(32px)); - backdrop-filter: var(--blur, blur(32px)); - background-color: var(--header); - - > ._button { - height: $header-height; - width: $header-height; - - &:hover { - color: var(--fgHighlighted); - } - } - - > .title { - flex: 1; - position: relative; - } - } - } -} -</style> - diff --git a/packages/client/src/ui/classic.vue b/packages/client/src/ui/classic.vue index c61cbc433e..a2c26f536e 100644 --- a/packages/client/src/ui/classic.vue +++ b/packages/client/src/ui/classic.vue @@ -7,31 +7,23 @@ <XSidebar/> </div> <div v-else ref="widgetsLeft" class="widgets left"> - <XWidgets :place="'left'" @mounted="attachSticky('widgetsLeft')"/> + <XWidgets :place="'left'" @mounted="attachSticky(widgetsLeft)"/> </div> - <main class="main" :style="{ background: pageInfo?.bg }" @contextmenu.stop="onContextmenu"> + <main class="main" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu"> <div class="content"> - <MkStickyContainer> - <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> - <router-view v-slot="{ Component }"> - <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> - <keep-alive :include="['MkTimelinePage']"> - <component :is="Component" :ref="changePage"/> - </keep-alive> - </transition> - </router-view> - </MkStickyContainer> + <RouterView/> </div> </main> <div v-if="isDesktop" ref="widgetsRight" class="widgets right"> - <XWidgets :place="null" @mounted="attachSticky('widgetsRight')"/> + <XWidgets :place="null" @mounted="attachSticky(widgetsRight)"/> </div> </div> <transition :name="$store.state.animation ? 'tray-back' : ''"> - <div v-if="widgetsShowing" + <div + v-if="widgetsShowing" class="tray-back _modalBg" @click="widgetsShowing = false" @touchstart.passive="widgetsShowing = false" @@ -48,157 +40,134 @@ </div> </template> -<script lang="ts"> -import { defineComponent, defineAsyncComponent, markRaw } from 'vue'; -import { instanceName } from '@/config'; -import { StickySidebar } from '@/scripts/sticky-sidebar'; +<script lang="ts" setup> +import { defineAsyncComponent, markRaw, ComputedRef, ref, onMounted, provide } from 'vue'; import XSidebar from './classic.sidebar.vue'; import XCommon from './_common_/common.vue'; +import { instanceName } from '@/config'; +import { StickySidebar } from '@/scripts/sticky-sidebar'; import * as os from '@/os'; import { menuDef } from '@/menu'; -import * as symbols from '@/symbols'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import { defaultStore } from '@/store'; +import { i18n } from '@/i18n'; +const XHeaderMenu = defineAsyncComponent(() => import('./classic.header.vue')); +const XWidgets = defineAsyncComponent(() => import('./classic.widgets.vue')); const DESKTOP_THRESHOLD = 1100; -export default defineComponent({ - components: { - XCommon, - XSidebar, - XHeaderMenu: defineAsyncComponent(() => import('./classic.header.vue')), - XWidgets: defineAsyncComponent(() => import('./classic.widgets.vue')), - }, +let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD); - provide() { - return { - shouldHeaderThin: this.showMenuOnTop, - shouldSpacerMin: true, - }; - }, +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); +let widgetsShowing = $ref(false); +let fullView = $ref(false); +let globalHeaderHeight = $ref(0); +const wallpaper = localStorage.getItem('wallpaper') != null; +const showMenuOnTop = $computed(() => defaultStore.state.menuDisplay === 'top'); +let live2d = $ref<HTMLIFrameElement>(); +let widgetsLeft = $ref(); +let widgetsRight = $ref(); - data() { - return { - pageInfo: null, - menuDef: menuDef, - globalHeaderHeight: 0, - isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, - widgetsShowing: false, - fullView: false, - wallpaper: localStorage.getItem('wallpaper') != null, - }; - }, - - computed: { - showMenuOnTop(): boolean { - return this.$store.state.menuDisplay === 'top'; - } - }, - - created() { - if (window.innerWidth < 1024) { - localStorage.setItem('ui', 'default'); - location.reload(); - } - - document.documentElement.style.overflowY = 'scroll'; +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; + if (pageMetadata.value) { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } +}); +provide('shouldHeaderThin', showMenuOnTop); +provide('shouldSpacerMin', true); - if (this.$store.state.widgets.length === 0) { - this.$store.set('widgets', [{ - name: 'calendar', - id: 'a', place: null, data: {} - }, { - name: 'notifications', - id: 'b', place: null, data: {} - }, { - name: 'trends', - id: 'c', place: null, data: {} - }]); - } - }, +function attachSticky(el) { + const sticky = new StickySidebar(el, defaultStore.state.menuDisplay === 'top' ? 0 : 16, defaultStore.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す + window.addEventListener('scroll', () => { + sticky.calc(window.scrollY); + }, { passive: true }); +} - mounted() { - window.addEventListener('resize', () => { - this.isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD); - }, { passive: true }); +function top() { + window.scroll({ top: 0, behavior: 'smooth' }); +} - if (this.$store.state.aiChanMode) { - const iframeRect = this.$refs.live2d.getBoundingClientRect(); - window.addEventListener('mousemove', ev => { - this.$refs.live2d.contentWindow.postMessage({ - type: 'moveCursor', - body: { - x: ev.clientX - iframeRect.left, - y: ev.clientY - iframeRect.top, - } - }, '*'); - }, { passive: true }); - window.addEventListener('touchmove', ev => { - this.$refs.live2d.contentWindow.postMessage({ - type: 'moveCursor', - body: { - x: ev.touches[0].clientX - iframeRect.left, - y: ev.touches[0].clientY - iframeRect.top, - } - }, '*'); - }, { passive: true }); +function onContextmenu(ev: MouseEvent) { + const isLink = (el: HTMLElement) => { + if (el.tagName === 'A') return true; + if (el.parentElement) { + return isLink(el.parentElement); } - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - document.title = `${this.pageInfo.title} | ${instanceName}`; - } + }; + if (isLink(ev.target)) return; + if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; + if (window.getSelection().toString() !== '') return; + const path = mainRouter.getCurrentPath(); + os.contextMenu([{ + type: 'label', + text: path, + }, { + icon: fullView ? 'fas fa-compress' : 'fas fa-expand', + text: fullView ? i18n.ts.quitFullView : i18n.ts.fullView, + action: () => { + fullView = !fullView; }, - - attachSticky(ref) { - const sticky = new StickySidebar(this.$refs[ref], this.$store.state.menuDisplay === 'top' ? 0 : 16, this.$store.state.menuDisplay === 'top' ? 60 : 0); // TODO: ヘッダーの高さを60pxと決め打ちしているのを直す - window.addEventListener('scroll', () => { - sticky.calc(window.scrollY); - }, { passive: true }); + }, { + icon: 'fas fa-window-maximize', + text: i18n.ts.openInWindow, + action: () => { + os.pageWindow(path); }, + }], ev); +} - top() { - window.scroll({ top: 0, behavior: 'smooth' }); - }, +function onAiClick(ev) { + //if (this.live2d) this.live2d.click(ev); +} - onTransition() { - if (window._scroll) window._scroll(); - }, +if (window.innerWidth < 1024) { + localStorage.setItem('ui', 'default'); + location.reload(); +} - onContextmenu(ev: MouseEvent) { - const isLink = (el: HTMLElement) => { - if (el.tagName === 'A') return true; - if (el.parentElement) { - return isLink(el.parentElement); - } - }; - if (isLink(ev.target)) return; - if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; - if (window.getSelection().toString() !== '') return; - const path = this.$route.path; - os.contextMenu([{ - type: 'label', - text: path, - }, { - icon: this.fullView ? 'fas fa-compress' : 'fas fa-expand', - text: this.fullView ? this.$ts.quitFullView : this.$ts.fullView, - action: () => { - this.fullView = !this.fullView; - } - }, { - icon: 'fas fa-window-maximize', - text: this.$ts.openInWindow, - action: () => { - os.pageWindow(path); - } - }], ev); - }, +document.documentElement.style.overflowY = 'scroll'; - onAiClick(ev) { - //if (this.live2d) this.live2d.click(ev); - } +if (defaultStore.state.widgets.length === 0) { + defaultStore.set('widgets', [{ + name: 'calendar', + id: 'a', place: null, data: {}, + }, { + name: 'notifications', + id: 'b', place: null, data: {}, + }, { + name: 'trends', + id: 'c', place: null, data: {}, + }]); +} + +onMounted(() => { + window.addEventListener('resize', () => { + isDesktop = (window.innerWidth >= DESKTOP_THRESHOLD); + }, { passive: true }); + + if (defaultStore.state.aiChanMode) { + const iframeRect = live2d.getBoundingClientRect(); + window.addEventListener('mousemove', ev => { + live2d.contentWindow.postMessage({ + type: 'moveCursor', + body: { + x: ev.clientX - iframeRect.left, + y: ev.clientY - iframeRect.top, + }, + }, '*'); + }, { passive: true }); + window.addEventListener('touchmove', ev => { + live2d.contentWindow.postMessage({ + type: 'moveCursor', + body: { + x: ev.touches[0].clientX - iframeRect.left, + y: ev.touches[0].clientY - iframeRect.top, + }, + }, '*'); + }, { passive: true }); } }); </script> @@ -272,7 +241,7 @@ export default defineComponent({ border-left: solid 1px var(--divider); border-right: solid 1px var(--divider); border-radius: 0; - overflow: clip; + overflow: hidden; overflow: clip; --margin: 12px; } diff --git a/packages/client/src/ui/deck.vue b/packages/client/src/ui/deck.vue index e538a93f06..19a99a95aa 100644 --- a/packages/client/src/ui/deck.vue +++ b/packages/client/src/ui/deck.vue @@ -1,37 +1,54 @@ <template> -<div class="mk-deck" :class="[{ isMobile }, `${deckStore.reactiveState.columnAlign.value}`]" :style="{ '--deckMargin': deckStore.reactiveState.columnMargin.value + 'px' }" - @contextmenu.self.prevent="onContextmenu" +<div + class="mk-deck" :class="[{ isMobile }]" > <XSidebar v-if="!isMobile"/> - <template v-for="ids in layout"> - <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> - <section v-if="ids.length > 1" - class="folder column" - :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }" - > - <DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/> - </section> - <DeckColumnCore v-else - :ref="ids[0]" - :key="ids[0]" - class="column" - :column="columns.find(c => c.id === ids[0])" - :is-stacked="false" - :style="columns.find(c => c.id === ids[0])!.flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0])!.width + 'px' }" - @parent-focus="moveFocus(ids[0], $event)" - /> - </template> + <div class="main"> + <XStatusBars class="statusbars"/> + <div ref="columnsEl" class="columns" :class="deckStore.reactiveState.columnAlign.value" @contextmenu.self.prevent="onContextmenu"> + <template v-for="ids in layout"> + <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> + <section + v-if="ids.length > 1" + class="folder column" + :style="columns.filter(c => ids.includes(c.id)).some(c => c.flexible) ? { flex: 1, minWidth: '350px' } : { width: Math.max(...columns.filter(c => ids.includes(c.id)).map(c => c.width)) + 'px' }" + > + <DeckColumnCore v-for="id in ids" :ref="id" :key="id" :column="columns.find(c => c.id === id)" :is-stacked="true" @parent-focus="moveFocus(id, $event)"/> + </section> + <DeckColumnCore + v-else + :ref="ids[0]" + :key="ids[0]" + class="column" + :column="columns.find(c => c.id === ids[0])" + :is-stacked="false" + :style="columns.find(c => c.id === ids[0])!.flexible ? { flex: 1, minWidth: '350px' } : { width: columns.find(c => c.id === ids[0])!.width + 'px' }" + @parent-focus="moveFocus(ids[0], $event)" + /> + </template> + <div v-if="layout.length === 0" class="intro _panel"> + <div>{{ i18n.ts._deck.introduction }}</div> + <MkButton primary class="add" @click="addColumn">{{ i18n.ts._deck.addColumn }}</MkButton> + <div>{{ i18n.ts._deck.introduction2 }}</div> + </div> + <div class="sideMenu"> + <button v-tooltip.left="i18n.ts._deck.addColumn" class="_button button" @click="addColumn"><i class="fas fa-plus"></i></button> + <button v-tooltip.left="i18n.ts.settings" class="_button button settings" @click="showSettings"><i class="fas fa-cog"></i></button> + </div> + </div> + </div> <div v-if="isMobile" class="buttons"> <button class="button nav _button" @click="drawerMenuShowing = true"><i class="fas fa-bars"></i><span v-if="menuIndicated" class="indicator"><i class="fas fa-circle"></i></span></button> - <button class="button home _button" @click="$router.push('/')"><i class="fas fa-home"></i></button> - <button class="button notifications _button" @click="$router.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button> + <button class="button home _button" @click="mainRouter.push('/')"><i class="fas fa-home"></i></button> + <button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button> <button class="button post _button" @click="os.post()"><i class="fas fa-pencil-alt"></i></button> </div> <transition :name="$store.state.animation ? 'menu-back' : ''"> - <div v-if="drawerMenuShowing" + <div + v-if="drawerMenuShowing" class="menu-back _modalBg" @click="drawerMenuShowing = false" @touchstart.passive="drawerMenuShowing = false" @@ -47,19 +64,30 @@ </template> <script lang="ts" setup> -import { computed, provide, ref, watch } from 'vue'; +import { computed, defineAsyncComponent, onMounted, provide, ref, watch } from 'vue'; import { v4 as uuid } from 'uuid'; +import XCommon from './_common_/common.vue'; +import { deckStore, addColumn as addColumnToStore, loadDeck } from './deck/deck-store'; import DeckColumnCore from '@/ui/deck/column-core.vue'; import XSidebar from '@/ui/_common_/sidebar.vue'; import XDrawerMenu from '@/ui/_common_/sidebar-for-mobile.vue'; +import MkButton from '@/components/ui/button.vue'; import { getScrollContainer } from '@/scripts/scroll'; import * as os from '@/os'; import { menuDef } from '@/menu'; -import XCommon from './_common_/common.vue'; -import { deckStore, addColumn as addColumnToStore, loadDeck } from './deck/deck-store'; -import { useRoute } from 'vue-router'; import { $i } from '@/account'; import { i18n } from '@/i18n'; +import { mainRouter } from '@/router'; +const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); + +mainRouter.navHook = (path): boolean => { + const noMainColumn = !deckStore.state.columns.some(x => x.type === 'main'); + if (deckStore.state.navWindow || noMainColumn) { + os.pageWindow(path); + return true; + } + return false; +}; const isMobile = ref(window.innerWidth <= 500); window.addEventListener('resize', () => { @@ -68,7 +96,7 @@ window.addEventListener('resize', () => { const drawerMenuShowing = ref(false); -const route = useRoute(); +const route = 'TODO'; watch(route, () => { drawerMenuShowing.value = false; }); @@ -83,6 +111,12 @@ const menuIndicated = computed(() => { return false; }); +function showSettings() { + os.pageWindow('/settings/deck'); +} + +let columnsEl = $ref<HTMLElement>(); + const addColumn = async (ev) => { const columns = [ 'main', @@ -98,8 +132,8 @@ const addColumn = async (ev) => { const { canceled, result: column } = await os.select({ title: i18n.ts._deck.addColumn, items: columns.map(column => ({ - value: column, text: i18n.t('_deck._columns.' + column) - })) + value: column, text: i18n.t('_deck._columns.' + column), + })), }); if (canceled) return; @@ -119,17 +153,14 @@ const onContextmenu = (ev) => { }; provide('shouldSpacerMin', true); -if (deckStore.state.navWindow) { - provide('navHook', (url) => { - os.pageWindow(url); - }); -} document.documentElement.style.overflowY = 'hidden'; document.documentElement.style.scrollBehavior = 'auto'; window.addEventListener('wheel', (ev) => { - if (getScrollContainer(ev.target as HTMLElement) == null && ev.deltaX === 0) { - document.documentElement.scrollLeft += ev.deltaY; + if (ev.target === columnsEl && ev.deltaX === 0) { + columnsEl.scrollLeft += ev.deltaY; + } else if (getScrollContainer(ev.target as HTMLElement) == null && ev.deltaX === 0) { + columnsEl.scrollLeft += ev.deltaY; } }); loadDeck(); @@ -168,37 +199,82 @@ function moveFocus(id: string, direction: 'up' | 'down' | 'left' | 'right') { // TODO: ここではなくて、各カラムで自身の幅に応じて上書きするようにしたい --margin: var(--marginHalf); + --deckDividerThickness: 5px; + display: flex; // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ height: calc(var(--vh, 1vh) * 100); box-sizing: border-box; flex: 1; - padding: var(--deckMargin); - - &.center { - > .column:first-of-type { - margin-left: auto; - } - - > .column:last-of-type { - margin-right: auto; - } - } &.isMobile { padding-bottom: 100px; } - > .column { - flex-shrink: 0; - margin-right: var(--deckMargin); + > .main { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; - &.folder { + > .columns { + flex: 1; display: flex; - flex-direction: column; + overflow-x: auto; + overflow-y: clip; + + &.center { + > .column:first-of-type { + margin-left: auto; + } + + > .column:last-of-type { + margin-right: auto; + } + } + + > .column { + flex-shrink: 0; + border-right: solid var(--deckDividerThickness) var(--deckDivider); + + &:first-of-type { + border-left: solid var(--deckDividerThickness) var(--deckDivider); + } + + &.folder { + display: flex; + flex-direction: column; - > *:not(:last-child) { - margin-bottom: var(--deckMargin); + > *:not(:last-of-type) { + border-bottom: solid var(--deckDividerThickness) var(--deckDivider); + } + } + } + + > .intro { + padding: 32px; + height: min-content; + text-align: center; + margin: auto; + + > .add { + margin: 1em auto; + } + } + + > .sideMenu { + flex-shrink: 0; + margin-right: 0; + margin-left: auto; + display: flex; + flex-direction: column; + justify-content: center; + width: 32px; + + > .button { + width: 100%; + aspect-ratio: 1; + } } } } diff --git a/packages/client/src/ui/deck/column.vue b/packages/client/src/ui/deck/column.vue index 6db3549fbb..e8e554d72b 100644 --- a/packages/client/src/ui/deck/column.vue +++ b/packages/client/src/ui/deck/column.vue @@ -1,13 +1,14 @@ <template> <!-- sectionを利用しているのは、deck.vue側でcolumnに対してfirst-of-typeを効かせるため --> -<section v-hotkey="keymap" class="dnpfarvg _panel _narrow_" +<section + v-hotkey="keymap" class="dnpfarvg _narrow_" :class="{ paged: isMainColumn, naked, active, isStacked, draghover, dragging, dropready }" - :style="{ '--deckColumnHeaderHeight': deckStore.reactiveState.columnHeaderHeight.value + 'px' }" @dragover.prevent.stop="onDragover" @dragleave="onDragleave" @drop.prevent.stop="onDrop" > - <header :class="{ indicated }" + <header + :class="{ indicated }" draggable="true" @click="goTop" @dragstart="onDragstart" @@ -22,7 +23,7 @@ <slot name="action"></slot> </div> <span class="header"><slot name="header"></slot></span> - <button v-if="func" v-tooltip="func.title" class="menu _button" @click.stop="func.handler"><i :class="func.icon || 'fas fa-cog'"></i></button> + <button v-tooltip="i18n.ts.settings" class="menu _button" @click.stop="showSettingsMenu"><i class="fas fa-cog"></i></button> </header> <div v-show="active" ref="body"> <slot></slot> @@ -39,9 +40,8 @@ export type DeckFunc = { </script> <script lang="ts" setup> import { onBeforeUnmount, onMounted, provide, watch } from 'vue'; +import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn, Column , deckStore } from './deck-store'; import * as os from '@/os'; -import { updateColumn, swapLeftColumn, swapRightColumn, swapUpColumn, swapDownColumn, stackLeftColumn, popRightColumn, removeColumn, swapColumn, Column } from './deck-store'; -import { deckStore } from './deck-store'; import { i18n } from '@/i18n'; provide('shouldHeaderThin', true); @@ -105,7 +105,7 @@ function onOtherDragEnd() { function toggleActive() { if (!props.isStacked) return; updateColumn(props.column.id, { - active: !props.column.active + active: !props.column.active, }); } @@ -118,69 +118,83 @@ function getMenu() { name: { type: 'string', label: i18n.ts.name, - default: props.column.name + default: props.column.name, }, width: { type: 'number', label: i18n.ts.width, - default: props.column.width + default: props.column.width, }, flexible: { type: 'boolean', label: i18n.ts.flexible, - default: props.column.flexible - } + default: props.column.flexible, + }, }); if (canceled) return; updateColumn(props.column.id, result); - } + }, }, null, { icon: 'fas fa-arrow-left', text: i18n.ts._deck.swapLeft, action: () => { swapLeftColumn(props.column.id); - } + }, }, { icon: 'fas fa-arrow-right', text: i18n.ts._deck.swapRight, action: () => { swapRightColumn(props.column.id); - } + }, }, props.isStacked ? { icon: 'fas fa-arrow-up', text: i18n.ts._deck.swapUp, action: () => { swapUpColumn(props.column.id); - } + }, } : undefined, props.isStacked ? { icon: 'fas fa-arrow-down', text: i18n.ts._deck.swapDown, action: () => { swapDownColumn(props.column.id); - } + }, } : undefined, null, { icon: 'fas fa-window-restore', text: i18n.ts._deck.stackLeft, action: () => { stackLeftColumn(props.column.id); - } + }, }, props.isStacked ? { icon: 'fas fa-window-maximize', text: i18n.ts._deck.popRight, action: () => { popRightColumn(props.column.id); - } + }, } : undefined, null, { icon: 'fas fa-trash-alt', text: i18n.ts.remove, danger: true, action: () => { removeColumn(props.column.id); - } + }, }]; + + if (props.func) { + items.unshift(null); + items.unshift({ + icon: props.func.icon, + text: props.func.title, + action: props.func.handler, + }); + } + return items; } +function showSettingsMenu(ev: MouseEvent) { + os.popupMenu(getMenu(), ev.currentTarget ?? ev.target); +} + function onContextmenu(ev: MouseEvent) { os.contextMenu(getMenu(), ev); } @@ -188,7 +202,7 @@ function onContextmenu(ev: MouseEvent) { function goTop() { body.scrollTo({ top: 0, - behavior: 'smooth' + behavior: 'smooth', }); } @@ -239,15 +253,13 @@ function onDrop(ev) { <style lang="scss" scoped> .dnpfarvg { --root-margin: 10px; + --deckColumnHeaderHeight: 42px; height: 100%; overflow: hidden; - contain: content; - box-shadow: 0 0 8px 0 var(--shadow); + contain: strict; &.draghover { - box-shadow: 0 0 0 2px var(--focus); - &:after { content: ""; display: block; @@ -262,7 +274,18 @@ function onDrop(ev) { } &.dragging { - box-shadow: 0 0 0 2px var(--focus); + &:after { + content: ""; + display: block; + position: absolute; + z-index: 1000; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: var(--focus); + opacity: 0.5; + } } &.dropready { diff --git a/packages/client/src/ui/deck/deck-store.ts b/packages/client/src/ui/deck/deck-store.ts index 03d57c3467..3ec53ad556 100644 --- a/packages/client/src/ui/deck/deck-store.ts +++ b/packages/client/src/ui/deck/deck-store.ts @@ -13,7 +13,7 @@ type ColumnWidget = { export type Column = { id: string; - type: string; + type: 'main' | 'widgets' | 'notifications' | 'tl' | 'antenna' | 'list' | 'mentions' | 'direct'; name: string | null; width: number; widgets?: ColumnWidget[]; @@ -54,14 +54,6 @@ export const deckStore = markRaw(new Storage('deck', { where: 'deviceAccount', default: true, }, - columnMargin: { - where: 'deviceAccount', - default: 16, - }, - columnHeaderHeight: { - where: 'deviceAccount', - default: 42, - }, })); export const loadDeck = async () => { diff --git a/packages/client/src/ui/deck/main-column.vue b/packages/client/src/ui/deck/main-column.vue index 3c97cd4867..9a5fd43af7 100644 --- a/packages/client/src/ui/deck/main-column.vue +++ b/packages/client/src/ui/deck/main-column.vue @@ -1,33 +1,24 @@ <template> -<XColumn v-if="deckStore.state.alwaysShowMainColumn || $route.name !== 'index'" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> +<XColumn v-if="deckStore.state.alwaysShowMainColumn || mainRouter.currentRoute.value.name !== 'index'" :column="column" :is-stacked="isStacked" @parent-focus="$event => emit('parent-focus', $event)"> <template #header> - <template v-if="pageInfo"> - <i :class="pageInfo.icon"></i> - {{ pageInfo.title }} + <template v-if="pageMetadata?.value"> + <i :class="pageMetadata?.value.icon"></i> + {{ pageMetadata?.value.title }} </template> </template> - <MkStickyContainer> - <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> - <router-view v-slot="{ Component }"> - <transition> - <keep-alive :include="['MkTimelinePage']"> - <component :is="Component" :ref="changePage" @contextmenu.stop="onContextmenu"/> - </keep-alive> - </transition> - </router-view> - </MkStickyContainer> + <RouterView @contextmenu.stop="onContextmenu"/> </XColumn> </template> <script lang="ts" setup> -import { } from 'vue'; +import { ComputedRef, provide } from 'vue'; import XColumn from './column.vue'; import { deckStore, Column } from '@/ui/deck/deck-store'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { i18n } from '@/i18n'; -import { router } from '@/router'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; defineProps<{ column: Column; @@ -38,14 +29,13 @@ const emit = defineEmits<{ (ev: 'parent-focus', direction: 'up' | 'down' | 'left' | 'right'): void; }>(); -let pageInfo = $ref<Record<string, any> | null>(null); +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); + +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; +}); -function changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - pageInfo = page[symbols.PAGE_INFO]; - } -} /* function back() { history.back(); @@ -63,7 +53,7 @@ function onContextmenu(ev: MouseEvent) { if (isLink(ev.target as HTMLElement)) return; if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes((ev.target as HTMLElement).tagName) || (ev.target as HTMLElement).attributes['contenteditable']) return; if (window.getSelection()?.toString() !== '') return; - const path = router.currentRoute.value.path; + const path = mainRouter.currentRoute.value.path; os.contextMenu([{ type: 'label', text: path, @@ -72,7 +62,7 @@ function onContextmenu(ev: MouseEvent) { text: i18n.ts.openInWindow, action: () => { os.pageWindow(path); - } + }, }], ev); } </script> diff --git a/packages/client/src/ui/deck/widgets-column.vue b/packages/client/src/ui/deck/widgets-column.vue index 9b10f602fb..f181fc328d 100644 --- a/packages/client/src/ui/deck/widgets-column.vue +++ b/packages/client/src/ui/deck/widgets-column.vue @@ -3,6 +3,7 @@ <template #header><i class="fas fa-window-maximize" style="margin-right: 8px;"></i>{{ column.name }}</template> <div class="wtdtxvec"> + <div v-if="!(column.widgets && column.widgets.length > 0) && !edit" class="intro">{{ i18n.ts._deck.widgetsIntroduction }}</div> <XWidgets :edit="edit" :widgets="column.widgets" @add-widget="addWidget" @remove-widget="removeWidget" @update-widget="updateWidget" @update-widgets="updateWidgets" @exit="edit = false"/> </div> </XColumn> @@ -13,6 +14,7 @@ import { } from 'vue'; import XColumn from './column.vue'; import { addColumnWidget, Column, removeColumnWidget, setColumnWidgets, updateColumnWidget } from './deck-store'; import XWidgets from '@/components/widgets.vue'; +import { i18n } from '@/i18n'; const props = defineProps<{ column: Column; @@ -52,5 +54,10 @@ function func() { --panelBorder: none; padding: 0 var(--margin); + + > .intro { + padding: 16px; + text-align: center; + } } </style> diff --git a/packages/client/src/ui/desktop.vue b/packages/client/src/ui/desktop.vue deleted file mode 100644 index 17783c58e3..0000000000 --- a/packages/client/src/ui/desktop.vue +++ /dev/null @@ -1,70 +0,0 @@ -<template> -<div class="mk-app" :class="{ wallpaper }" @contextmenu.prevent="() => {}"> - <XSidebar ref="nav" class="sidebar"/> - - <XCommon/> -</div> -</template> - -<script lang="ts"> -import { defineComponent } from 'vue'; -import { host } from '@/config'; -import { search } from '@/scripts/search'; -import XCommon from './_common_/common.vue'; -import * as os from '@/os'; -import XSidebar from '@/ui/_common_/sidebar.vue'; -import { menuDef } from '@/menu'; -import { ColdDeviceStorage } from '@/store'; - -export default defineComponent({ - components: { - XCommon, - XSidebar - }, - - provide() { - return { - navHook: (url) => { - os.pageWindow(url); - } - }; - }, - - data() { - return { - host: host, - menuDef: menuDef, - wallpaper: localStorage.getItem('wallpaper') != null, - }; - }, - - computed: { - menu(): string[] { - return this.$store.state.menu; - }, - }, - - created() { - if (window.innerWidth < 1024) { - localStorage.setItem('ui', 'default'); - location.reload(); - } - }, - - methods: { - help() { - window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank'); - }, - } -}); -</script> - -<style lang="scss" scoped> -.mk-app { - height: 100vh; - width: 100vw; -} -</style> - -<style lang="scss"> -</style> diff --git a/packages/client/src/ui/universal.vue b/packages/client/src/ui/universal.vue index a5ff7a6255..2edfb3f12d 100644 --- a/packages/client/src/ui/universal.vue +++ b/packages/client/src/ui/universal.vue @@ -2,42 +2,33 @@ <div class="dkgtipfy" :class="{ wallpaper }"> <XSidebar v-if="!isMobile" class="sidebar"/> - <div class="contents" :style="{ background: pageInfo?.bg }" @contextmenu.stop="onContextmenu"> - <main> - <div class="content"> - <MkStickyContainer> - <template #header><MkHeader v-if="pageInfo && !pageInfo.hideHeader" :info="pageInfo"/></template> - <router-view v-slot="{ Component }"> - <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> - <keep-alive :include="['MkTimelinePage']"> - <component :is="Component" :ref="changePage"/> - </keep-alive> - </transition> - </router-view> - </MkStickyContainer> + <MkStickyContainer class="contents"> + <template #header><XStatusBars :class="$style.statusbars"/></template> + <main style="min-width: 0;" :style="{ background: pageMetadata?.value?.bg }" @contextmenu.stop="onContextmenu"> + <div :class="$style.content"> + <RouterView/> </div> - <div class="spacer"></div> + <div :class="$style.spacer"></div> </main> - </div> - - <XSideView v-if="isDesktop" ref="sideEl" class="side"/> + </MkStickyContainer> <div v-if="isDesktop" ref="widgetsEl" class="widgets"> <XWidgets @mounted="attachSticky"/> </div> - <button class="widgetButton _button" :class="{ show: true }" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button> + <button v-if="!isDesktop && !isMobile" class="widgetButton _button" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button> <div v-if="isMobile" class="buttons"> <button class="button nav _button" @click="drawerMenuShowing = true"><i class="fas fa-bars"></i><span v-if="menuIndicated" class="indicator"><i class="fas fa-circle"></i></span></button> - <button class="button home _button" @click="$route.name === 'index' ? top() : $router.push('/')"><i class="fas fa-home"></i></button> - <button class="button notifications _button" @click="$router.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button> + <button class="button home _button" @click="mainRouter.currentRoute.value.name === 'index' ? top() : mainRouter.push('/')"><i class="fas fa-home"></i></button> + <button class="button notifications _button" @click="mainRouter.push('/my/notifications')"><i class="fas fa-bell"></i><span v-if="$i?.hasUnreadNotification" class="indicator"><i class="fas fa-circle"></i></span></button> <button class="button widget _button" @click="widgetsShowing = true"><i class="fas fa-layer-group"></i></button> <button class="button post _button" @click="os.post()"><i class="fas fa-pencil-alt"></i></button> </div> <transition :name="$store.state.animation ? 'menuDrawer-back' : ''"> - <div v-if="drawerMenuShowing" + <div + v-if="drawerMenuShowing" class="menuDrawer-back _modalBg" @click="drawerMenuShowing = false" @touchstart.passive="drawerMenuShowing = false" @@ -49,7 +40,8 @@ </transition> <transition :name="$store.state.animation ? 'widgetsDrawer-back' : ''"> - <div v-if="widgetsShowing" + <div + v-if="widgetsShowing" class="widgetsDrawer-back _modalBg" @click="widgetsShowing = false" @touchstart.passive="widgetsShowing = false" @@ -65,21 +57,22 @@ </template> <script lang="ts" setup> -import { defineAsyncComponent, provide, onMounted, computed, ref, watch } from 'vue'; +import { defineAsyncComponent, provide, onMounted, computed, ref, watch, ComputedRef } from 'vue'; +import XCommon from './_common_/common.vue'; import { instanceName } from '@/config'; import { StickySidebar } from '@/scripts/sticky-sidebar'; import XDrawerMenu from '@/ui/_common_/sidebar-for-mobile.vue'; -import XCommon from './_common_/common.vue'; -import XSideView from './classic.side.vue'; import * as os from '@/os'; -import * as symbols from '@/symbols'; import { defaultStore } from '@/store'; import { menuDef } from '@/menu'; -import { useRoute } from 'vue-router'; import { i18n } from '@/i18n'; import { $i } from '@/account'; +import { Router } from '@/nirax'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; const XWidgets = defineAsyncComponent(() => import('./universal.widgets.vue')); const XSidebar = defineAsyncComponent(() => import('@/ui/_common_/sidebar.vue')); +const XStatusBars = defineAsyncComponent(() => import('@/ui/_common_/statusbars.vue')); const DESKTOP_THRESHOLD = 1100; const MOBILE_THRESHOLD = 500; @@ -90,15 +83,18 @@ window.addEventListener('resize', () => { isMobile.value = window.innerWidth <= MOBILE_THRESHOLD; }); -const pageInfo = ref(); +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); const widgetsEl = $ref<HTMLElement>(); -const widgetsShowing = ref(false); - -let sideEl = $ref<InstanceType<typeof XSideView>>(); +const widgetsShowing = $ref(false); -provide('sideViewHook', isDesktop.value ? (url) => { - sideEl.navigate(url); -} : null); +provide('router', mainRouter); +provideMetadataReceiver((info) => { + console.log(info); + pageMetadata = info; + if (pageMetadata.value) { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } +}); const menuIndicated = computed(() => { for (const def in menuDef) { @@ -110,8 +106,7 @@ const menuIndicated = computed(() => { const drawerMenuShowing = ref(false); -const route = useRoute(); -watch(route, () => { +mainRouter.on('change', () => { drawerMenuShowing.value = false; }); @@ -120,13 +115,13 @@ document.documentElement.style.overflowY = 'scroll'; if (defaultStore.state.widgets.length === 0) { defaultStore.set('widgets', [{ name: 'calendar', - id: 'a', place: 'right', data: {} + id: 'a', place: 'right', data: {}, }, { name: 'notifications', - id: 'b', place: 'right', data: {} + id: 'b', place: 'right', data: {}, }, { name: 'trends', - id: 'c', place: 'right', data: {} + id: 'c', place: 'right', data: {}, }]); } @@ -138,14 +133,6 @@ onMounted(() => { } }); -const changePage = (page) => { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - pageInfo.value = page[symbols.PAGE_INFO]; - document.title = `${pageInfo.value.title} | ${instanceName}`; - } -}; - const onContextmenu = (ev) => { const isLink = (el: HTMLElement) => { if (el.tagName === 'A') return true; @@ -156,22 +143,16 @@ const onContextmenu = (ev) => { if (isLink(ev.target)) return; if (['INPUT', 'TEXTAREA', 'IMG', 'VIDEO', 'CANVAS'].includes(ev.target.tagName) || ev.target.attributes['contenteditable']) return; if (window.getSelection()?.toString() !== '') return; - const path = route.path; + const path = mainRouter.getCurrentPath(); os.contextMenu([{ type: 'label', text: path, }, { - icon: 'fas fa-columns', - text: i18n.ts.openInSideView, - action: () => { - sideEl.navigate(path); - } - }, { icon: 'fas fa-window-maximize', text: i18n.ts.openInWindow, action: () => { os.pageWindow(path); - } + }, }], ev); }; @@ -186,10 +167,6 @@ function top() { window.scroll({ top: 0, behavior: 'smooth' }); } -function onTransition() { - if (window._scroll) window._scroll(); -} - const wallpaper = localStorage.getItem('wallpaper') != null; </script> @@ -259,25 +236,7 @@ const wallpaper = localStorage.getItem('wallpaper') != null; > .contents { width: 100%; min-width: 0; - background: var(--panel); - - > main { - min-width: 0; - - > .spacer { - height: calc(env(safe-area-inset-bottom, 0px) + 96px); - - @media (min-width: ($widgets-hide-threshold + 1px)) { - display: none; - } - } - } - } - - > .side { - min-width: 370px; - max-width: 370px; - border-left: solid 0.5px var(--divider); + background: var(--bg); } > .widgets { @@ -290,7 +249,6 @@ const wallpaper = localStorage.getItem('wallpaper') != null; } } -/* > .widgetButton { display: block; position: fixed; @@ -303,18 +261,6 @@ const wallpaper = localStorage.getItem('wallpaper') != null; box-shadow: 0 3px 5px -1px rgba(0, 0, 0, 0.2), 0 6px 10px 0 rgba(0, 0, 0, 0.14), 0 1px 18px 0 rgba(0, 0, 0, 0.12); font-size: 22px; background: var(--panel); - - &.navHidden { - display: none; - } - - @media (min-width: ($widgets-hide-threshold + 1px)) { - display: none; - } - }*/ - - > .widgetButton { - display: none; } > .widgetsDrawer-back { @@ -427,5 +373,20 @@ const wallpaper = localStorage.getItem('wallpaper') != null; } </style> -<style lang="scss"> +<style lang="scss" module> +.statusbars { + position: sticky; + top: 0; + left: 0; +} + +.spacer { + $widgets-hide-threshold: 1090px; + + height: calc(env(safe-area-inset-bottom, 0px) + 96px); + + @media (min-width: ($widgets-hide-threshold + 1px)) { + display: none; + } +} </style> diff --git a/packages/client/src/ui/visitor/a.vue b/packages/client/src/ui/visitor/a.vue index af77f9e970..2473af549a 100644 --- a/packages/client/src/ui/visitor/a.vue +++ b/packages/client/src/ui/visitor/a.vue @@ -1,9 +1,10 @@ <template> <div class="mk-app"> - <div v-if="$route.path === '/'" class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> + <div v-if="mainRouter.currentRoute?.name === 'index'" class="banner" :style="{ backgroundImage: `url(${ $instance.bannerUrl })` }"> <div> <h1 v-if="meta"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></h1> <div v-if="meta" class="about"> + <!-- eslint-disable-next-line vue/no-v-html --> <div class="desc" v-html="meta.description || $ts.introMisskey"></div> </div> <div class="action"> @@ -20,15 +21,11 @@ <div class="main"> <div ref="contents" class="contents" :class="{ wallpaper }"> - <header v-show="$route.path !== '/'" ref="header" class="header"> + <header v-show="mainRouter.currentRoute?.name !== 'index'" ref="header" class="header"> <XHeader :info="pageInfo"/> </header> <main ref="main"> - <router-view v-slot="{ Component }"> - <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> - <component :is="Component" :ref="changePage"/> - </transition> - </router-view> + <RouterView/> </main> <div class="powered-by"> <b><MkA to="/">{{ host }}</MkA></b> @@ -41,14 +38,14 @@ <script lang="ts"> import { defineComponent, defineAsyncComponent } from 'vue'; +import XHeader from './header.vue'; import { host, instanceName } from '@/config'; import { search } from '@/scripts/search'; import * as os from '@/os'; import MkPagination from '@/components/ui/pagination.vue'; import MkButton from '@/components/ui/button.vue'; -import XHeader from './header.vue'; import { ColdDeviceStorage } from '@/store'; -import * as symbols from '@/symbols'; +import { mainRouter } from '@/router'; const DESKTOP_THRESHOLD = 1100; @@ -70,6 +67,7 @@ export default defineComponent({ endpoint: 'announcements', limit: 10, }, + mainRouter, isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, }; }, @@ -82,7 +80,7 @@ export default defineComponent({ this.$store.set('darkMode', !this.$store.state.darkMode); }, 's': search, - 'h|/': this.help + 'h|/': this.help, }; }, }, @@ -104,13 +102,18 @@ export default defineComponent({ }, methods: { - setParallax(el) { - //new simpleParallax(el); - }, + // @ThatOneCalculator: Are these methods even used? + // I can't find references to them anywhere else in the code... + + // setParallax(el) { + // new simpleParallax(el); + // }, changePage(page) { if (page == null) return; + // eslint-disable-next-line no-undef if (page[symbols.PAGE_INFO]) { + // eslint-disable-next-line no-undef this.pageInfo = page[symbols.PAGE_INFO]; } }, @@ -120,13 +123,9 @@ export default defineComponent({ }, help() { - window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank'); + window.open('https://misskey-hub.net/docs/keyboard-shortcut.md', '_blank'); }, - - onTransition() { - if (window._scroll) window._scroll(); - }, - } + }, }); </script> diff --git a/packages/client/src/ui/visitor/b.vue b/packages/client/src/ui/visitor/b.vue index c9c0a1f72e..09be12d689 100644 --- a/packages/client/src/ui/visitor/b.vue +++ b/packages/client/src/ui/visitor/b.vue @@ -12,11 +12,7 @@ <div class="contents"> <XHeader v-if="!root" class="header" :info="pageInfo"/> <main> - <router-view v-slot="{ Component }"> - <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> - <component :is="Component" :ref="changePage"/> - </transition> - </router-view> + <RouterView/> </main> <div v-if="!root" class="powered-by"> <b><MkA to="/">{{ host }}</MkA></b> @@ -26,7 +22,8 @@ </div> <transition :name="$store.state.animation ? 'tray-back' : ''"> - <div v-if="showMenu" + <div + v-if="showMenu" class="menu-back _modalBg" @click="showMenu = false" @touchstart.passive="showMenu = false" @@ -48,8 +45,10 @@ </div> </template> -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; +<script lang="ts" setup> +import { ComputedRef, onMounted, provide } from 'vue'; +import XHeader from './header.vue'; +import XKanban from './kanban.vue'; import { host, instanceName } from '@/config'; import { search } from '@/scripts/search'; import * as os from '@/os'; @@ -57,103 +56,70 @@ import MkPagination from '@/components/ui/pagination.vue'; import XSigninDialog from '@/components/signin-dialog.vue'; import XSignupDialog from '@/components/signup-dialog.vue'; import MkButton from '@/components/ui/button.vue'; -import XHeader from './header.vue'; -import XKanban from './kanban.vue'; -import { ColdDeviceStorage } from '@/store'; -import * as symbols from '@/symbols'; +import { ColdDeviceStorage, defaultStore } from '@/store'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; const DESKTOP_THRESHOLD = 1100; -export default defineComponent({ - components: { - XHeader, - XKanban, - MkPagination, - MkButton, - }, +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); - data() { - return { - host, - instanceName, - pageInfo: null, - meta: null, - showMenu: false, - narrow: window.innerWidth < 1280, - announcements: { - endpoint: 'announcements', - limit: 10, - }, - isDesktop: window.innerWidth >= DESKTOP_THRESHOLD, - }; - }, +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; + if (pageMetadata.value) { + document.title = `${pageMetadata.value.title} | ${instanceName}`; + } +}); - computed: { - keymap(): any { - return { - 'd': () => { - if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; - this.$store.set('darkMode', !this.$store.state.darkMode); - }, - 's': search, - 'h|/': this.help - }; - }, +const announcements = { + endpoint: 'announcements', + limit: 10, +}; +let showMenu = $ref(false); +let isDesktop = $ref(window.innerWidth >= DESKTOP_THRESHOLD); +let narrow = $ref(window.innerWidth < 1280); +let meta = $ref(); - root(): boolean { - return this.$route.path === '/'; - }, - }, - - created() { - //document.documentElement.style.overflowY = 'scroll'; - - os.api('meta', { detail: true }).then(meta => { - this.meta = meta; - }); - }, - - mounted() { - if (!this.isDesktop) { - window.addEventListener('resize', () => { - if (window.innerWidth >= DESKTOP_THRESHOLD) this.isDesktop = true; - }, { passive: true }); - } - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - } +const keymap = $computed(() => { + return { + 'd': () => { + if (ColdDeviceStorage.get('syncDeviceDarkMode')) return; + defaultStore.set('darkMode', !defaultStore.state.darkMode); }, + 's': search, + }; +}); - top() { - window.scroll({ top: 0, behavior: 'smooth' }); - }, +const root = $computed(() => mainRouter.currentRoute.value.name === 'index'); - help() { - window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank'); - }, +os.api('meta', { detail: true }).then(res => { + meta = res; +}); - onTransition() { - if (window._scroll) window._scroll(); - }, +function signin() { + os.popup(XSigninDialog, { + autoSet: true, + }, {}, 'closed'); +} - signin() { - os.popup(XSigninDialog, { - autoSet: true - }, {}, 'closed'); - }, +function signup() { + os.popup(XSignupDialog, { + autoSet: true, + }, {}, 'closed'); +} - signup() { - os.popup(XSignupDialog, { - autoSet: true - }, {}, 'closed'); - } +onMounted(() => { + if (!isDesktop) { + window.addEventListener('resize', () => { + if (window.innerWidth >= DESKTOP_THRESHOLD) isDesktop = true; + }, { passive: true }); } }); + +defineExpose({ + showMenu: $$(showMenu), +}); </script> <style> diff --git a/packages/client/src/ui/visitor/kanban.vue b/packages/client/src/ui/visitor/kanban.vue index ee0f11b838..44b5557259 100644 --- a/packages/client/src/ui/visitor/kanban.vue +++ b/packages/client/src/ui/visitor/kanban.vue @@ -1,10 +1,11 @@ +<!-- eslint-disable vue/no-v-html --> <template> <div class="rwqkcmrc" :style="{ backgroundImage: transparent ? 'none' : `url(${ $instance.backgroundImageUrl })` }"> <div class="back" :class="{ transparent }"></div> <div class="contents"> <div class="wrapper"> <h1 v-if="meta" :class="{ full }"> - <MkA to="/" class="link"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl"><span v-else class="text">{{ instanceName }}</span></MkA> + <MkA to="/" class="link"><img v-if="meta.logoImageUrl" class="logo" :src="meta.logoImageUrl" alt="logo"><span v-else class="text">{{ instanceName }}</span></MkA> </h1> <template v-if="full"> <div v-if="meta" class="about"> @@ -21,7 +22,7 @@ <div class="title">{{ announcement.title }}</div> <div class="content"> <Mfm :text="announcement.text"/> - <img v-if="announcement.imageUrl" :src="announcement.imageUrl"/> + <img v-if="announcement.imageUrl" :src="announcement.imageUrl" alt="announcement image"/> </div> </section> </MkPagination> diff --git a/packages/client/src/ui/zen.vue b/packages/client/src/ui/zen.vue index a7234f729b..c915f82428 100644 --- a/packages/client/src/ui/zen.vue +++ b/packages/client/src/ui/zen.vue @@ -1,106 +1,35 @@ <template> <div class="mk-app"> - <div class="contents"> - <header class="header"> - <MkHeader :info="pageInfo"/> - </header> - <main ref="main"> - <div class="content"> - <router-view v-slot="{ Component }"> - <transition :name="$store.state.animation ? 'page' : ''" mode="out-in" @enter="onTransition"> - <keep-alive :include="['MkTimelinePage']"> - <component :is="Component" :ref="changePage"/> - </keep-alive> - </transition> - </router-view> - </div> - </main> - </div> + <RouterView/> <XCommon/> </div> </template> -<script lang="ts"> -import { defineComponent, defineAsyncComponent } from 'vue'; -import { host } from '@/config'; +<script lang="ts" setup> +import { provide, ComputedRef } from 'vue'; import XCommon from './_common_/common.vue'; -import * as symbols from '@/symbols'; +import { mainRouter } from '@/router'; +import { PageMetadata, provideMetadataReceiver, setPageMetadata } from '@/scripts/page-metadata'; +import { instanceName } from '@/config'; -export default defineComponent({ - components: { - XCommon, - }, +let pageMetadata = $ref<null | ComputedRef<PageMetadata>>(); - data() { - return { - host: host, - pageInfo: null, - }; - }, - - created() { - document.documentElement.style.overflowY = 'scroll'; - }, - - methods: { - changePage(page) { - if (page == null) return; - if (page[symbols.PAGE_INFO]) { - this.pageInfo = page[symbols.PAGE_INFO]; - } - }, - - top() { - window.scroll({ top: 0, behavior: 'smooth' }); - }, - - help() { - window.open(`https://misskey-hub.net/docs/keyboard-shortcut.md`, '_blank'); - }, - - onTransition() { - if (window._scroll) window._scroll(); - }, +provide('router', mainRouter); +provideMetadataReceiver((info) => { + pageMetadata = info; + if (pageMetadata.value) { + document.title = `${pageMetadata.value.title} | ${instanceName}`; } }); + +document.documentElement.style.overflowY = 'scroll'; </script> <style lang="scss" scoped> .mk-app { - $header-height: 52px; - $ui-font-size: 1em; // TODO: どこかに集約したい - // ほんとは単に 100vh と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ min-height: calc(var(--vh, 1vh) * 100); box-sizing: border-box; - - > .contents { - padding-top: $header-height; - - > .header { - position: fixed; - z-index: 1000; - top: 0; - height: $header-height; - width: 100%; - line-height: $header-height; - text-align: center; - //background-color: var(--panel); - -webkit-backdrop-filter: var(--blur, blur(32px)); - backdrop-filter: var(--blur, blur(32px)); - background-color: var(--header); - border-bottom: solid 0.5px var(--divider); - } - - > main { - > .content { - > * { - // ほんとは単に calc(100vh - #{$header-height}) と書きたいところだが... https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - min-height: calc((var(--vh, 1vh) * 100) - #{$header-height}); - } - } - } - } } </style> diff --git a/packages/client/src/widgets/activity.vue b/packages/client/src/widgets/activity.vue index 7fb9f5894c..7252d65403 100644 --- a/packages/client/src/widgets/activity.vue +++ b/packages/client/src/widgets/activity.vue @@ -1,6 +1,6 @@ <template> <MkContainer :show-header="widgetProps.showHeader" :naked="widgetProps.transparent" class="mkw-activity"> - <template #header><i class="fas fa-chart-bar"></i>{{ $ts._widgets.activity }}</template> + <template #header><i class="fas fa-chart-simple"></i>{{ $ts._widgets.activity }}</template> <template #func><button class="_button" @click="toggleView()"><i class="fas fa-sort"></i></button></template> <div> @@ -15,12 +15,12 @@ <script lang="ts" setup> import { onMounted, onUnmounted, reactive, ref, watch } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; -import * as os from '@/os'; -import MkContainer from '@/components/ui/container.vue'; import XCalendar from './activity.calendar.vue'; import XChart from './activity.chart.vue'; +import { GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import MkContainer from '@/components/ui/container.vue'; import { $i } from '@/account'; const name = 'activity'; @@ -67,7 +67,7 @@ const toggleView = () => { save(); }; -os.api('charts/user/notes', { +os.apiGet('charts/user/notes', { userId: $i.id, span: 'day', limit: 7 * 21, @@ -76,7 +76,7 @@ os.api('charts/user/notes', { total: res.diffs.normal[i] + res.diffs.reply[i] + res.diffs.renote[i], notes: res.diffs.normal[i], replies: res.diffs.reply[i], - renotes: res.diffs.renote[i] + renotes: res.diffs.renote[i], })); fetching.value = false; }); diff --git a/packages/client/src/widgets/aichan.vue b/packages/client/src/widgets/aichan.vue index cdd367cc84..828490fd9c 100644 --- a/packages/client/src/widgets/aichan.vue +++ b/packages/client/src/widgets/aichan.vue @@ -6,8 +6,8 @@ <script lang="ts" setup> import { onMounted, onUnmounted, reactive, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; const name = 'ai'; @@ -38,22 +38,23 @@ const touched = () => { //if (this.live2d) this.live2d.changeExpression('gurugurume'); }; -onMounted(() => { - const onMousemove = (ev: MouseEvent) => { - const iframeRect = live2d.value.getBoundingClientRect(); - live2d.value.contentWindow.postMessage({ - type: 'moveCursor', - body: { - x: ev.clientX - iframeRect.left, - y: ev.clientY - iframeRect.top, - } - }, '*'); - }; +const onMousemove = (ev: MouseEvent) => { + const iframeRect = live2d.value.getBoundingClientRect(); + live2d.value.contentWindow.postMessage({ + type: 'moveCursor', + body: { + x: ev.clientX - iframeRect.left, + y: ev.clientY - iframeRect.top, + }, + }, '*'); +}; +onMounted(() => { window.addEventListener('mousemove', onMousemove, { passive: true }); - onUnmounted(() => { - window.removeEventListener('mousemove', onMousemove); - }); +}); + +onUnmounted(() => { + window.removeEventListener('mousemove', onMousemove); }); defineExpose<WidgetComponentExpose>({ diff --git a/packages/client/src/widgets/calendar.vue b/packages/client/src/widgets/calendar.vue index 2a2b035541..3a0dc8970c 100644 --- a/packages/client/src/widgets/calendar.vue +++ b/packages/client/src/widgets/calendar.vue @@ -34,9 +34,10 @@ <script lang="ts" setup> import { onUnmounted, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import { i18n } from '@/i18n'; +import { useInterval } from '@/scripts/use-interval'; const name = 'calendar'; @@ -85,28 +86,26 @@ const tick = () => { i18n.ts._weekday.wednesday, i18n.ts._weekday.thursday, i18n.ts._weekday.friday, - i18n.ts._weekday.saturday + i18n.ts._weekday.saturday, ][now.getDay()]; - const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime(); - const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/; + const dayNumer = now.getTime() - new Date(ny, nm, nd).getTime(); + const dayDenom = 1000/*ms*/ * 60/*s*/ * 60/*m*/ * 24/*h*/; const monthNumer = now.getTime() - new Date(ny, nm, 1).getTime(); const monthDenom = new Date(ny, nm + 1, 1).getTime() - new Date(ny, nm, 1).getTime(); - const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime(); - const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime(); + const yearNumer = now.getTime() - new Date(ny, 0, 1).getTime(); + const yearDenom = new Date(ny + 1, 0, 1).getTime() - new Date(ny, 0, 1).getTime(); - dayP.value = dayNumer / dayDenom * 100; + dayP.value = dayNumer / dayDenom * 100; monthP.value = monthNumer / monthDenom * 100; - yearP.value = yearNumer / yearDenom * 100; + yearP.value = yearNumer / yearDenom * 100; isHoliday.value = now.getDay() === 0 || now.getDay() === 6; }; -tick(); - -const intervalId = window.setInterval(tick, 1000); -onUnmounted(() => { - window.clearInterval(intervalId); +useInterval(tick, 1000, { + immediate: true, + afterMounted: false, }); defineExpose<WidgetComponentExpose>({ diff --git a/packages/client/src/widgets/federation.vue b/packages/client/src/widgets/federation.vue index a3862077bb..ac87cdac2e 100644 --- a/packages/client/src/widgets/federation.vue +++ b/packages/client/src/widgets/federation.vue @@ -20,11 +20,12 @@ <script lang="ts" setup> import { onMounted, onUnmounted, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import MkContainer from '@/components/ui/container.vue'; import MkMiniChart from '@/components/mini-chart.vue'; import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; const name = 'federation'; @@ -56,20 +57,17 @@ const fetching = ref(true); const fetch = async () => { const fetchedInstances = await os.api('federation/instances', { sort: '+lastCommunicatedAt', - limit: 5 + limit: 5, }); - const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.api('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); + const fetchedCharts = await Promise.all(fetchedInstances.map(i => os.apiGet('charts/instance', { host: i.host, limit: 16, span: 'hour' }))); instances.value = fetchedInstances; charts.value = fetchedCharts; fetching.value = false; }; -onMounted(() => { - fetch(); - const intervalId = window.setInterval(fetch, 1000 * 60); - onUnmounted(() => { - window.clearInterval(intervalId); - }); +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, }); defineExpose<WidgetComponentExpose>({ diff --git a/packages/client/src/widgets/index.ts b/packages/client/src/widgets/index.ts index 51a82af080..baf6acd23d 100644 --- a/packages/client/src/widgets/index.ts +++ b/packages/client/src/widgets/index.ts @@ -6,6 +6,7 @@ export default function(app: App) { app.component('MkwTimeline', defineAsyncComponent(() => import('./timeline.vue'))); app.component('MkwCalendar', defineAsyncComponent(() => import('./calendar.vue'))); app.component('MkwRss', defineAsyncComponent(() => import('./rss.vue'))); + app.component('MkwRssTicker', defineAsyncComponent(() => import('./rss-ticker.vue'))); app.component('MkwTrends', defineAsyncComponent(() => import('./trends.vue'))); app.component('MkwClock', defineAsyncComponent(() => import('./clock.vue'))); app.component('MkwActivity', defineAsyncComponent(() => import('./activity.vue'))); @@ -17,6 +18,7 @@ export default function(app: App) { app.component('MkwServerMetric', defineAsyncComponent(() => import('./server-metric/index.vue'))); app.component('MkwOnlineUsers', defineAsyncComponent(() => import('./online-users.vue'))); app.component('MkwJobQueue', defineAsyncComponent(() => import('./job-queue.vue'))); + app.component('MkwInstanceCloud', defineAsyncComponent(() => import('./instance-cloud.vue'))); app.component('MkwButton', defineAsyncComponent(() => import('./button.vue'))); app.component('MkwAiscript', defineAsyncComponent(() => import('./aiscript.vue'))); app.component('MkwAichan', defineAsyncComponent(() => import('./aichan.vue'))); @@ -28,12 +30,14 @@ export const widgets = [ 'timeline', 'calendar', 'rss', + 'rssTicker', 'trends', 'clock', 'activity', 'photos', 'digitalClock', 'federation', + 'instanceCloud', 'postForm', 'slideshow', 'serverMetric', diff --git a/packages/client/src/widgets/instance-cloud.vue b/packages/client/src/widgets/instance-cloud.vue new file mode 100644 index 0000000000..597ce0e824 --- /dev/null +++ b/packages/client/src/widgets/instance-cloud.vue @@ -0,0 +1,76 @@ +<template> +<MkContainer :naked="widgetProps.transparent" :show-header="false" class="mkw-instance-cloud"> + <div class=""> + <MkTagCloud v-if="activeInstances"> + <li v-for="instance in activeInstances" :key="instance.id"> + <a @click.prevent="onInstanceClick(instance)"> + <img style="width: 32px;" :src="instance.iconUrl"> + </a> + </li> + </MkTagCloud> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; +import MkContainer from '@/components/ui/container.vue'; +import MkTagCloud from '@/components/tag-cloud.vue'; +import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; + +const name = 'instanceCloud'; + +const widgetPropsDef = { + transparent: { + type: 'boolean' as const, + default: false, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +let cloud = $ref<InstanceType<typeof MkTagCloud> | null>(); +let activeInstances = $shallowRef(null); + +function onInstanceClick(i) { + os.pageWindow(`/instance-info/${i.host}`); +} + +useInterval(() => { + os.api('federation/instances', { + sort: '+lastCommunicatedAt', + limit: 25, + }).then(res => { + activeInstances = res; + if (cloud) cloud.update(); + }); +}, 1000 * 60 * 3, { + immediate: true, + afterMounted: true, +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> + +</style> diff --git a/packages/client/src/widgets/online-users.vue b/packages/client/src/widgets/online-users.vue index eb3184fe9d..4122a82657 100644 --- a/packages/client/src/widgets/online-users.vue +++ b/packages/client/src/widgets/online-users.vue @@ -8,9 +8,10 @@ <script lang="ts" setup> import { onMounted, onUnmounted, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; const name = 'onlineUsers'; @@ -43,12 +44,9 @@ const tick = () => { }); }; -onMounted(() => { - tick(); - const intervalId = window.setInterval(tick, 1000 * 15); - onUnmounted(() => { - window.clearInterval(intervalId); - }); +useInterval(tick, 1000 * 15, { + immediate: true, + afterMounted: true, }); defineExpose<WidgetComponentExpose>({ diff --git a/packages/client/src/widgets/rss-ticker.vue b/packages/client/src/widgets/rss-ticker.vue new file mode 100644 index 0000000000..06995bc865 --- /dev/null +++ b/packages/client/src/widgets/rss-ticker.vue @@ -0,0 +1,144 @@ +<template> +<MkContainer :naked="widgetProps.transparent" :show-header="widgetProps.showHeader" class="mkw-rss-ticker"> + <template #header><i class="fas fa-rss-square"></i>RSS</template> + <template #func><button class="_button" @click="configure"><i class="fas fa-cog"></i></button></template> + + <div class="ekmkgxbk"> + <MkLoading v-if="fetching"/> + <div v-else class="feed"> + <transition name="change" mode="default"> + <MarqueeText :key="key" :duration="widgetProps.duration" :reverse="widgetProps.reverse"> + <span v-for="item in items" class="item"> + <a class="link" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a><span class="divider"></span> + </span> + </MarqueeText> + </transition> + </div> + </div> +</MkContainer> +</template> + +<script lang="ts" setup> +import { onMounted, onUnmounted, ref, watch } from 'vue'; +import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import MarqueeText from '@/components/marquee.vue'; +import { GetFormResultType } from '@/scripts/form'; +import * as os from '@/os'; +import MkContainer from '@/components/ui/container.vue'; +import { useInterval } from '@/scripts/use-interval'; + +const name = 'rssTicker'; + +const widgetPropsDef = { + url: { + type: 'string' as const, + default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews', + }, + refreshIntervalSec: { + type: 'number' as const, + default: 60, + }, + duration: { + type: 'range' as const, + default: 70, + step: 1, + min: 5, + max: 200, + }, + reverse: { + type: 'boolean' as const, + default: false, + }, + showHeader: { + type: 'boolean' as const, + default: false, + }, + transparent: { + type: 'boolean' as const, + default: false, + }, +}; + +type WidgetProps = GetFormResultType<typeof widgetPropsDef>; + +// 現時点ではvueの制限によりimportしたtypeをジェネリックに渡せない +//const props = defineProps<WidgetComponentProps<WidgetProps>>(); +//const emit = defineEmits<WidgetComponentEmits<WidgetProps>>(); +const props = defineProps<{ widget?: Widget<WidgetProps>; }>(); +const emit = defineEmits<{ (ev: 'updateProps', props: WidgetProps); }>(); + +const { widgetProps, configure } = useWidgetPropsManager(name, + widgetPropsDef, + props, + emit, +); + +const items = ref([]); +const fetching = ref(true); +let key = $ref(0); + +const tick = () => { + fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => { + res.json().then(feed => { + items.value = feed.items; + fetching.value = false; + key++; + }); + }); +}; + +watch(() => widgetProps.url, tick); + +useInterval(tick, Math.max(10000, widgetProps.refreshIntervalSec * 1000), { + immediate: true, + afterMounted: true, +}); + +defineExpose<WidgetComponentExpose>({ + name, + configure, + id: props.widget ? props.widget.id : null, +}); +</script> + +<style lang="scss" scoped> +.change-enter-active, .change-leave-active { + position: absolute; + top: 0; + transition: all 1s ease; +} +.change-enter-from { + opacity: 0; + transform: translateY(-100%); +} +.change-leave-to { + opacity: 0; + transform: translateY(100%); +} + +.ekmkgxbk { + > .feed { + --height: 42px; + padding: 0; + font-size: 0.9em; + line-height: var(--height); + height: var(--height); + contain: strict; + + ::v-deep(.item) { + display: inline-flex; + align-items: center; + vertical-align: bottom; + color: var(--fg); + + > .divider { + display: inline-block; + width: 0.5px; + height: 16px; + margin: 0 1em; + background: var(--divider); + } + } + } +} +</style> diff --git a/packages/client/src/widgets/rss.vue b/packages/client/src/widgets/rss.vue index fc65f11813..72f6249820 100644 --- a/packages/client/src/widgets/rss.vue +++ b/packages/client/src/widgets/rss.vue @@ -6,7 +6,7 @@ <div class="ekmkgxbj"> <MkLoading v-if="fetching"/> <div v-else class="feed"> - <a v-for="item in items" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> + <a v-for="item in items" class="item" :href="item.link" rel="nofollow noopener" target="_blank" :title="item.title">{{ item.title }}</a> </div> </div> </MkContainer> @@ -14,22 +14,23 @@ <script lang="ts" setup> import { onMounted, onUnmounted, ref, watch } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; import MkContainer from '@/components/ui/container.vue'; +import { useInterval } from '@/scripts/use-interval'; const name = 'rss'; const widgetPropsDef = { - showHeader: { - type: 'boolean' as const, - default: true, - }, url: { type: 'string' as const, default: 'http://feeds.afpbb.com/rss/afpbb/afpbbnews', }, + showHeader: { + type: 'boolean' as const, + default: true, + }, }; type WidgetProps = GetFormResultType<typeof widgetPropsDef>; @@ -50,7 +51,7 @@ const items = ref([]); const fetching = ref(true); const tick = () => { - fetch(`https://api.rss2json.com/v1/api.json?rss_url=${widgetProps.url}`, {}).then(res => { + fetch(`/api/fetch-rss?url=${widgetProps.url}`, {}).then(res => { res.json().then(feed => { items.value = feed.items; fetching.value = false; @@ -60,12 +61,9 @@ const tick = () => { watch(() => widgetProps.url, tick); -onMounted(() => { - tick(); - const intervalId = window.setInterval(tick, 60000); - onUnmounted(() => { - window.clearInterval(intervalId); - }); +useInterval(tick, 60000, { + immediate: true, + afterMounted: true, }); defineExpose<WidgetComponentExpose>({ @@ -81,7 +79,7 @@ defineExpose<WidgetComponentExpose>({ padding: 0; font-size: 0.9em; - > a { + > .item { display: block; padding: 8px 16px; color: var(--fg); diff --git a/packages/client/src/widgets/slideshow.vue b/packages/client/src/widgets/slideshow.vue index fd78edbe40..c286312161 100644 --- a/packages/client/src/widgets/slideshow.vue +++ b/packages/client/src/widgets/slideshow.vue @@ -13,9 +13,10 @@ <script lang="ts" setup> import { nextTick, onMounted, onUnmounted, reactive, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; const name = 'slideshow'; @@ -75,7 +76,7 @@ const fetch = () => { os.api('drive/files', { folderId: widgetProps.folderId, type: 'image/*', - limit: 100 + limit: 100, }).then(res => { images.value = res; fetching.value = false; @@ -96,15 +97,15 @@ const choose = () => { }); }; +useInterval(change, 10000, { + immediate: false, + afterMounted: true, +}); + onMounted(() => { if (widgetProps.folderId != null) { fetch(); } - - const intervalId = window.setInterval(change, 10000); - onUnmounted(() => { - window.clearInterval(intervalId); - }); }); defineExpose<WidgetComponentExpose>({ diff --git a/packages/client/src/widgets/trends.vue b/packages/client/src/widgets/trends.vue index 9680f1c892..0f34ea6341 100644 --- a/packages/client/src/widgets/trends.vue +++ b/packages/client/src/widgets/trends.vue @@ -19,11 +19,12 @@ <script lang="ts" setup> import { onMounted, onUnmounted, ref } from 'vue'; -import { GetFormResultType } from '@/scripts/form'; import { useWidgetPropsManager, Widget, WidgetComponentEmits, WidgetComponentExpose, WidgetComponentProps } from './widget'; +import { GetFormResultType } from '@/scripts/form'; import MkContainer from '@/components/ui/container.vue'; import MkMiniChart from '@/components/mini-chart.vue'; import * as os from '@/os'; +import { useInterval } from '@/scripts/use-interval'; const name = 'hashtags'; @@ -58,12 +59,9 @@ const fetch = () => { }); }; -onMounted(() => { - fetch(); - const intervalId = window.setInterval(fetch, 1000 * 60); - onUnmounted(() => { - window.clearInterval(intervalId); - }); +useInterval(fetch, 1000 * 60, { + immediate: true, + afterMounted: true, }); defineExpose<WidgetComponentExpose>({ diff --git a/packages/client/src/widgets/widget.ts b/packages/client/src/widgets/widget.ts index 9626d01619..9fdfe7f3e1 100644 --- a/packages/client/src/widgets/widget.ts +++ b/packages/client/src/widgets/widget.ts @@ -36,8 +36,9 @@ export const useWidgetPropsManager = <F extends Form & Record<string, { default: const mergeProps = () => { for (const prop of Object.keys(propsDef)) { - if (widgetProps.hasOwnProperty(prop)) continue; - widgetProps[prop] = propsDef[prop].default; + if (typeof widgetProps[prop] === 'undefined') { + widgetProps[prop] = propsDef[prop].default; + } } }; watch(widgetProps, () => { diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json index f7320a7251..86109f600a 100644 --- a/packages/client/tsconfig.json +++ b/packages/client/tsconfig.json @@ -35,7 +35,8 @@ "lib": [ "esnext", "dom" - ] + ], + "jsx": "preserve" }, "compileOnSave": false, "include": [ diff --git a/packages/client/vite.config.ts b/packages/client/vite.config.ts index af13e646c6..5800cf5021 100644 --- a/packages/client/vite.config.ts +++ b/packages/client/vite.config.ts @@ -1,10 +1,10 @@ import * as fs from 'fs'; import pluginVue from '@vitejs/plugin-vue'; -import pluginJson5 from './vite.json5'; import { defineConfig } from 'vite'; import locales from '../../locales'; import meta from '../../package.json'; +import pluginJson5 from './vite.json5'; const extensions = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.json', '.json5', '.svg', '.sass', '.scss', '.css', '.vue']; @@ -57,7 +57,7 @@ export default defineConfig(({ command, mode }) => { }, output: { manualChunks: { - vue: ['vue', 'vue-router'], + vue: ['vue'], }, }, }, @@ -68,5 +68,5 @@ export default defineConfig(({ command, mode }) => { sourcemap: process.env.NODE_ENV !== 'production', reportCompressedSize: false, }, - } + }; }); diff --git a/packages/client/yarn.lock b/packages/client/yarn.lock index 796c72304a..e5459b8dda 100644 --- a/packages/client/yarn.lock +++ b/packages/client/yarn.lock @@ -515,14 +515,14 @@ dependencies: "@types/node" "*" -"@typescript-eslint/eslint-plugin@5.27.1": - version "5.27.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.27.1.tgz#fdf59c905354139046b41b3ed95d1609913d0758" - integrity sha512-6dM5NKT57ZduNnJfpY81Phe9nc9wolnMCnknb1im6brWi1RYv84nbMS3olJa27B6+irUVV1X/Wb+Am0FjJdGFw== +"@typescript-eslint/eslint-plugin@5.30.0": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.30.0.tgz#524a11e15c09701733033c96943ecf33f55d9ca1" + integrity sha512-lvhRJ2pGe2V9MEU46ELTdiHgiAFZPKtLhiU5wlnaYpMc2+c1R8fh8i80ZAa665drvjHKUJyRRGg3gEm1If54ow== dependencies: - "@typescript-eslint/scope-manager" "5.27.1" - "@typescript-eslint/type-utils" "5.27.1" - "@typescript-eslint/utils" "5.27.1" + "@typescript-eslint/scope-manager" "5.30.0" + "@typescript-eslint/type-utils" "5.30.0" + "@typescript-eslint/utils" "5.30.0" debug "^4.3.4" functional-red-black-tree "^1.0.1" ignore "^5.2.0" @@ -530,69 +530,69 @@ semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/parser@5.27.1": - version "5.27.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.27.1.tgz#3a4dcaa67e45e0427b6ca7bb7165122c8b569639" - integrity sha512-7Va2ZOkHi5NP+AZwb5ReLgNF6nWLGTeUJfxdkVUAPPSaAdbWNnFZzLZ4EGGmmiCTg+AwlbE1KyUYTBglosSLHQ== +"@typescript-eslint/parser@5.30.0": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-5.30.0.tgz#a2184fb5f8ef2bf1db0ae61a43907e2e32aa1b8f" + integrity sha512-2oYYUws5o2liX6SrFQ5RB88+PuRymaM2EU02/9Ppoyu70vllPnHVO7ioxDdq/ypXHA277R04SVjxvwI8HmZpzA== dependencies: - "@typescript-eslint/scope-manager" "5.27.1" - "@typescript-eslint/types" "5.27.1" - "@typescript-eslint/typescript-estree" "5.27.1" + "@typescript-eslint/scope-manager" "5.30.0" + "@typescript-eslint/types" "5.30.0" + "@typescript-eslint/typescript-estree" "5.30.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@5.27.1": - version "5.27.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.27.1.tgz#4d1504392d01fe5f76f4a5825991ec78b7b7894d" - integrity sha512-fQEOSa/QroWE6fAEg+bJxtRZJTH8NTskggybogHt4H9Da8zd4cJji76gA5SBlR0MgtwF7rebxTbDKB49YUCpAg== +"@typescript-eslint/scope-manager@5.30.0": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-5.30.0.tgz#bf585ee801ab4ad84db2f840174e171a6bb002c7" + integrity sha512-3TZxvlQcK5fhTBw5solQucWSJvonXf5yua5nx8OqK94hxdrT7/6W3/CS42MLd/f1BmlmmbGEgQcTHHCktUX5bQ== dependencies: - "@typescript-eslint/types" "5.27.1" - "@typescript-eslint/visitor-keys" "5.27.1" + "@typescript-eslint/types" "5.30.0" + "@typescript-eslint/visitor-keys" "5.30.0" -"@typescript-eslint/type-utils@5.27.1": - version "5.27.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.27.1.tgz#369f695199f74c1876e395ebea202582eb1d4166" - integrity sha512-+UC1vVUWaDHRnC2cQrCJ4QtVjpjjCgjNFpg8b03nERmkHv9JV9X5M19D7UFMd+/G7T/sgFwX2pGmWK38rqyvXw== +"@typescript-eslint/type-utils@5.30.0": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-5.30.0.tgz#98f3af926a5099153f092d4dad87148df21fbaae" + integrity sha512-GF8JZbZqSS+azehzlv/lmQQ3EU3VfWYzCczdZjJRxSEeXDQkqFhCBgFhallLDbPwQOEQ4MHpiPfkjKk7zlmeNg== dependencies: - "@typescript-eslint/utils" "5.27.1" + "@typescript-eslint/utils" "5.30.0" debug "^4.3.4" tsutils "^3.21.0" -"@typescript-eslint/types@5.27.1": - version "5.27.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.27.1.tgz#34e3e629501349d38be6ae97841298c03a6ffbf1" - integrity sha512-LgogNVkBhCTZU/m8XgEYIWICD6m4dmEDbKXESCbqOXfKZxRKeqpiJXQIErv66sdopRKZPo5l32ymNqibYEH/xg== +"@typescript-eslint/types@5.30.0": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-5.30.0.tgz#db7d81d585a3da3801432a9c1d2fafbff125e110" + integrity sha512-vfqcBrsRNWw/LBXyncMF/KrUTYYzzygCSsVqlZ1qGu1QtGs6vMkt3US0VNSQ05grXi5Yadp3qv5XZdYLjpp8ag== -"@typescript-eslint/typescript-estree@5.27.1": - version "5.27.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.27.1.tgz#7621ee78607331821c16fffc21fc7a452d7bc808" - integrity sha512-DnZvvq3TAJ5ke+hk0LklvxwYsnXpRdqUY5gaVS0D4raKtbznPz71UJGnPTHEFo0GDxqLOLdMkkmVZjSpET1hFw== +"@typescript-eslint/typescript-estree@5.30.0": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-5.30.0.tgz#4565ee8a6d2ac368996e20b2344ea0eab1a8f0bb" + integrity sha512-hDEawogreZB4n1zoqcrrtg/wPyyiCxmhPLpZ6kmWfKF5M5G0clRLaEexpuWr31fZ42F96SlD/5xCt1bT5Qm4Nw== dependencies: - "@typescript-eslint/types" "5.27.1" - "@typescript-eslint/visitor-keys" "5.27.1" + "@typescript-eslint/types" "5.30.0" + "@typescript-eslint/visitor-keys" "5.30.0" debug "^4.3.4" globby "^11.1.0" is-glob "^4.0.3" semver "^7.3.7" tsutils "^3.21.0" -"@typescript-eslint/utils@5.27.1": - version "5.27.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.27.1.tgz#b4678b68a94bc3b85bf08f243812a6868ac5128f" - integrity sha512-mZ9WEn1ZLDaVrhRaYgzbkXBkTPghPFsup8zDbbsYTxC5OmqrFE7skkKS/sraVsLP3TcT3Ki5CSyEFBRkLH/H/w== +"@typescript-eslint/utils@5.30.0": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-5.30.0.tgz#1dac771fead5eab40d31860716de219356f5f754" + integrity sha512-0bIgOgZflLKIcZsWvfklsaQTM3ZUbmtH0rJ1hKyV3raoUYyeZwcjQ8ZUJTzS7KnhNcsVT1Rxs7zeeMHEhGlltw== dependencies: "@types/json-schema" "^7.0.9" - "@typescript-eslint/scope-manager" "5.27.1" - "@typescript-eslint/types" "5.27.1" - "@typescript-eslint/typescript-estree" "5.27.1" + "@typescript-eslint/scope-manager" "5.30.0" + "@typescript-eslint/types" "5.30.0" + "@typescript-eslint/typescript-estree" "5.30.0" eslint-scope "^5.1.1" eslint-utils "^3.0.0" -"@typescript-eslint/visitor-keys@5.27.1": - version "5.27.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.27.1.tgz#05a62666f2a89769dac2e6baa48f74e8472983af" - integrity sha512-xYs6ffo01nhdJgPieyk7HAOpjhTsx7r/oB9LWEhwAXgwn33tkr+W8DI2ChboqhZlC4q3TC6geDYPoiX8ROqyOQ== +"@typescript-eslint/visitor-keys@5.30.0": + version "5.30.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-5.30.0.tgz#07721d23daca2ec4c2da7f1e660d41cd78bacac3" + integrity sha512-6WcIeRk2DQ3pHKxU1Ni0qMXJkjO/zLjBymlYBy/53qxe7yjEFSvzKLDToJjURUhSl2Fzhkl4SMXQoETauF74cw== dependencies: - "@typescript-eslint/types" "5.27.1" + "@typescript-eslint/types" "5.30.0" eslint-visitor-keys "^3.3.0" "@ungap/promise-all-settled@1.1.2": @@ -600,10 +600,10 @@ resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44" integrity sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q== -"@vitejs/plugin-vue@2.3.3": - version "2.3.3" - resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-2.3.3.tgz#fbf80cc039b82ac21a1acb0f0478de8f61fbf600" - integrity sha512-SmQLDyhz+6lGJhPELsBdzXGc+AcaT8stgkbiTFGpXPe8Tl1tJaBw1A6pxDqDuRsVkD8uscrkx3hA7QDOoKYtyw== +"@vitejs/plugin-vue@3.0.0-beta.1": + version "3.0.0-beta.1" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-vue/-/plugin-vue-3.0.0-beta.1.tgz#65a6be6ed619955a5edea6115dedcfc5da4ed3f6" + integrity sha512-cPVQHIKZkVEQ8qW7+BlbTrGJXNpP2aMKzVhQdTnWK9u6cSDmVdZOXHmKPO2KVvrNpFXXS8R7hHBXMsSApA+XOA== "@vue/compiler-core@3.2.37": version "3.2.37" @@ -647,11 +647,6 @@ "@vue/compiler-dom" "3.2.37" "@vue/shared" "3.2.37" -"@vue/devtools-api@^6.0.0": - version "6.0.12" - resolved "https://registry.yarnpkg.com/@vue/devtools-api/-/devtools-api-6.0.12.tgz#7b57cce215ae9f37a86984633b3aa3d595aa5b46" - integrity sha512-iO/4FIezHKXhiDBdKySCvJVh8/mZPxHpiQrTy+PXVqJZgpTPTdHy4q8GXulaY+UKEagdkBb0onxNQZ0LNiqVhw== - "@vue/reactivity-transform@3.2.37": version "3.2.37" resolved "https://registry.yarnpkg.com/@vue/reactivity-transform/-/reactivity-transform-3.2.37.tgz#0caa47c4344df4ae59f5a05dde2a8758829f8eca" @@ -1265,10 +1260,10 @@ csstype@^2.6.8: resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.13.tgz#a6893015b90e84dd6e85d0e3b442a1e84f2dbe0f" integrity sha512-ul26pfSQTZW8dcOnD2iiJssfXw0gdNVX9IJDH/X3K5DGPfj+fUYe3kB+swUY6BF3oZDxaID3AJt+9/ojSAE05A== -cypress@10.0.3: - version "10.0.3" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-10.0.3.tgz#889b4bef863b7d1ef1b608b85b964394ad350c5f" - integrity sha512-8C82XTybsEmJC9POYSNITGUhMLCRwB9LadP0x33H+52QVoBjhsWvIzrI+ybCe0+TyxaF0D5/9IL2kSTgjqCB9A== +cypress@10.3.0: + version "10.3.0" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-10.3.0.tgz#fae8d32f0822fcfb938e79c7c31ef344794336ae" + integrity sha512-txkQWKzvBVnWdCuKs5Xc08gjpO89W2Dom2wpZgT9zWZT5jXxqPIxqP/NC1YArtkpmp3fN5HW8aDjYBizHLUFvg== dependencies: "@cypress/request" "^2.88.10" "@cypress/xvfb" "^1.2.4" @@ -1539,131 +1534,131 @@ es6-symbol@^3.1.1, es6-symbol@~3.1.3: d "^1.0.1" ext "^1.1.2" -esbuild-android-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.38.tgz#5b94a1306df31d55055f64a62ff6b763a47b7f64" - integrity sha512-aRFxR3scRKkbmNuGAK+Gee3+yFxkTJO/cx83Dkyzo4CnQl/2zVSurtG6+G86EQIZ+w+VYngVyK7P3HyTBKu3nw== +esbuild-android-64@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.48.tgz#7e6394a0e517f738641385aaf553c7e4fb6d1ae3" + integrity sha512-3aMjboap/kqwCUpGWIjsk20TtxVoKck8/4Tu19rubh7t5Ra0Yrpg30Mt1QXXlipOazrEceGeWurXKeFJgkPOUg== -esbuild-android-arm64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.38.tgz#78acc80773d16007de5219ccce544c036abd50b8" - integrity sha512-L2NgQRWuHFI89IIZIlpAcINy9FvBk6xFVZ7xGdOwIm8VyhX1vNCEqUJO3DPSSy945Gzdg98cxtNt8Grv1CsyhA== +esbuild-android-arm64@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.48.tgz#6877566be0f82dd5a43030c0007d06ece7f7c02f" + integrity sha512-vptI3K0wGALiDq+EvRuZotZrJqkYkN5282iAfcffjI5lmGG9G1ta/CIVauhY42MBXwEgDJkweiDcDMRLzBZC4g== -esbuild-darwin-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.38.tgz#e02b1291f629ebdc2aa46fabfacc9aa28ff6aa46" - integrity sha512-5JJvgXkX87Pd1Og0u/NJuO7TSqAikAcQQ74gyJ87bqWRVeouky84ICoV4sN6VV53aTW+NE87qLdGY4QA2S7KNA== +esbuild-darwin-64@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.48.tgz#ea3caddb707d88f844b1aa1dea5ff3b0a71ef1fd" + integrity sha512-gGQZa4+hab2Va/Zww94YbshLuWteyKGD3+EsVon8EWTWhnHFRm5N9NbALNbwi/7hQ/hM1Zm4FuHg+k6BLsl5UA== -esbuild-darwin-arm64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.38.tgz#01eb6650ec010b18c990e443a6abcca1d71290a9" - integrity sha512-eqF+OejMI3mC5Dlo9Kdq/Ilbki9sQBw3QlHW3wjLmsLh+quNfHmGMp3Ly1eWm981iGBMdbtSS9+LRvR2T8B3eQ== +esbuild-darwin-arm64@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.48.tgz#4e5eaab54df66cc319b76a2ac0e8af4e6f0d9c2f" + integrity sha512-bFjnNEXjhZT+IZ8RvRGNJthLWNHV5JkCtuOFOnjvo5pC0sk2/QVk0Qc06g2PV3J0TcU6kaPC3RN9yy9w2PSLEA== -esbuild-freebsd-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.38.tgz#790b8786729d4aac7be17648f9ea8e0e16475b5e" - integrity sha512-epnPbhZUt93xV5cgeY36ZxPXDsQeO55DppzsIgWM8vgiG/Rz+qYDLmh5ts3e+Ln1wA9dQ+nZmVHw+RjaW3I5Ig== +esbuild-freebsd-64@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.48.tgz#47b5abc7426eae66861490ffbb380acc67af5b15" + integrity sha512-1NOlwRxmOsnPcWOGTB10JKAkYSb2nue0oM1AfHWunW/mv3wERfJmnYlGzL3UAOIUXZqW8GeA2mv+QGwq7DToqA== -esbuild-freebsd-arm64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.38.tgz#b66340ab28c09c1098e6d9d8ff656db47d7211e6" - integrity sha512-/9icXUYJWherhk+y5fjPI5yNUdFPtXHQlwP7/K/zg8t8lQdHVj20SqU9/udQmeUo5pDFHMYzcEFfJqgOVeKNNQ== +esbuild-freebsd-arm64@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.48.tgz#e8c54c8637cd44feed967ea12338b0a4da3a7b11" + integrity sha512-gXqKdO8wabVcYtluAbikDH2jhXp+Klq5oCD5qbVyUG6tFiGhrC9oczKq3vIrrtwcxDQqK6+HDYK8Zrd4bCA9Gw== -esbuild-linux-32@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.38.tgz#7927f950986fd39f0ff319e92839455912b67f70" - integrity sha512-QfgfeNHRFvr2XeHFzP8kOZVnal3QvST3A0cgq32ZrHjSMFTdgXhMhmWdKzRXP/PKcfv3e2OW9tT9PpcjNvaq6g== +esbuild-linux-32@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.48.tgz#229cf3246de2b7937c3ac13fac622d4d7a1344c5" + integrity sha512-ghGyDfS289z/LReZQUuuKq9KlTiTspxL8SITBFQFAFRA/IkIvDpnZnCAKTCjGXAmUqroMQfKJXMxyjJA69c/nQ== -esbuild-linux-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.38.tgz#4893d07b229d9cfe34a2b3ce586399e73c3ac519" - integrity sha512-uuZHNmqcs+Bj1qiW9k/HZU3FtIHmYiuxZ/6Aa+/KHb/pFKr7R3aVqvxlAudYI9Fw3St0VCPfv7QBpUITSmBR1Q== +esbuild-linux-64@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.48.tgz#7c0e7226c02c42aacc5656c36977493dc1e96c4f" + integrity sha512-vni3p/gppLMVZLghI7oMqbOZdGmLbbKR23XFARKnszCIBpEMEDxOMNIKPmMItQrmH/iJrL1z8Jt2nynY0bE1ug== -esbuild-linux-arm64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.38.tgz#8442402e37d0b8ae946ac616784d9c1a2041056a" - integrity sha512-HlMGZTEsBrXrivr64eZ/EO0NQM8H8DuSENRok9d+Jtvq8hOLzrxfsAT9U94K3KOGk2XgCmkaI2KD8hX7F97lvA== +esbuild-linux-arm64@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.48.tgz#0af1eda474b5c6cc0cace8235b74d0cb8fcf57a7" + integrity sha512-3CFsOlpoxlKPRevEHq8aAntgYGYkE1N9yRYAcPyng/p4Wyx0tPR5SBYsxLKcgPB9mR8chHEhtWYz6EZ+H199Zw== -esbuild-linux-arm@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.38.tgz#d5dbf32d38b7f79be0ec6b5fb2f9251fd9066986" - integrity sha512-FiFvQe8J3VKTDXG01JbvoVRXQ0x6UZwyrU4IaLBZeq39Bsbatd94Fuc3F1RGqPF5RbIWW7RvkVQjn79ejzysnA== +esbuild-linux-arm@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.48.tgz#de4d1fa6b77cdcd00e2bb43dd0801e4680f0ab52" + integrity sha512-+VfSV7Akh1XUiDNXgqgY1cUP1i2vjI+BmlyXRfVz5AfV3jbpde8JTs5Q9sYgaoq5cWfuKfoZB/QkGOI+QcL1Tw== -esbuild-linux-mips64le@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.38.tgz#95081e42f698bbe35d8ccee0e3a237594b337eb5" - integrity sha512-qd1dLf2v7QBiI5wwfil9j0HG/5YMFBAmMVmdeokbNAMbcg49p25t6IlJFXAeLzogv1AvgaXRXvgFNhScYEUXGQ== +esbuild-linux-mips64le@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.48.tgz#822c1778495f7868e990d4da47ad7281df28fd15" + integrity sha512-cs0uOiRlPp6ymknDnjajCgvDMSsLw5mST2UXh+ZIrXTj2Ifyf2aAP3Iw4DiqgnyYLV2O/v/yWBJx+WfmKEpNLA== -esbuild-linux-ppc64le@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.38.tgz#dceb0a1b186f5df679618882a7990bd422089b47" - integrity sha512-mnbEm7o69gTl60jSuK+nn+pRsRHGtDPfzhrqEUXyCl7CTOCLtWN2bhK8bgsdp6J/2NyS/wHBjs1x8aBWwP2X9Q== +esbuild-linux-ppc64le@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.48.tgz#55de0a9ec4a48fedfe82a63e083164d001709447" + integrity sha512-+2F0vJMkuI0Wie/wcSPDCqXvSFEELH7Jubxb7mpWrA/4NpT+/byjxDz0gG6R1WJoeDefcrMfpBx4GFNN1JQorQ== -esbuild-linux-riscv64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.38.tgz#61fb8edb75f475f9208c4a93ab2bfab63821afd2" - integrity sha512-+p6YKYbuV72uikChRk14FSyNJZ4WfYkffj6Af0/Tw63/6TJX6TnIKE+6D3xtEc7DeDth1fjUOEqm+ApKFXbbVQ== +esbuild-linux-riscv64@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.48.tgz#cd2b7381880b2f4b21a5a598fb673492120f18a5" + integrity sha512-BmaK/GfEE+5F2/QDrIXteFGKnVHGxlnK9MjdVKMTfvtmudjY3k2t8NtlY4qemKSizc+QwyombGWTBDc76rxePA== -esbuild-linux-s390x@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.38.tgz#34c7126a4937406bf6a5e69100185fd702d12fe0" - integrity sha512-0zUsiDkGJiMHxBQ7JDU8jbaanUY975CdOW1YDrurjrM0vWHfjv9tLQsW9GSyEb/heSK1L5gaweRjzfUVBFoybQ== +esbuild-linux-s390x@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.48.tgz#4b319eca2a5c64637fc7397ffbd9671719cdb6bf" + integrity sha512-tndw/0B9jiCL+KWKo0TSMaUm5UWBLsfCKVdbfMlb3d5LeV9WbijZ8Ordia8SAYv38VSJWOEt6eDCdOx8LqkC4g== -esbuild-netbsd-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.38.tgz#322ea9937d9e529183ee281c7996b93eb38a5d95" - integrity sha512-cljBAApVwkpnJZfnRVThpRBGzCi+a+V9Ofb1fVkKhtrPLDYlHLrSYGtmnoTVWDQdU516qYI8+wOgcGZ4XIZh0Q== +esbuild-netbsd-64@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.48.tgz#c27cde8b5cb55dcc227943a18ab078fb98d0adbf" + integrity sha512-V9hgXfwf/T901Lr1wkOfoevtyNkrxmMcRHyticybBUHookznipMOHoF41Al68QBsqBxnITCEpjjd4yAos7z9Tw== -esbuild-openbsd-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.38.tgz#1ca29bb7a2bf09592dcc26afdb45108f08a2cdbd" - integrity sha512-CDswYr2PWPGEPpLDUO50mL3WO/07EMjnZDNKpmaxUPsrW+kVM3LoAqr/CE8UbzugpEiflYqJsGPLirThRB18IQ== +esbuild-openbsd-64@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.48.tgz#af5ab2d1cb41f09064bba9465fc8bf1309150df1" + integrity sha512-+IHf4JcbnnBl4T52egorXMatil/za0awqzg2Vy6FBgPcBpisDWT2sVz/tNdrK9kAqj+GZG/jZdrOkj7wsrNTKA== -esbuild-sunos-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.38.tgz#c9446f7d8ebf45093e7bb0e7045506a88540019b" - integrity sha512-2mfIoYW58gKcC3bck0j7lD3RZkqYA7MmujFYmSn9l6TiIcAMpuEvqksO+ntBgbLep/eyjpgdplF7b+4T9VJGOA== +esbuild-sunos-64@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.48.tgz#db3ae20526055cf6fd5c4582676233814603ac54" + integrity sha512-77m8bsr5wOpOWbGi9KSqDphcq6dFeJyun8TA+12JW/GAjyfTwVtOnN8DOt6DSPUfEV+ltVMNqtXUeTeMAxl5KA== -esbuild-windows-32@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.38.tgz#f8e9b4602fd0ccbd48e5c8d117ec0ba4040f2ad1" - integrity sha512-L2BmEeFZATAvU+FJzJiRLFUP+d9RHN+QXpgaOrs2klshoAm1AE6Us4X6fS9k33Uy5SzScn2TpcgecbqJza1Hjw== +esbuild-windows-32@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.48.tgz#021ffceb0a3f83078262870da88a912293c57475" + integrity sha512-EPgRuTPP8vK9maxpTGDe5lSoIBHGKO/AuxDncg5O3NkrPeLNdvvK8oywB0zGaAZXxYWfNNSHskvvDgmfVTguhg== -esbuild-windows-64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.38.tgz#280f58e69f78535f470905ce3e43db1746518107" - integrity sha512-Khy4wVmebnzue8aeSXLC+6clo/hRYeNIm0DyikoEqX+3w3rcvrhzpoix0S+MF9vzh6JFskkIGD7Zx47ODJNyCw== +esbuild-windows-64@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.48.tgz#a4d3407b580f9faac51f61eec095fa985fb3fee4" + integrity sha512-YmpXjdT1q0b8ictSdGwH3M8VCoqPpK1/UArze3X199w6u8hUx3V8BhAi1WjbsfDYRBanVVtduAhh2sirImtAvA== -esbuild-windows-arm64@0.14.38: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.38.tgz#d97e9ac0f95a4c236d9173fa9f86c983d6a53f54" - integrity sha512-k3FGCNmHBkqdJXuJszdWciAH77PukEyDsdIryEHn9cKLQFxzhT39dSumeTuggaQcXY57UlmLGIkklWZo2qzHpw== +esbuild-windows-arm64@0.14.48: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.48.tgz#762c0562127d8b09bfb70a3c816460742dd82880" + integrity sha512-HHaOMCsCXp0rz5BT2crTka6MPWVno121NKApsGs/OIW5QC0ggC69YMGs1aJct9/9FSUF4A1xNE/cLvgB5svR4g== -esbuild@^0.14.27: - version "0.14.38" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.38.tgz#99526b778cd9f35532955e26e1709a16cca2fb30" - integrity sha512-12fzJ0fsm7gVZX1YQ1InkOE5f9Tl7cgf6JPYXRJtPIoE0zkWAbHdPHVPPaLi9tYAcEBqheGzqLn/3RdTOyBfcA== +esbuild@^0.14.47: + version "0.14.48" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.48.tgz#da5d8d25cd2d940c45ea0cfecdca727f7aee2b85" + integrity sha512-w6N1Yn5MtqK2U1/WZTX9ZqUVb8IOLZkZ5AdHkT6x3cHDMVsYWC7WPdiLmx19w3i4Rwzy5LqsEMtVihG3e4rFzA== optionalDependencies: - esbuild-android-64 "0.14.38" - esbuild-android-arm64 "0.14.38" - esbuild-darwin-64 "0.14.38" - esbuild-darwin-arm64 "0.14.38" - esbuild-freebsd-64 "0.14.38" - esbuild-freebsd-arm64 "0.14.38" - esbuild-linux-32 "0.14.38" - esbuild-linux-64 "0.14.38" - esbuild-linux-arm "0.14.38" - esbuild-linux-arm64 "0.14.38" - esbuild-linux-mips64le "0.14.38" - esbuild-linux-ppc64le "0.14.38" - esbuild-linux-riscv64 "0.14.38" - esbuild-linux-s390x "0.14.38" - esbuild-netbsd-64 "0.14.38" - esbuild-openbsd-64 "0.14.38" - esbuild-sunos-64 "0.14.38" - esbuild-windows-32 "0.14.38" - esbuild-windows-64 "0.14.38" - esbuild-windows-arm64 "0.14.38" + esbuild-android-64 "0.14.48" + esbuild-android-arm64 "0.14.48" + esbuild-darwin-64 "0.14.48" + esbuild-darwin-arm64 "0.14.48" + esbuild-freebsd-64 "0.14.48" + esbuild-freebsd-arm64 "0.14.48" + esbuild-linux-32 "0.14.48" + esbuild-linux-64 "0.14.48" + esbuild-linux-arm "0.14.48" + esbuild-linux-arm64 "0.14.48" + esbuild-linux-mips64le "0.14.48" + esbuild-linux-ppc64le "0.14.48" + esbuild-linux-riscv64 "0.14.48" + esbuild-linux-s390x "0.14.48" + esbuild-netbsd-64 "0.14.48" + esbuild-openbsd-64 "0.14.48" + esbuild-sunos-64 "0.14.48" + esbuild-windows-32 "0.14.48" + esbuild-windows-64 "0.14.48" + esbuild-windows-arm64 "0.14.48" escalade@^3.1.1: version "3.1.1" @@ -1720,10 +1715,10 @@ eslint-plugin-import@2.26.0: resolve "^1.22.0" tsconfig-paths "^3.14.1" -eslint-plugin-vue@9.1.0: - version "9.1.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-9.1.0.tgz#b528941325e26a24bc5d5c5030c0a8996c36659c" - integrity sha512-EPCeInPicQ/YyfOWJDr1yfEeSNoFCMzUus107lZyYi37xejdOolNzS5MXGXp8+9bkoKZMdv/1AcZzQebME6r+g== +eslint-plugin-vue@9.1.1: + version "9.1.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-vue/-/eslint-plugin-vue-9.1.1.tgz#341f7533cb041958455138834341d5be01f9f327" + integrity sha512-W9n5PB1X2jzC7CK6riG0oAcxjmKrjTF6+keL1rni8n57DZeilx/Fulz+IRJK3lYseLNAygN0I62L7DvioW40Tw== dependencies: eslint-utils "^3.0.0" natural-compare "^1.4.0" @@ -1766,10 +1761,10 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@8.17.0: - version "8.17.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.17.0.tgz#1cfc4b6b6912f77d24b874ca1506b0fe09328c21" - integrity sha512-gq0m0BTJfci60Fz4nczYxNAlED+sMcihltndR8t9t1evnU/azx53x3t2UHXC/uRjcbvRw/XctpaNygSTcQD+Iw== +eslint@8.18.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.18.0.tgz#78d565d16c993d0b73968c523c0446b13da784fd" + integrity sha512-As1EfFMVk7Xc6/CvhssHUjsAQSkpfXvUGMFC3ce8JDe6WvqCgRrLOBQbVpsBFr1X1V+RACOadnzVvcUS5ni2bA== dependencies: "@eslint/eslintrc" "^1.3.0" "@humanwhocodes/config-array" "^0.9.2" @@ -2349,10 +2344,10 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -idb-keyval@6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-6.1.0.tgz#e659cff41188e6097d7fadd69926f6adbbe70041" - integrity sha512-u/qHZ75rlD3gH+Zah8dAJVJcGW/RfCnfNrFkElC5RpRCnpsCXXhqjVk+6MoVKJ3WhmNbRYdI6IIVP88e+5sxGw== +idb-keyval@6.2.0: + version "6.2.0" + resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-6.2.0.tgz#3af94a3cc0689d6ee0bc9e045d2a3340ea897173" + integrity sha512-uw+MIyQn2jl3+hroD7hF8J7PUviBU7BPKWw4f/ISf32D4LoGu98yHjrzWWJDASu9QNrX10tCJqk9YY0ClWm8Ng== dependencies: safari-14-idb-fix "^3.0.0" @@ -2489,6 +2484,13 @@ is-core-module@^2.8.1: dependencies: has "^1.0.3" +is-core-module@^2.9.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.9.0.tgz#e1c34429cd51c6dd9e09e0799e396e27b19a9c69" + integrity sha512-+5FPy5PnwmO3lvfMb0AsoPaBG+5KHUI0wYFXOtYPnVVVspTFUuMZNfNaNVRt3FZadstu2c8x23vykRW/NBoU6A== + dependencies: + has "^1.0.3" + is-date-object@^1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.2.tgz#bda736f2cd8fd06d32844e7743bfa7494c3bfd7e" @@ -3000,6 +3002,11 @@ nanoid@3.3.3, nanoid@^3.3.3: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.3.tgz#fd8e8b7aa761fe807dba2d1b98fb7241bb724a25" integrity sha512-p1sjXuopFs0xg+fPASzQ28agW1oHD7xDsd9Xkf3T15H3c/cifrFHVwrh74PdoklAPi+i7MdRsE47vm2r6JoB+w== +nanoid@^3.3.4: + version "3.3.4" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" + integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" @@ -3262,10 +3269,10 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= -photoswipe@5.2.7: - version "5.2.7" - resolved "https://registry.yarnpkg.com/photoswipe/-/photoswipe-5.2.7.tgz#9ff2aaf2a3e03c817ac2835dc6dee0f901e8159d" - integrity sha512-AogMba7W/O5gOtDIZ8cQuou1ltwxlaLNoZY1qi1s+kbYXpZk9D6rXxnNGAfDppl+bfe+sKLW2w2sx+3uQ8oPzg== +photoswipe@5.2.8: + version "5.2.8" + resolved "https://registry.yarnpkg.com/photoswipe/-/photoswipe-5.2.8.tgz#c276a17dac575c746262472ceb594fc390786176" + integrity sha512-tsbG+1ILcli4mR3Tzp4xdxCUSSJaz14wct4dSznk3suVst9tBdt6vDlSASOw/VFqSkcDjbRUA1tC1LoF2DCkzg== picocolors@^1.0.0: version "1.0.0" @@ -3307,7 +3314,7 @@ postcss-selector-parser@^6.0.9: cssesc "^3.0.0" util-deprecate "^1.0.2" -postcss@^8.1.10, postcss@^8.4.13: +postcss@^8.1.10: version "8.4.13" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.13.tgz#7c87bc268e79f7f86524235821dfdf9f73e5d575" integrity sha512-jtL6eTBrza5MPzy8oJLFuUscHDXTV5KcLlqAWHl5q5WYRfnNRGSmOZmOZ1T6Gy7A99mOZfqungmZMpMmCVJ8ZA== @@ -3316,6 +3323,15 @@ postcss@^8.1.10, postcss@^8.4.13: picocolors "^1.0.0" source-map-js "^1.0.2" +postcss@^8.4.14: + version "8.4.14" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.14.tgz#ee9274d5622b4858c1007a74d76e42e56fd21caf" + integrity sha512-E398TUmfAYFPBSdzgeieK2Y1+1cpdxJx8yXbK/m57nRhKSmk1GB2tO4lbLBtlkfPQTDKfe4Xqv1ASWPpayPEig== + dependencies: + nanoid "^3.3.4" + picocolors "^1.0.0" + source-map-js "^1.0.2" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396" @@ -3596,6 +3612,15 @@ resolve@^1.22.0: path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" +resolve@^1.22.1: + version "1.22.1" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" + integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== + dependencies: + is-core-module "^2.9.0" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" + restore-cursor@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" @@ -3624,17 +3649,10 @@ rndstr@1.0.0: rangestr "0.0.1" seedrandom "2.4.2" -rollup@2.75.6: - version "2.75.6" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.75.6.tgz#ac4dc8600f95942a0180f61c7c9d6200e374b439" - integrity sha512-OEf0TgpC9vU6WGROJIk1JA3LR5vk/yvqlzxqdrE2CzzXnqKXNzbAwlWUXis8RS3ZPe7LAq+YUxsRa0l3r27MLA== - optionalDependencies: - fsevents "~2.3.2" - -rollup@^2.59.0: - version "2.70.2" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.70.2.tgz#808d206a8851628a065097b7ba2053bd83ba0c0d" - integrity sha512-EitogNZnfku65I1DD5Mxe8JYRUCy0hkK5X84IlDtUs+O6JRMpRciXTzyCUuX11b5L5pvjH+OmFXiQ3XjabcXgg== +rollup@2.75.7, rollup@^2.75.6: + version "2.75.7" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.75.7.tgz#221ff11887ae271e37dcc649ba32ce1590aaa0b9" + integrity sha512-VSE1iy0eaAYNCxEXaleThdFXqZJ42qDBatAwrfnPlENEZ8erQ+0LYX4JXOLPceWfZpV1VtZwZ3dFCuOZiSyFtQ== optionalDependencies: fsevents "~2.3.2" @@ -3682,10 +3700,10 @@ safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== -sass@1.52.3: - version "1.52.3" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.52.3.tgz#b7cc7ffea2341ccc9a0c4fd372bf1b3f9be1b6cb" - integrity sha512-LNNPJ9lafx+j1ArtA7GyEJm9eawXN8KlA1+5dF6IZyoONg1Tyo/g+muOsENWJH/2Q1FHbbV4UwliU0cXMa/VIA== +sass@1.53.0: + version "1.53.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.53.0.tgz#eab73a7baac045cc57ddc1d1ff501ad2659952eb" + integrity sha512-zb/oMirbKhUgRQ0/GFz8TSAwRq2IlR29vOUJZOx0l8sV+CkHUfHa4u5nqrG+1VceZp7Jfj59SVW9ogdhTvJDcQ== dependencies: chokidar ">=3.0.0 <4.0.0" immutable "^4.0.0" @@ -3950,10 +3968,10 @@ textarea-caret@3.1.0: resolved "https://registry.yarnpkg.com/textarea-caret/-/textarea-caret-3.1.0.tgz#5d5a35bb035fd06b2ff0e25d5359e97f2655087f" integrity sha512-cXAvzO9pP5CGa6NKx0WYHl+8CHKZs8byMkt3PCJBCmq2a34YA9pO1NrQET5pzeqnBjBdToF5No4rrmkDUgQC2Q== -three@0.141.0: - version "0.141.0" - resolved "https://registry.yarnpkg.com/three/-/three-0.141.0.tgz#16677a12b9dd0c3e1568ebad0fd09de15d5a8216" - integrity sha512-JaSDAPWuk4RTzG5BYRQm8YZbERUxTfTDVouWgHMisS2to4E5fotMS9F2zPFNOIJyEFTTQDDKPpsgZVThKU3pXA== +three@0.142.0: + version "0.142.0" + resolved "https://registry.yarnpkg.com/three/-/three-0.142.0.tgz#89e226a16221f212eb1d40f0786604b711f28aed" + integrity sha512-ESjPO+3geFr+ZUfVMpMnF/eVU2uJPOh0e2ZpMFqjNca1wApS9lJb7E4MjwGIczgt9iuKd8PEm6Pfgp2bJ92Xtg== throttle-debounce@5.0.0: version "5.0.0" @@ -4007,10 +4025,10 @@ tough-cookie@~2.5.0: psl "^1.1.28" punycode "^2.1.1" -tsc-alias@1.6.9: - version "1.6.9" - resolved "https://registry.yarnpkg.com/tsc-alias/-/tsc-alias-1.6.9.tgz#d04d95124b95ad8eea55e52d45cf65a744c26baa" - integrity sha512-5lv5uAHn0cgxY1XfpXIdquUSz2xXq3ryQyNtxC6DYH7YT5rt/W+9Gsft2uyLFTh+ozk4qU8iCSP3VemjT69xlQ== +tsc-alias@1.6.11: + version "1.6.11" + resolved "https://registry.yarnpkg.com/tsc-alias/-/tsc-alias-1.6.11.tgz#d6d83f030ad11f48e4ead8ec5729929e5e60519b" + integrity sha512-mXEM21WriTJMQyo07B4Kc2nNFFk/1qOjU+jZ0ymXOyLz/A8J+dIBkimqZrh3s/x1qLGoJ1cNZQxa8GGoWOGX1Q== dependencies: chokidar "^3.5.3" commander "^9.0.0" @@ -4106,10 +4124,10 @@ typedarray-to-buffer@^3.1.5: dependencies: is-typedarray "^1.0.0" -typescript@4.7.3: - version "4.7.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.3.tgz#8364b502d5257b540f9de4c40be84c98e23a129d" - integrity sha512-WOkT3XYvrpXx4vMMqlD+8R8R37fZkjyLGlxavMc4iB8lrl8L0DeTcHbYgw/v0N/z9wAFsgBhcsF0ruoySS22mA== +typescript@4.7.4: + version "4.7.4" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" + integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== unbox-primitive@^1.0.1: version "1.0.1" @@ -4197,15 +4215,15 @@ verror@1.10.0: core-util-is "1.0.2" extsprintf "^1.2.0" -vite@2.9.10: - version "2.9.10" - resolved "https://registry.yarnpkg.com/vite/-/vite-2.9.10.tgz#f574d96655622c2e0fbc662edd0ed199c60fe91a" - integrity sha512-TwZRuSMYjpTurLqXspct+HZE7ONiW9d+wSWgvADGxhDPPyoIcNywY+RX4ng+QpK30DCa1l/oZgi2PLZDibhzbQ== +vite@3.0.0-beta.7: + version "3.0.0-beta.7" + resolved "https://registry.yarnpkg.com/vite/-/vite-3.0.0-beta.7.tgz#ded6483ef3b9b16dbe3a912a35accb9cc3498530" + integrity sha512-yjw154hB229qq5Bl6+/CJSTxC/yIDmDJbaAjE/pdracz3jytNEd2ovk5BvxgZT6+qPiUc2rRH3FgGqiZnweIFw== dependencies: - esbuild "^0.14.27" - postcss "^8.4.13" - resolve "^1.22.0" - rollup "^2.59.0" + esbuild "^0.14.47" + postcss "^8.4.14" + resolve "^1.22.1" + rollup "^2.75.6" optionalDependencies: fsevents "~2.3.2" @@ -4232,13 +4250,6 @@ vue-prism-editor@2.0.0-alpha.2: resolved "https://registry.yarnpkg.com/vue-prism-editor/-/vue-prism-editor-2.0.0-alpha.2.tgz#aa53a88efaaed628027cbb282c2b1d37fc7c5c69" integrity sha512-Gu42ba9nosrE+gJpnAEuEkDMqG9zSUysIR8SdXUw8MQKDjBnnNR9lHC18uOr/ICz7yrA/5c7jHJr9lpElODC7w== -vue-router@4.0.16: - version "4.0.16" - resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.0.16.tgz#9477beeeef36e80e04d041a1738801a55e6e862e" - integrity sha512-JcO7cb8QJLBWE+DfxGUL3xUDOae/8nhM1KVdnudadTAORbuxIC/xAydC5Zr/VLHUDQi1ppuTF5/rjBGzgzrJNA== - dependencies: - "@vue/devtools-api" "^6.0.0" - vue@3.2.37: version "3.2.37" resolved "https://registry.yarnpkg.com/vue/-/vue-3.2.37.tgz#da220ccb618d78579d25b06c7c21498ca4e5452e" |