Merge branch 'frontend-refactor'
30
makefile
@@ -32,58 +32,62 @@ src/platform/versioninfo.rc:
|
||||
|
||||
# build targets
|
||||
|
||||
host:
|
||||
assets:
|
||||
npm run build
|
||||
|
||||
host: assets
|
||||
go build $(GO_FLAGS) -o out/yarr ./cmd/yarr
|
||||
|
||||
darwin_amd64:
|
||||
darwin_amd64: assets
|
||||
# cross-compilation not supported: CC="zig cc -target x86_64-macos-none"
|
||||
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
darwin_arm64:
|
||||
darwin_arm64: assets
|
||||
# cross-compilation not supported: CC="zig cc -target aarch64-macos-none"
|
||||
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
linux_amd64:
|
||||
linux_amd64: assets
|
||||
CC="zig cc -target x86_64-linux-musl -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=amd64 \
|
||||
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
linux_arm64:
|
||||
linux_arm64: assets
|
||||
CC="zig cc -target aarch64-linux-musl -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=arm64 \
|
||||
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
linux_armv7:
|
||||
linux_armv7: assets
|
||||
CC="zig cc -target arm-linux-musleabihf -O2 -g0" CGO_CFLAGS="-D_LARGEFILE64_SOURCE" GOOS=linux GOARCH=arm GOARM=7 \
|
||||
go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
windows_amd64:
|
||||
windows_amd64: assets
|
||||
CC="zig cc -target x86_64-windows-gnu" GOOS=windows GOARCH=amd64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
windows_arm64:
|
||||
windows_arm64: assets
|
||||
CC="zig cc -target aarch64-windows-gnu" GOOS=windows GOARCH=arm64 go build $(GO_FLAGS) -o out/$@/yarr ./cmd/yarr
|
||||
|
||||
darwin_arm64_gui: etc/icon.icns
|
||||
darwin_arm64_gui: assets etc/icon.icns
|
||||
GOOS=darwin GOARCH=arm64 go build $(GO_FLAGS_GUI) -o out/$@/yarr ./cmd/yarr
|
||||
./etc/macos_package.sh $(VERSION) etc/icon.icns out/$@/yarr out/$@
|
||||
|
||||
darwin_amd64_gui: etc/icon.icns
|
||||
darwin_amd64_gui: assets etc/icon.icns
|
||||
GOOS=darwin GOARCH=amd64 go build $(GO_FLAGS_GUI) -o out/$@/yarr ./cmd/yarr
|
||||
./etc/macos_package.sh $(VERSION) etc/icon.icns out/$@/yarr out/$@
|
||||
|
||||
windows_amd64_gui: src/platform/versioninfo.rc
|
||||
windows_amd64_gui: assets src/platform/versioninfo.rc
|
||||
GOOS=windows GOARCH=amd64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
|
||||
|
||||
windows_arm64_gui: src/platform/versioninfo.rc
|
||||
windows_arm64_gui: assets src/platform/versioninfo.rc
|
||||
GOOS=windows GOARCH=arm64 go build $(GO_FLAGS_GUI_WIN) -o out/$@/yarr.exe ./cmd/yarr
|
||||
|
||||
YARR_DB ?= local.db
|
||||
|
||||
serve:
|
||||
serve: assets
|
||||
go run $(GO_FLAGS_DEBUG) ./cmd/yarr -db "$(YARR_DB)"
|
||||
|
||||
test:
|
||||
go test $(GO_FLAGS) ./...
|
||||
|
||||
.PHONY: \
|
||||
assets \
|
||||
host \
|
||||
darwin_amd64 darwin_amd64_gui \
|
||||
darwin_arm64 darwin_arm64_gui \
|
||||
|
||||
707
package-lock.json
generated
Normal file
@@ -0,0 +1,707 @@
|
||||
{
|
||||
"name": "yarr",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@fluent/bundle": "^0.19.1",
|
||||
"bootstrap": "4.5",
|
||||
"vue": "^2.7.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz",
|
||||
"integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz",
|
||||
"integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz",
|
||||
"integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz",
|
||||
"integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz",
|
||||
"integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz",
|
||||
"integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz",
|
||||
"integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz",
|
||||
"integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz",
|
||||
"integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@fluent/bundle": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@fluent/bundle/-/bundle-0.19.1.tgz",
|
||||
"integrity": "sha512-SWJLZrPamDPsJlFFOW1nkgN0j0rbPbmSdmK0XAoXlyqKieLtMVl4vzng3aR5pwKoUx0scug8+YY2oct3fdfy9A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.0.0",
|
||||
"npm": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "2.7.16",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-2.7.16.tgz",
|
||||
"integrity": "sha512-KWhJ9k5nXuNtygPU7+t1rX6baZeqOYLEforUPjgNDBnLicfHCoi48H87Q8XyLZOrNNsmhuwKqtpDQWjEFe6Ekg==",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.23.5",
|
||||
"postcss": "^8.4.14",
|
||||
"source-map": "^0.6.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"prettier": "^1.18.2 || ^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bootstrap": {
|
||||
"version": "4.5.3",
|
||||
"resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.5.3.tgz",
|
||||
"integrity": "sha512-o9ppKQioXGqhw8Z7mah6KdTYpNQY//tipnkxppWhPbiSWdD+1raYsnhwEZjkTHYbGee4cVQ0Rx65EhOY/HNLcQ==",
|
||||
"deprecated": "This version of Bootstrap is no longer supported. Please upgrade to the latest version.",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/bootstrap"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"jquery": "1.9.1 - 3",
|
||||
"popper.js": "^1.16.1"
|
||||
}
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz",
|
||||
"integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.1",
|
||||
"@esbuild/android-arm": "0.28.1",
|
||||
"@esbuild/android-arm64": "0.28.1",
|
||||
"@esbuild/android-x64": "0.28.1",
|
||||
"@esbuild/darwin-arm64": "0.28.1",
|
||||
"@esbuild/darwin-x64": "0.28.1",
|
||||
"@esbuild/freebsd-arm64": "0.28.1",
|
||||
"@esbuild/freebsd-x64": "0.28.1",
|
||||
"@esbuild/linux-arm": "0.28.1",
|
||||
"@esbuild/linux-arm64": "0.28.1",
|
||||
"@esbuild/linux-ia32": "0.28.1",
|
||||
"@esbuild/linux-loong64": "0.28.1",
|
||||
"@esbuild/linux-mips64el": "0.28.1",
|
||||
"@esbuild/linux-ppc64": "0.28.1",
|
||||
"@esbuild/linux-riscv64": "0.28.1",
|
||||
"@esbuild/linux-s390x": "0.28.1",
|
||||
"@esbuild/linux-x64": "0.28.1",
|
||||
"@esbuild/netbsd-arm64": "0.28.1",
|
||||
"@esbuild/netbsd-x64": "0.28.1",
|
||||
"@esbuild/openbsd-arm64": "0.28.1",
|
||||
"@esbuild/openbsd-x64": "0.28.1",
|
||||
"@esbuild/openharmony-arm64": "0.28.1",
|
||||
"@esbuild/sunos-x64": "0.28.1",
|
||||
"@esbuild/win32-arm64": "0.28.1",
|
||||
"@esbuild/win32-ia32": "0.28.1",
|
||||
"@esbuild/win32-x64": "0.28.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jquery": {
|
||||
"version": "3.7.1",
|
||||
"resolved": "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz",
|
||||
"integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/popper.js": {
|
||||
"version": "1.16.1",
|
||||
"resolved": "https://registry.npmjs.org/popper.js/-/popper.js-1.16.1.tgz",
|
||||
"integrity": "sha512-Wb4p1J4zyFTbM+u6WuO4XstYx4Ky9Cewe4DWrel7B0w6VVICvPwdOpotjzcf6eD8TsckVnIMNONQyPIUFOUbCQ==",
|
||||
"deprecated": "You can find the new Popper v2 at @popperjs/core, this package is dedicated to the legacy v1",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "2.8.8",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
|
||||
"integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"prettier": "bin-prettier.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map": {
|
||||
"version": "0.6.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
|
||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "2.7.16",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz",
|
||||
"integrity": "sha512-4gCtFXaAA3zYZdTp5s4Hl2sozuySsgz4jy1EnpBHNfpMa9dK1ZCG7viqBPCwXtmgc8nHqUsAu3G4gtmXkkY3Sw==",
|
||||
"deprecated": "Vue 2 has reached EOL and is no longer actively maintained. See https://v2.vuejs.org/eol/ for more details.",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vue/compiler-sfc": "2.7.16",
|
||||
"csstype": "^3.1.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
package.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"scripts": {
|
||||
"build": "esbuild src/frontend/js/main.ts src/frontend/css/app.css --bundle --outdir=src/assets/static --entry-names=bundle"
|
||||
},
|
||||
"devDependencies": {
|
||||
"esbuild": "0.28.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluent/bundle": "^0.19.1",
|
||||
"bootstrap": "4.5",
|
||||
"vue": "^2.7.16"
|
||||
}
|
||||
}
|
||||
@@ -1,61 +1,19 @@
|
||||
//go:build debug
|
||||
|
||||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
type assetsfs struct {
|
||||
embedded *embed.FS
|
||||
templates map[string]*template.Template
|
||||
const rootDir = "src/assets"
|
||||
|
||||
func Templates() *template.Template {
|
||||
return template.Must(template.ParseGlob(rootDir + "/templates/*.html"))
|
||||
}
|
||||
|
||||
var FS assetsfs
|
||||
|
||||
func (afs assetsfs) Open(name string) (fs.File, error) {
|
||||
if afs.embedded != nil {
|
||||
return afs.embedded.Open(name)
|
||||
}
|
||||
return os.DirFS("src/assets").Open(name)
|
||||
}
|
||||
|
||||
func Template(path string) *template.Template {
|
||||
var tmpl *template.Template
|
||||
tmpl, found := FS.templates[path]
|
||||
if !found {
|
||||
tmpl = template.Must(template.New(path).Delims("{%", "%}").Funcs(template.FuncMap{
|
||||
"inline": func(svg string) template.HTML {
|
||||
svgfile, err := FS.Open("graphicarts/" + svg)
|
||||
// should never happen
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer svgfile.Close()
|
||||
|
||||
content, err := io.ReadAll(svgfile)
|
||||
// should never happen
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return template.HTML(content)
|
||||
},
|
||||
}).ParseFS(FS, path))
|
||||
if FS.embedded != nil {
|
||||
FS.templates[path] = tmpl
|
||||
}
|
||||
}
|
||||
return tmpl
|
||||
}
|
||||
|
||||
func Render(path string, writer io.Writer, data any) {
|
||||
tmpl := Template(path)
|
||||
tmpl.Execute(writer, data)
|
||||
}
|
||||
|
||||
func init() {
|
||||
FS.templates = make(map[string]*template.Template)
|
||||
func StaticFS() fs.FS {
|
||||
return os.DirFS(rootDir + "/static")
|
||||
}
|
||||
|
||||
@@ -2,14 +2,22 @@
|
||||
|
||||
package assets
|
||||
|
||||
import "embed"
|
||||
import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed *.html
|
||||
//go:embed graphicarts
|
||||
//go:embed javascripts
|
||||
//go:embed stylesheets
|
||||
var embedded embed.FS
|
||||
//go:embed static/*
|
||||
var static embed.FS
|
||||
|
||||
func init() {
|
||||
FS.embedded = &embedded
|
||||
//go:embed templates
|
||||
var templates embed.FS
|
||||
|
||||
func Templates() *template.Template {
|
||||
return template.Must(template.ParseFS(templates, "*.html"))
|
||||
}
|
||||
|
||||
func StaticFS() fs.FS {
|
||||
return static
|
||||
}
|
||||
|
||||
@@ -1,465 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>yarr!</title>
|
||||
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="./static/stylesheets/app.css">
|
||||
<link rel="icon" href="./static/graphicarts/favicon.svg" type="image/svg+xml">
|
||||
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
|
||||
<link rel="manifest" href="./manifest.json" />
|
||||
<meta name="theme-color" content="" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<script>
|
||||
window.app = window.app || {}
|
||||
window.app.settings = {% .settings %}
|
||||
window.app.authenticated = {% .authenticated %}
|
||||
</script>
|
||||
</head>
|
||||
<body class="theme-{% .settings.theme_name %}">
|
||||
<div id="app" class="d-flex" :class="{'feed-selected': feedSelected !== null, 'item-selected': itemSelected !== null}" v-cloak>
|
||||
<!-- feed list -->
|
||||
<div id="col-feed-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: feedListWidth+'px'}">
|
||||
<drag :width="feedListWidth" @resize="resizeFeedList"></drag>
|
||||
<div class="p-2 toolbar d-flex align-items-center">
|
||||
<div class="icon mx-2">{% inline "anchor.svg" %}</div>
|
||||
<div class="flex-grow-1"></div>
|
||||
<button class="toolbar-item ml-1"
|
||||
:class="{active: filterSelected == 'unread'}"
|
||||
:aria-pressed="filterSelected == 'unread'"
|
||||
:title="$t('unread')"
|
||||
@click="filterSelected = 'unread'">
|
||||
<span class="icon">{% inline "circle-full.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item mx-1"
|
||||
:class="{active: filterSelected == 'starred'}"
|
||||
:aria-pressed="filterSelected == 'starred'"
|
||||
:title="$t('starred')"
|
||||
@click="filterSelected = 'starred'">
|
||||
<span class="icon">{% inline "star-full.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item mr-1"
|
||||
:class="{active: filterSelected == ''}"
|
||||
:aria-pressed="filterSelected == ''"
|
||||
:title="$t('all')"
|
||||
@click="filterSelected = ''">
|
||||
<span class="icon">{% inline "assorted.svg" %}</span>
|
||||
</button>
|
||||
<div class="flex-grow-1"></div>
|
||||
<dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" :title="$t('settings')">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</template>
|
||||
|
||||
<button class="dropdown-item" @click="showSettings('create')">
|
||||
<span class="icon mr-1">{% inline "plus.svg" %}</span>
|
||||
{{ $t('new_feed') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" @click="fetchAllFeeds()">
|
||||
<span class="icon mr-1">{% inline "rotate-cw.svg" %}</span>
|
||||
{{ $t('refresh_feeds') }}
|
||||
</button>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('theme') }}</header>
|
||||
<div class="row text-center m-0">
|
||||
<button class="btn btn-link col-4 px-0 rounded-0"
|
||||
:class="'theme-'+t"
|
||||
:title="t"
|
||||
:aria-label="t"
|
||||
:aria-pressed="theme.name == t"
|
||||
@click.stop="theme.name = t"
|
||||
v-for="t in ['light', 'sepia', 'night']">
|
||||
<span class="icon" v-if="theme.name == t">{% inline "check.svg" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('auto_refresh') }}</header>
|
||||
<div class="row text-center m-0">
|
||||
<button class="dropdown-item col-4 px-0"
|
||||
@click.stop="changeRefreshRate(-1)"
|
||||
:disabled="!refreshRate">
|
||||
<span class="icon">
|
||||
{% inline "chevron-down.svg" %}
|
||||
</span>
|
||||
</button>
|
||||
<div class="col-4 d-flex align-items-center justify-content-center">{{ refreshRateTitle }}</div>
|
||||
<button class="dropdown-item col-4 px-0"
|
||||
@click.stop="changeRefreshRate(1)" :disabled="refreshRate === refreshRateOptions.at(-1).value">
|
||||
<span class="icon">
|
||||
{% inline "chevron-up.svg" %}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('show_first') }}</header>
|
||||
<div class="d-flex text-center">
|
||||
<button class="dropdown-item px-0" :aria-pressed="itemSortNewestFirst" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">{{ $t('new') }}</button>
|
||||
<button class="dropdown-item px-0" :aria-pressed="!itemSortNewestFirst" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">{{ $t('old') }}</button>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('subscriptions') }}</header>
|
||||
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
|
||||
<input type="file"
|
||||
id="opml-import"
|
||||
@change="importOPML"
|
||||
name="opml"
|
||||
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
|
||||
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
|
||||
<span class="icon mr-1">{% inline "download.svg" %}</span>
|
||||
{{ $t('import') }}
|
||||
</label>
|
||||
</form>
|
||||
<a class="dropdown-item" href="./opml/export">
|
||||
<span class="icon mr-1">{% inline "upload.svg" %}</span>
|
||||
{{ $t('export') }}
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" @click="showSettings('shortcuts')">
|
||||
<span class="icon mr-1">{% inline "help-circle.svg" %}</span>
|
||||
{{ $t('shortcuts') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">A / あ / 文</header>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<button
|
||||
v-for="lang in languages"
|
||||
class="dropdown-item text-center col-3 px-0"
|
||||
:aria-label="lang.name"
|
||||
:title="lang.name"
|
||||
:class="{active: language==lang.code}"
|
||||
@click.stop="changeLanguage(lang.code)">
|
||||
{{ lang.code }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-divider" v-if="authenticated"></div>
|
||||
<button class="dropdown-item" v-if="authenticated" @click="logout()">
|
||||
<span class="icon mr-1">{% inline "log-out.svg" %}</span>
|
||||
{{ $t('log_out') }}
|
||||
</button>
|
||||
</dropdown>
|
||||
</div>
|
||||
<div id="feed-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1">
|
||||
<label class="selectgroup">
|
||||
<input type="radio" name="feed" value="" v-model="feedSelected">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||
<span class="icon mr-2">{% inline "layers.svg" %}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">{{ $t('all_unread') }}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">{{ $t('all_starred') }}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">{{ $t('all_feeds') }}</span>
|
||||
<span class="counter text-right">{{ filteredTotalStats }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div v-for="folder in foldersWithFeeds">
|
||||
<label class="selectgroup mt-1"
|
||||
:class="{'d-none': mustHideFolder(folder)}"
|
||||
v-if="folder.id">
|
||||
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
|
||||
<span class="icon mr-2"
|
||||
:class="{expanded: folder.is_expanded}"
|
||||
@click.prevent="toggleFolderExpanded(folder)">
|
||||
{% inline "chevron-right.svg" %}
|
||||
</span>
|
||||
<span class="flex-fill text-left text-truncate">{{ folder.title }}</span>
|
||||
<span class="counter text-right">{{ filteredFolderStats[folder.id] || '' }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
|
||||
<label class="selectgroup"
|
||||
:class="{'d-none': mustHideFeed(feed)}"
|
||||
v-for="feed in folder.feeds">
|
||||
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||
<span class="icon mr-2" v-if="!feed.has_icon">{% inline "rss.svg" %}</span>
|
||||
<span class="icon mr-2" v-else><img :src="'./api/feeds/'+feed.id+'/icon'" alt="" loading="lazy"></span>
|
||||
<span class="flex-fill text-left text-truncate">{{ feed.title }}</span>
|
||||
<span class="counter text-right">{{ filteredFeedStats[feed.id] || '' }}</span>
|
||||
<span class="icon flex-shrink-0 mx-2"
|
||||
:title="feed_errors[feed.id]"
|
||||
v-if="!filterSelected && feed_errors[feed.id]">
|
||||
{% inline "alert-circle.svg" %}
|
||||
</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
|
||||
<span class="icon loading mx-2"></span>
|
||||
<span class="text-truncate cursor-default noselect">{{ $t('refreshing_progress', {count: loading.feeds}) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- item list -->
|
||||
<div id="col-item-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: itemListWidth+'px'}">
|
||||
<drag :width="itemListWidth" @resize="resizeItemList"></drag>
|
||||
<div class="px-2 toolbar d-flex align-items-center">
|
||||
<button class="toolbar-item mr-2 d-block d-md-none"
|
||||
@click="feedSelected = null"
|
||||
:title="$t('show_feeds')">
|
||||
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||
</button>
|
||||
<div class="input-icon flex-grow-1">
|
||||
<span class="icon">{% inline "search.svg" %}</span>
|
||||
<!-- id used by keybindings -->
|
||||
<input id="searchbar" type="" class="d-block toolbar-search" v-model="itemSearch" @keydown.enter="$event.target.blur()">
|
||||
</div>
|
||||
<button class="toolbar-item ml-2"
|
||||
@click="markItemsRead()"
|
||||
v-if="filterSelected == 'unread'"
|
||||
:title="$t('mark_all_read')">
|
||||
<span class="icon">{% inline "check.svg" %}</span>
|
||||
</button>
|
||||
|
||||
|
||||
<button class="btn btn-link toolbar-item px-2 ml-2" v-if="!current.type" disabled>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</button>
|
||||
<dropdown class="settings-dropdown"
|
||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||
drop="right"
|
||||
:title="$t('feed_settings')"
|
||||
v-if="current.type == 'feed'">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</template>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.feed.title }}</header>
|
||||
<a class="dropdown-item" :href="current.feed.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.link">
|
||||
<span class="icon mr-1">{% inline "globe.svg" %}</span>
|
||||
{{ $t('website') }}
|
||||
</a>
|
||||
<a class="dropdown-item" :href="current.feed.feed_link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.feed_link">
|
||||
<span class="icon mr-1">{% inline "rss.svg" %}</span>
|
||||
{{ $t('feed_link') }}
|
||||
</a>
|
||||
<div class="dropdown-divider" v-if="current.feed.link || current.feed.feed_link"></div>
|
||||
<button class="dropdown-item" @click="renameFeed(current.feed)">
|
||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||
{{ $t('rename') }}
|
||||
</button>
|
||||
<button class="dropdown-item" @click="updateFeedLink(current.feed)" v-if="current.feed.feed_link">
|
||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||
{{ $t('change_link') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('move_to') }}</header>
|
||||
<button class="dropdown-item"
|
||||
v-if="folder.id != current.feed.folder_id"
|
||||
v-for="folder in folders"
|
||||
@click="moveFeed(current.feed, folder)">
|
||||
<span class="icon mr-1">{% inline "folder.svg" %}</span>
|
||||
{{ folder.title }}
|
||||
</button>
|
||||
<button class="dropdown-item text-muted" @click="moveFeed(current.feed, null)" v-if="current.feed.folder_id">
|
||||
<span class="icon mr-1">{% inline "folder-minus.svg" %}</span>
|
||||
──
|
||||
</button>
|
||||
<button class="dropdown-item text-muted" @click="moveFeedToNewFolder(current.feed)">
|
||||
<span class="icon mr-1">{% inline "folder-plus.svg" %}</span>
|
||||
{{ $t('new_folder') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @click.prevent="deleteFeed(current.feed)">
|
||||
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
||||
{{ $t('delete') }}
|
||||
</button>
|
||||
</dropdown>
|
||||
<dropdown class="settings-dropdown"
|
||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||
:title="$t('folder_settings')"
|
||||
drop="right"
|
||||
v-if="current.type == 'folder'">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "more-horizontal.svg" %}</span>
|
||||
</template>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.folder.title }}</header>
|
||||
<button class="dropdown-item" @click="renameFolder(current.folder)">
|
||||
<span class="icon mr-1">{% inline "edit.svg" %}</span>
|
||||
{{ $t('rename') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @click="deleteFolder(current.folder)">
|
||||
<span class="icon mr-1">{% inline "trash.svg" %}</span>
|
||||
{{ $t('delete') }}
|
||||
</button>
|
||||
</dropdown>
|
||||
</div>
|
||||
<div id="item-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
|
||||
<label v-for="item in items" :key="item.id"
|
||||
class="selectgroup">
|
||||
<input type="radio" name="item" :value="item.id" v-model="itemSelected">
|
||||
<div class="selectgroup-label d-flex flex-column">
|
||||
<div style="line-height: 100%; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
|
||||
<transition name="indicator">
|
||||
<span class="icon icon-small mr-1" v-if="item.status=='unread'">{% inline "circle-full.svg" %}</span>
|
||||
<span class="icon icon-small mr-1" v-if="item.status=='starred'">{% inline "star-full.svg" %}</span>
|
||||
</transition>
|
||||
<small class="flex-fill text-truncate mr-1">
|
||||
{{ (feedsById[item.feed_id] || {}).title }}
|
||||
</small>
|
||||
<small class="flex-shrink-0"><relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
|
||||
</div>
|
||||
<div>{{ item.title || $t('untitled') }}</div>
|
||||
</div>
|
||||
</label>
|
||||
<button class="btn btn-link btn-block loading my-3" v-if="itemsHasMore"></button>
|
||||
</div>
|
||||
<div class="px-3 py-2 border-top text-danger text-break" v-if="feed_errors[current.feed.id]">
|
||||
{{ feed_errors[current.feed.id] }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- item show -->
|
||||
<div id="col-item" class="vh-100 d-flex flex-column w-100" style="min-width: 0;">
|
||||
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
|
||||
<button class="toolbar-item"
|
||||
@click="toggleItemStarred(itemSelectedDetails)"
|
||||
:title="$t('mark_starred')">
|
||||
<span class="icon" v-if="itemSelectedDetails.status=='starred'" >{% inline "star-full.svg" %}</span>
|
||||
<span class="icon" v-else-if="itemSelectedDetails.status!='starred'" >{% inline "star.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item"
|
||||
:title="$t('mark_unread')"
|
||||
@click="toggleItemRead(itemSelectedDetails)">
|
||||
<span class="icon" v-if="itemSelectedDetails.status=='unread'">{% inline "circle-full.svg" %}</span>
|
||||
<span class="icon" v-if="itemSelectedDetails.status!='unread'">{% inline "circle.svg" %}</span>
|
||||
</button>
|
||||
<dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" :title="$t('appearance')">
|
||||
<template v-slot:button>
|
||||
<span class="icon">{% inline "sliders.svg" %}</span>
|
||||
</template>
|
||||
|
||||
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">{{ $t('sans_serif') }}</button>
|
||||
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">{{ $t('serif') }}</button>
|
||||
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">{{ $t('monospace') }}</button>
|
||||
|
||||
<div class="d-flex text-center">
|
||||
<button class="dropdown-item" style="font-size: 0.8rem" @click.stop="incrFont(-1)">A</button>
|
||||
<button class="dropdown-item" style="font-size: 1.2rem" @click.stop="incrFont(1)">A</button>
|
||||
</div>
|
||||
</dropdown>
|
||||
<button class="toolbar-item"
|
||||
:class="{active: itemSelectedReadability}"
|
||||
@click="toggleReadability()"
|
||||
:title="$t('read_here')">
|
||||
<span class="icon" :class="{'icon-loading': loading.readability}">{% inline "book-open.svg" %}</span>
|
||||
</button>
|
||||
<a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" :title="$t('open_link')">
|
||||
<span class="icon">{% inline "external-link.svg" %}</span>
|
||||
</a>
|
||||
<div class="flex-grow-1"></div>
|
||||
<button class="toolbar-item" @click="navigateToItem(-1)" :title="$t('previous_article')" :disabled="!items.length || itemSelected == items[0].id">
|
||||
<span class="icon">{% inline "chevron-left.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item" @click="navigateToItem(+1)" :title="$t('next_article')" :disabled="!items.length || itemSelected == items[items.length - 1].id">
|
||||
<span class="icon">{% inline "chevron-right.svg" %}</span>
|
||||
</button>
|
||||
<button class="toolbar-item" @click="itemSelected=null" :title="$t('close_article')">
|
||||
<span class="icon">{% inline "x.svg" %}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="itemSelectedDetails"
|
||||
ref="content"
|
||||
class="content px-4 pt-3 pb-5 border-top overflow-auto scroll-touch"
|
||||
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
|
||||
:style="{'font-size': theme.size + 'rem'}">
|
||||
<div class="content-wrapper">
|
||||
<h1><b>{{ itemSelectedDetails.title || $t('untitled') }}</b></h1>
|
||||
<div class="text-muted">
|
||||
<div>
|
||||
<span class="cursor-pointer" @click="feedSelected = 'feed:'+(feedsById[itemSelectedDetails.feed_id] || {}).id">
|
||||
{{ (feedsById[itemSelectedDetails.feed_id] || {}).title }}
|
||||
</span>
|
||||
</div>
|
||||
<time>{{ formatDate(itemSelectedDetails.date) }}</time>
|
||||
</div>
|
||||
<hr>
|
||||
<div v-if="!itemSelectedReadability">
|
||||
<div v-if="contentImages.length">
|
||||
<figure v-for="media in contentImages">
|
||||
<img :src="media.url" loading="lazy">
|
||||
<figcaption v-if="media.description">{{ media.description }}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<audio class="w-100" controls v-for="media in contentAudios" :src="media.url"></audio>
|
||||
<video class="w-100" controls v-for="media in contentVideos" :src="media.url"></video>
|
||||
</div>
|
||||
<div v-html="itemSelectedContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<modal :open="!!settings" @hide="settings = ''">
|
||||
<button class="btn btn-link outline-none float-right p-2 mr-n2 mt-n2" style="line-height: 1" @click="settings = ''">
|
||||
<span class="icon">{% inline "x.svg" %}</span>
|
||||
</button>
|
||||
<div v-if="settings=='create'">
|
||||
<p class="cursor-default"><b>{{ $t('new_feed') }}</b></p>
|
||||
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
|
||||
<label for="feed-url">{{ $t('url') }}</label>
|
||||
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0" placeholder="https://example.com/feed" v-focus>
|
||||
<label for="feed-folder" class="mt-3 d-block">
|
||||
{{ $t('folder') }}
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">{{ $t('new_folder') }}</a>
|
||||
</label>
|
||||
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
|
||||
<option value="">---</option>
|
||||
<option :value="folder.id" v-for="folder in folders" :selected="folder.id === current.feed.folder_id || folder.id === current.folder.id">{{ folder.title }}</option>
|
||||
</select>
|
||||
<div class="mt-4" v-if="feedNewChoice.length">
|
||||
<p class="mb-2">
|
||||
{{ $t('multiple_feeds_found') }}
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">{{ $t('cancel') }}</a>
|
||||
</p>
|
||||
<label class="selectgroup" v-for="choice in feedNewChoice">
|
||||
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
|
||||
<div class="selectgroup-label">
|
||||
<div class="text-truncate">{{ choice.title }}</div>
|
||||
<div class="text-truncate" :class="{light: choice.title}">{{ choice.url }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">{{ $t('add') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else-if="settings=='shortcuts'">
|
||||
<p class="cursor-default"><b>{{ $t('keyboard_shortcuts') }}</b></p>
|
||||
|
||||
<table class="table table-borderless table-sm table-compact m-0">
|
||||
<tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td>
|
||||
<td>{{ $t('kb_show_filters') }}</td></tr>
|
||||
<tr><td><kbd>/</kbd></td> <td>{{ $t('kb_focus_search') }}</td></tr>
|
||||
|
||||
<tr><td colspan=2> </td></tr>
|
||||
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>{{ $t('kb_next_prev_article') }}</td></tr>
|
||||
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>{{ $t('kb_next_prev_feed') }}</td></tr>
|
||||
<tr><td><kbd>q</kbd></td> <td>{{ $t('kb_close_article') }}</td></tr>
|
||||
|
||||
<tr><td colspan=2> </td></tr>
|
||||
<tr><td><kbd>R</kbd></td> <td>{{ $t('kb_mark_all_read') }}</td></tr>
|
||||
<tr><td><kbd>r</kbd></td> <td>{{ $t('kb_mark_read') }}</td></tr>
|
||||
<tr><td><kbd>s</kbd></td> <td>{{ $t('kb_mark_starred') }}</td></tr>
|
||||
<tr><td><kbd>o</kbd></td> <td>{{ $t('kb_open_link') }}</td></tr>
|
||||
<tr><td><kbd>i</kbd></td> <td>{{ $t('kb_read_here') }}</td> </tr>
|
||||
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>{{ $t('kb_scroll_content') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</modal>
|
||||
</div>
|
||||
<!-- external -->
|
||||
<script src="./static/javascripts/vue.min.js"></script>
|
||||
<script src="./static/javascripts/fluent.js"></script>
|
||||
<!-- internal -->
|
||||
<script src="./static/javascripts/i18n.js"></script>
|
||||
<script src="./static/javascripts/api.js"></script>
|
||||
<script src="./static/javascripts/app.js"></script>
|
||||
<script src="./static/javascripts/key.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
6
src/assets/javascripts/vue.min.js
vendored
@@ -1,56 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>yarr!</title>
|
||||
<link rel="stylesheet" href="./static/stylesheets/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="./static/stylesheets/app.css">
|
||||
<link rel="icon" href="./static/graphicarts/favicon.svg" type="image/svg+xml">
|
||||
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<style>
|
||||
[v-cloak] { display: none }
|
||||
form {
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
form img {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
display: block;
|
||||
margin: 3rem auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="theme-{% .settings.theme_name %}">
|
||||
<div id="app" v-cloak>
|
||||
<form action="" method="post">
|
||||
<img src="./static/graphicarts/anchor.svg" alt="">
|
||||
<div class="text-danger text-center my-3" v-if="hasError">{{ $t('login_error') }}</div>
|
||||
<div class="form-group">
|
||||
<label for="username">{{ $t('username') }}</label>
|
||||
<input name="username" class="form-control" id="username" autocomplete="off"
|
||||
value="{% if .username %}{% .username %}{% end %}" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">{{ $t('password') }}</label>
|
||||
<input name="password" class="form-control" id="password" type="password" required>
|
||||
</div>
|
||||
<button class="btn btn-block btn-default" type="submit">{{ $t('login') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
<script src="./static/javascripts/vue.min.js"></script>
|
||||
<script src="./static/javascripts/fluent.js"></script>
|
||||
<script src="./static/javascripts/i18n.js"></script>
|
||||
<script>
|
||||
Vue.use(i18n)
|
||||
new Vue({
|
||||
data: { hasError: {% .hasError %} },
|
||||
created: function () {
|
||||
this.$setLang('{% .settings.language %}')
|
||||
}
|
||||
}).$mount('#app')
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
2
src/assets/static/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
bundle.js
|
||||
bundle.css
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 603 B After Width: | Height: | Size: 603 B |
6
src/assets/stylesheets/bootstrap.min.css
vendored
22
src/assets/templates/index.html
Normal file
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>yarr!</title>
|
||||
<link rel="stylesheet" href="./static/bundle.css">
|
||||
<link rel="icon" href="./static/graphicarts/favicon.svg" type="image/svg+xml">
|
||||
<link rel="alternate icon" href="./static/graphicarts/favicon.png" type="image/png">
|
||||
<link rel="manifest" href="./manifest.json" />
|
||||
<meta name="theme-color" content="" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
|
||||
<script>
|
||||
window.app = window.app || {}
|
||||
window.app.settings = {{ .settings }};
|
||||
window.app.authenticated = {{ .authenticated }};
|
||||
</script>
|
||||
</head>
|
||||
<body class="theme-{{ .settings.theme_name }}">
|
||||
<div id="app" v-cloak></div>
|
||||
<script src="./static/bundle.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,3 +1,5 @@
|
||||
@import "bootstrap/dist/css/bootstrap.min.css";
|
||||
|
||||
[v-cloak] {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -609,3 +611,15 @@ a,
|
||||
padding: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.login-page {
|
||||
max-width: 300px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
.login-page img {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
display: block;
|
||||
margin: 3rem auto;
|
||||
}
|
||||
|
Before Width: | Height: | Size: 356 B After Width: | Height: | Size: 356 B |
|
Before Width: | Height: | Size: 345 B After Width: | Height: | Size: 345 B |
|
Before Width: | Height: | Size: 353 B After Width: | Height: | Size: 353 B |
|
Before Width: | Height: | Size: 339 B After Width: | Height: | Size: 339 B |
|
Before Width: | Height: | Size: 262 B After Width: | Height: | Size: 262 B |
|
Before Width: | Height: | Size: 269 B After Width: | Height: | Size: 269 B |
|
Before Width: | Height: | Size: 270 B After Width: | Height: | Size: 270 B |
|
Before Width: | Height: | Size: 270 B After Width: | Height: | Size: 270 B |
|
Before Width: | Height: | Size: 268 B After Width: | Height: | Size: 268 B |
|
Before Width: | Height: | Size: 267 B After Width: | Height: | Size: 267 B |
|
Before Width: | Height: | Size: 258 B After Width: | Height: | Size: 258 B |
|
Before Width: | Height: | Size: 370 B After Width: | Height: | Size: 370 B |
|
Before Width: | Height: | Size: 365 B After Width: | Height: | Size: 365 B |
|
Before Width: | Height: | Size: 388 B After Width: | Height: | Size: 388 B |
|
Before Width: | Height: | Size: 361 B After Width: | Height: | Size: 361 B |
|
Before Width: | Height: | Size: 405 B After Width: | Height: | Size: 405 B |
|
Before Width: | Height: | Size: 311 B After Width: | Height: | Size: 311 B |
|
Before Width: | Height: | Size: 409 B After Width: | Height: | Size: 409 B |
|
Before Width: | Height: | Size: 365 B After Width: | Height: | Size: 365 B |
|
Before Width: | Height: | Size: 365 B After Width: | Height: | Size: 365 B |
|
Before Width: | Height: | Size: 367 B After Width: | Height: | Size: 367 B |
|
Before Width: | Height: | Size: 343 B After Width: | Height: | Size: 343 B |
|
Before Width: | Height: | Size: 304 B After Width: | Height: | Size: 304 B |
|
Before Width: | Height: | Size: 321 B After Width: | Height: | Size: 321 B |
|
Before Width: | Height: | Size: 330 B After Width: | Height: | Size: 330 B |
|
Before Width: | Height: | Size: 308 B After Width: | Height: | Size: 308 B |
|
Before Width: | Height: | Size: 611 B After Width: | Height: | Size: 611 B |
|
Before Width: | Height: | Size: 348 B After Width: | Height: | Size: 348 B |
|
Before Width: | Height: | Size: 339 B After Width: | Height: | Size: 339 B |
|
Before Width: | Height: | Size: 356 B After Width: | Height: | Size: 356 B |
|
Before Width: | Height: | Size: 365 B After Width: | Height: | Size: 365 B |
|
Before Width: | Height: | Size: 299 B After Width: | Height: | Size: 299 B |
@@ -1,6 +1,4 @@
|
||||
"use strict";
|
||||
|
||||
(function() {
|
||||
var xfetch = function(resource, init) {
|
||||
init = init || {}
|
||||
if (['post', 'put', 'delete'].indexOf(init.method) !== -1) {
|
||||
@@ -29,7 +27,7 @@
|
||||
}).join('&')
|
||||
}
|
||||
|
||||
window.api = {
|
||||
export default {
|
||||
feeds: {
|
||||
list: function() {
|
||||
return api('get', './api/feeds').then(json)
|
||||
@@ -108,4 +106,3 @@
|
||||
return api('get', './page?url=' + encodeURIComponent(url)).then(json)
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -1,211 +1,38 @@
|
||||
'use strict';
|
||||
import Vue from 'vue/dist/vue.esm.js'
|
||||
import i18n from './i18n'
|
||||
import api from './api'
|
||||
import template from './templates/index.html' with {type: 'text'}
|
||||
import icons from './icons'
|
||||
import { setupKeybindings } from './key'
|
||||
import { scrollto, debounce, dateRepr } from './utils'
|
||||
import drag from './components/drag'
|
||||
import dropdown from './components/dropdown'
|
||||
import modal from './components/modal'
|
||||
import relativeTime from './components/relative-time'
|
||||
import icon from './components/icon'
|
||||
import scrollDir from './directives/scroll'
|
||||
import focusDir from './directives/focus'
|
||||
|
||||
var app = window.app
|
||||
var vm
|
||||
|
||||
var TITLE = document.title
|
||||
|
||||
function scrollto(target, scroll) {
|
||||
var padding = 10
|
||||
var targetRect = target.getBoundingClientRect()
|
||||
var scrollRect = scroll.getBoundingClientRect()
|
||||
|
||||
// target
|
||||
var relativeOffset = targetRect.y - scrollRect.y
|
||||
var absoluteOffset = relativeOffset + scroll.scrollTop
|
||||
|
||||
if (padding <= relativeOffset && relativeOffset + targetRect.height <= scrollRect.height - padding) return
|
||||
|
||||
var newPos = scroll.scrollTop
|
||||
if (relativeOffset < padding) {
|
||||
newPos = absoluteOffset - padding
|
||||
} else {
|
||||
newPos = absoluteOffset - scrollRect.height + targetRect.height + padding
|
||||
}
|
||||
scroll.scrollTop = Math.round(newPos)
|
||||
}
|
||||
|
||||
var debounce = function(callback, wait) {
|
||||
var timeout
|
||||
return function() {
|
||||
var ctx = this, args = arguments
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(function() {
|
||||
callback.apply(ctx, args)
|
||||
}, wait)
|
||||
}
|
||||
}
|
||||
|
||||
Vue.directive('scroll', {
|
||||
inserted: function(el, binding) {
|
||||
el.addEventListener('scroll', debounce(function(event) {
|
||||
binding.value(event, el)
|
||||
}, 200))
|
||||
export default {
|
||||
template: template,
|
||||
components: {
|
||||
'v-drag': drag,
|
||||
'v-dropdown': dropdown,
|
||||
'v-modal': modal,
|
||||
'v-relative-time': relativeTime,
|
||||
'v-icon': icon,
|
||||
},
|
||||
})
|
||||
|
||||
Vue.directive('focus', {
|
||||
inserted: function(el) {
|
||||
el.focus()
|
||||
}
|
||||
})
|
||||
|
||||
Vue.component('drag', {
|
||||
props: ['width'],
|
||||
template: '<div class="drag"></div>',
|
||||
mounted: function() {
|
||||
var self = this
|
||||
var startX = undefined
|
||||
var initW = undefined
|
||||
var onMouseMove = function(e) {
|
||||
var offset = e.clientX - startX
|
||||
var newWidth = initW + offset
|
||||
self.$emit('resize', newWidth)
|
||||
}
|
||||
var onMouseUp = function(e) {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
this.$el.addEventListener('mousedown', function(e) {
|
||||
startX = e.clientX
|
||||
initW = self.width
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
})
|
||||
directives: {
|
||||
scroll: scrollDir,
|
||||
focus: focusDir,
|
||||
},
|
||||
})
|
||||
|
||||
Vue.component('dropdown', {
|
||||
props: ['class', 'toggle-class', 'ref', 'drop', 'title'],
|
||||
data: function() {
|
||||
return {open: false}
|
||||
},
|
||||
template: `
|
||||
<div class="dropdown" :class="$attrs.class">
|
||||
<button ref="btn" @click="toggle" :class="btnToggleClass" :title="$props.title"><slot name="button"></slot></button>
|
||||
<div ref="menu" class="dropdown-menu" :class="{show: open}"><slot v-if="open"></slot></div>
|
||||
</div>
|
||||
`,
|
||||
computed: {
|
||||
btnToggleClass: function() {
|
||||
var c = this.$props.toggleClass || ''
|
||||
c += ' dropdown-toggle dropdown-toggle-no-caret'
|
||||
c += this.open ? ' show' : ''
|
||||
return c.trim()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggle: function(e) {
|
||||
this.open ? this.hide() : this.show()
|
||||
},
|
||||
show: function(e) {
|
||||
this.open = true
|
||||
this.$refs.menu.style.top = this.$refs.btn.offsetHeight + 'px'
|
||||
var drop = this.$props.drop
|
||||
|
||||
if (drop === 'right') {
|
||||
this.$refs.menu.style.left = 'auto'
|
||||
this.$refs.menu.style.right = '0'
|
||||
} else if (drop === 'center') {
|
||||
this.$nextTick(function() {
|
||||
var btnWidth = this.$refs.btn.getBoundingClientRect().width
|
||||
var menuWidth = this.$refs.menu.getBoundingClientRect().width
|
||||
this.$refs.menu.style.left = '-' + ((menuWidth - btnWidth) / 2) + 'px'
|
||||
}.bind(this))
|
||||
}
|
||||
|
||||
document.addEventListener('click', this.clickHandler)
|
||||
},
|
||||
hide: function() {
|
||||
this.open = false
|
||||
document.removeEventListener('click', this.clickHandler)
|
||||
},
|
||||
clickHandler: function(e) {
|
||||
var dropdown = e.target.closest('.dropdown')
|
||||
if (dropdown == null || dropdown != this.$el) return this.hide()
|
||||
if (e.target.closest('.dropdown-item') != null) return this.hide()
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
Vue.component('modal', {
|
||||
props: ['open'],
|
||||
template: `
|
||||
<div class="modal custom-modal" tabindex="-1" v-if="$props.open">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content" ref="content">
|
||||
<div class="modal-body">
|
||||
<slot v-if="$props.open"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
data: function() {
|
||||
return {opening: false}
|
||||
},
|
||||
watch: {
|
||||
'open': function(newVal) {
|
||||
if (newVal) {
|
||||
this.opening = true
|
||||
document.addEventListener('click', this.handleClick)
|
||||
} else {
|
||||
document.removeEventListener('click', this.handleClick)
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleClick: function(e) {
|
||||
if (this.opening) {
|
||||
this.opening = false
|
||||
return
|
||||
}
|
||||
if (e.target.closest('.modal-content') == null) this.$emit('hide')
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function dateRepr(d) {
|
||||
var sec = (new Date().getTime() - d.getTime()) / 1000
|
||||
var neg = sec < 0
|
||||
var out = ''
|
||||
|
||||
sec = Math.abs(sec)
|
||||
if (sec < 2700) // less than 45 minutes
|
||||
out = Math.round(sec / 60) + 'm'
|
||||
else if (sec < 86400) // less than 24 hours
|
||||
out = Math.round(sec / 3600) + 'h'
|
||||
else if (sec < 604800) // less than a week
|
||||
out = Math.round(sec / 86400) + 'd'
|
||||
else
|
||||
out = d.toLocaleDateString(undefined, {year: "numeric", month: "long", day: "numeric"})
|
||||
|
||||
if (neg) return '-' + out
|
||||
return out
|
||||
}
|
||||
|
||||
Vue.component('relative-time', {
|
||||
props: ['val'],
|
||||
data: function() {
|
||||
var d = new Date(this.val)
|
||||
return {
|
||||
'date': d,
|
||||
'formatted': dateRepr(d),
|
||||
'interval': null,
|
||||
}
|
||||
},
|
||||
template: '<time :datetime="val">{{ formatted }}</time>',
|
||||
mounted: function() {
|
||||
this.interval = setInterval(function() {
|
||||
this.formatted = dateRepr(this.date)
|
||||
}.bind(this), 600000) // every 10 minutes
|
||||
},
|
||||
destroyed: function() {
|
||||
clearInterval(this.interval)
|
||||
},
|
||||
})
|
||||
|
||||
Vue.use(i18n)
|
||||
|
||||
var vm = new Vue({
|
||||
created: function() {
|
||||
vm = this
|
||||
this.refreshStats()
|
||||
.then(this.refreshFeeds.bind(this))
|
||||
.then(this.refreshItems.bind(this, false))
|
||||
@@ -216,6 +43,9 @@ var vm = new Vue({
|
||||
this.updateMetaTheme(app.settings.theme_name)
|
||||
this.$setLang(app.settings.language)
|
||||
},
|
||||
mounted: function() {
|
||||
setupKeybindings(this)
|
||||
},
|
||||
data: function() {
|
||||
var s = app.settings
|
||||
return {
|
||||
@@ -541,7 +371,7 @@ var vm = new Vue({
|
||||
vm.refreshStats()
|
||||
})
|
||||
},
|
||||
toggleFolderExpanded: function(folder) {
|
||||
toggleFolderExpanded: function (folder) {
|
||||
folder.is_expanded = !folder.is_expanded
|
||||
api.folders.update(folder.id, {is_expanded: folder.is_expanded})
|
||||
},
|
||||
@@ -855,6 +685,4 @@ var vm = new Vue({
|
||||
api.settings.update({language: lang})
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vm.$mount('#app')
|
||||
}
|
||||
24
src/frontend/js/components/drag.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
export default {
|
||||
props: ['width'],
|
||||
template: '<div class="drag"></div>',
|
||||
mounted: function() {
|
||||
var self = this
|
||||
var startX = undefined
|
||||
var initW = undefined
|
||||
var onMouseMove = function(e) {
|
||||
var offset = e.clientX - startX
|
||||
var newWidth = initW + offset
|
||||
self.$emit('resize', newWidth)
|
||||
}
|
||||
var onMouseUp = function(e) {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
document.removeEventListener('mouseup', onMouseUp)
|
||||
}
|
||||
this.$el.addEventListener('mousedown', function(e) {
|
||||
startX = e.clientX
|
||||
initW = self.width
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', onMouseUp)
|
||||
})
|
||||
},
|
||||
}
|
||||
52
src/frontend/js/components/dropdown.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
export default {
|
||||
props: ['toggle-class', 'drop', 'title'],
|
||||
data: function() {
|
||||
return {open: false}
|
||||
},
|
||||
template: `
|
||||
<div class="dropdown" :class="$attrs.class">
|
||||
<button ref="btn" @click="toggle" :class="btnToggleClass" :title="$props.title"><slot name="button"></slot></button>
|
||||
<div ref="menu" class="dropdown-menu" :class="{show: open}"><slot v-if="open"></slot></div>
|
||||
</div>
|
||||
`,
|
||||
computed: {
|
||||
btnToggleClass: function() {
|
||||
var c = this.$props.toggleClass || ''
|
||||
c += ' dropdown-toggle dropdown-toggle-no-caret'
|
||||
c += this.open ? ' show' : ''
|
||||
return c.trim()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
toggle: function(e) {
|
||||
this.open ? this.hide() : this.show()
|
||||
},
|
||||
show: function(e) {
|
||||
this.open = true
|
||||
this.$refs.menu.style.top = this.$refs.btn.offsetHeight + 'px'
|
||||
var drop = this.$props.drop
|
||||
|
||||
if (drop === 'right') {
|
||||
this.$refs.menu.style.left = 'auto'
|
||||
this.$refs.menu.style.right = '0'
|
||||
} else if (drop === 'center') {
|
||||
this.$nextTick(function() {
|
||||
var btnWidth = this.$refs.btn.getBoundingClientRect().width
|
||||
var menuWidth = this.$refs.menu.getBoundingClientRect().width
|
||||
this.$refs.menu.style.left = '-' + ((menuWidth - btnWidth) / 2) + 'px'
|
||||
}.bind(this))
|
||||
}
|
||||
|
||||
document.addEventListener('click', this.clickHandler)
|
||||
},
|
||||
hide: function() {
|
||||
this.open = false
|
||||
document.removeEventListener('click', this.clickHandler)
|
||||
},
|
||||
clickHandler: function(e) {
|
||||
var dropdown = e.target.closest('.dropdown')
|
||||
if (dropdown == null || dropdown != this.$el) return this.hide()
|
||||
if (e.target.closest('.dropdown-item') != null) return this.hide()
|
||||
}
|
||||
},
|
||||
}
|
||||
9
src/frontend/js/components/icon.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import icons from '../icons'
|
||||
|
||||
export default {
|
||||
props: ['name'],
|
||||
template: '<span class="icon" v-html="content"></span>',
|
||||
computed: {
|
||||
content: function () { return icons[this.name] || '' }
|
||||
}
|
||||
}
|
||||
36
src/frontend/js/components/modal.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export default {
|
||||
props: ['open'],
|
||||
template: `
|
||||
<div class="modal custom-modal" tabindex="-1" v-if="$props.open">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content" ref="content">
|
||||
<div class="modal-body">
|
||||
<slot v-if="$props.open"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
data: function() {
|
||||
return {opening: false}
|
||||
},
|
||||
watch: {
|
||||
'open': function(newVal) {
|
||||
if (newVal) {
|
||||
this.opening = true
|
||||
document.addEventListener('click', this.handleClick)
|
||||
} else {
|
||||
document.removeEventListener('click', this.handleClick)
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
handleClick: function(e) {
|
||||
if (this.opening) {
|
||||
this.opening = false
|
||||
return
|
||||
}
|
||||
if (e.target.closest('.modal-content') == null) this.$emit('hide')
|
||||
},
|
||||
},
|
||||
}
|
||||
22
src/frontend/js/components/relative-time.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { dateRepr } from '../utils'
|
||||
|
||||
export default {
|
||||
props: ['val'],
|
||||
data: function() {
|
||||
var d = new Date(this.val)
|
||||
return {
|
||||
'date': d,
|
||||
'formatted': dateRepr(d),
|
||||
'interval': null,
|
||||
}
|
||||
},
|
||||
template: '<time :datetime="val">{{ formatted }}</time>',
|
||||
mounted: function() {
|
||||
this.interval = setInterval(function() {
|
||||
this.formatted = dateRepr(this.date)
|
||||
}.bind(this), 600000) // every 10 minutes
|
||||
},
|
||||
destroyed: function() {
|
||||
clearInterval(this.interval)
|
||||
},
|
||||
}
|
||||
5
src/frontend/js/directives/focus.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
inserted: function(el) {
|
||||
el.focus()
|
||||
}
|
||||
}
|
||||
9
src/frontend/js/directives/scroll.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { debounce } from '../utils'
|
||||
|
||||
export default {
|
||||
inserted: function(el, binding) {
|
||||
el.addEventListener('scroll', debounce(function(event) {
|
||||
binding.value(event, el)
|
||||
}, 200))
|
||||
},
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
(function (exports) {
|
||||
import { FluentResource, FluentBundle } from '@fluent/bundle'
|
||||
|
||||
const translations = {
|
||||
"unread": {
|
||||
"en": "Unread",
|
||||
@@ -691,26 +692,25 @@
|
||||
"ru": "Пароль"
|
||||
},
|
||||
};
|
||||
function ftlFrom(lang) {
|
||||
function ftlFrom(lang) {
|
||||
return Object.entries(translations)
|
||||
.map(([key, langs]) => `${key} = ${langs[lang]}`)
|
||||
.join('\n')
|
||||
}
|
||||
exports.i18n = {
|
||||
install(Vue) {
|
||||
let bundle = null
|
||||
Vue.prototype.$setLang = function (lang) {
|
||||
const ftl = ftlFrom(lang)
|
||||
const resource = new FluentBundle.FluentResource(ftl)
|
||||
bundle = new FluentBundle.FluentBundle(lang)
|
||||
bundle.addResource(resource)
|
||||
}
|
||||
Vue.prototype.$t = function (code, args) {
|
||||
if (!bundle) return
|
||||
const msg = bundle.getMessage(code)
|
||||
if (!msg || !msg.value) return
|
||||
return bundle.formatPattern(msg.value, args)
|
||||
}
|
||||
.map(([key, langs]) => `${key} = ${langs[lang]}`)
|
||||
.join('\n')
|
||||
}
|
||||
export default {
|
||||
install(Vue) {
|
||||
let bundle = null
|
||||
Vue.prototype.$setLang = function (lang) {
|
||||
const ftl = ftlFrom(lang)
|
||||
const resource = new FluentResource(ftl)
|
||||
bundle = new FluentBundle(lang)
|
||||
bundle.addResource(resource)
|
||||
}
|
||||
Vue.prototype.$t = function (code, args) {
|
||||
if (!bundle) return
|
||||
const msg = bundle.getMessage(code)
|
||||
if (!msg || !msg.value) return
|
||||
return bundle.formatPattern(msg.value, args)
|
||||
}
|
||||
}
|
||||
})(window)
|
||||
}
|
||||
67
src/frontend/js/icons.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import anchor from '../graphicarts/anchor.svg' with { type: 'text' }
|
||||
import alertCircle from '../graphicarts/alert-circle.svg' with { type: 'text' }
|
||||
import assorted from '../graphicarts/assorted.svg' with { type: 'text' }
|
||||
import bookOpen from '../graphicarts/book-open.svg' with { type: 'text' }
|
||||
import check from '../graphicarts/check.svg' with { type: 'text' }
|
||||
import chevronDown from '../graphicarts/chevron-down.svg' with { type: 'text' }
|
||||
import chevronLeft from '../graphicarts/chevron-left.svg' with { type: 'text' }
|
||||
import chevronRight from '../graphicarts/chevron-right.svg' with { type: 'text' }
|
||||
import chevronUp from '../graphicarts/chevron-up.svg' with { type: 'text' }
|
||||
import circle from '../graphicarts/circle.svg' with { type: 'text' }
|
||||
import circleFull from '../graphicarts/circle-full.svg' with { type: 'text' }
|
||||
import download from '../graphicarts/download.svg' with { type: 'text' }
|
||||
import edit from '../graphicarts/edit.svg' with { type: 'text' }
|
||||
import externalLink from '../graphicarts/external-link.svg' with { type: 'text' }
|
||||
import folder from '../graphicarts/folder.svg' with { type: 'text' }
|
||||
import folderMinus from '../graphicarts/folder-minus.svg' with { type: 'text' }
|
||||
import folderPlus from '../graphicarts/folder-plus.svg' with { type: 'text' }
|
||||
import globe from '../graphicarts/globe.svg' with { type: 'text' }
|
||||
import helpCircle from '../graphicarts/help-circle.svg' with { type: 'text' }
|
||||
import layers from '../graphicarts/layers.svg' with { type: 'text' }
|
||||
import logOut from '../graphicarts/log-out.svg' with { type: 'text' }
|
||||
import moreHorizontal from '../graphicarts/more-horizontal.svg' with { type: 'text' }
|
||||
import plus from '../graphicarts/plus.svg' with { type: 'text' }
|
||||
import rotateCw from '../graphicarts/rotate-cw.svg' with { type: 'text' }
|
||||
import rss from '../graphicarts/rss.svg' with { type: 'text' }
|
||||
import search from '../graphicarts/search.svg' with { type: 'text' }
|
||||
import sliders from '../graphicarts/sliders.svg' with { type: 'text' }
|
||||
import star from '../graphicarts/star.svg' with { type: 'text' }
|
||||
import starFull from '../graphicarts/star-full.svg' with { type: 'text' }
|
||||
import trash from '../graphicarts/trash.svg' with { type: 'text' }
|
||||
import upload from '../graphicarts/upload.svg' with { type: 'text' }
|
||||
import x from '../graphicarts/x.svg' with { type: 'text' }
|
||||
|
||||
export default {
|
||||
anchor,
|
||||
'alert-circle': alertCircle,
|
||||
assorted,
|
||||
'book-open': bookOpen,
|
||||
check,
|
||||
'chevron-down': chevronDown,
|
||||
'chevron-left': chevronLeft,
|
||||
'chevron-right': chevronRight,
|
||||
'chevron-up': chevronUp,
|
||||
circle,
|
||||
'circle-full': circleFull,
|
||||
download,
|
||||
edit,
|
||||
'external-link': externalLink,
|
||||
folder,
|
||||
'folder-minus': folderMinus,
|
||||
'folder-plus': folderPlus,
|
||||
globe,
|
||||
'help-circle': helpCircle,
|
||||
layers,
|
||||
'log-out': logOut,
|
||||
'more-horizontal': moreHorizontal,
|
||||
plus,
|
||||
'rotate-cw': rotateCw,
|
||||
rss,
|
||||
search,
|
||||
sliders,
|
||||
star,
|
||||
'star-full': starFull,
|
||||
trash,
|
||||
upload,
|
||||
x,
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export function setupKeybindings(vm) {
|
||||
var helperFunctions = {
|
||||
scrollContent: function(direction) {
|
||||
var padding = 40
|
||||
@@ -136,3 +137,4 @@ document.addEventListener('keydown',function(event) {
|
||||
keybindFunction()
|
||||
}
|
||||
})
|
||||
}
|
||||
25
src/frontend/js/login.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import template from './templates/login.html' with {type: 'text'}
|
||||
|
||||
export default {
|
||||
template: template,
|
||||
data: function () {
|
||||
return { hasError: false }
|
||||
},
|
||||
created: function () {
|
||||
this.$setLang(window.app.settings.language)
|
||||
},
|
||||
methods: {
|
||||
login: function (event: Event) {
|
||||
event.preventDefault()
|
||||
var data = new FormData(event.target)
|
||||
fetch('./login', { method: 'POST', body: data }).then(function (res) {
|
||||
if (res.ok) {
|
||||
// TODO:
|
||||
document.location.assign('./')
|
||||
} else {
|
||||
this.hasError = true
|
||||
}
|
||||
}.bind(this))
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/frontend/js/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import Vue from 'vue/dist/vue.esm.js'
|
||||
import i18n from './i18n'
|
||||
import App from './app'
|
||||
import Login from './login'
|
||||
|
||||
Vue.use(i18n)
|
||||
|
||||
var vm = new Vue({
|
||||
render: function (h) {
|
||||
return h(window.app.authenticated ? App : Login)
|
||||
}
|
||||
}).$mount('#app')
|
||||
429
src/frontend/js/templates/index.html
Normal file
@@ -0,0 +1,429 @@
|
||||
<div class="d-flex" :class="{'feed-selected': feedSelected !== null, 'item-selected': itemSelected !== null}">
|
||||
<!-- feed list -->
|
||||
<div id="col-feed-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: feedListWidth+'px'}">
|
||||
<v-drag :width="feedListWidth" @resize="resizeFeedList"></v-drag>
|
||||
<div class="p-2 toolbar d-flex align-items-center">
|
||||
<v-icon class="mx-2" name="anchor" />
|
||||
<div class="flex-grow-1"></div>
|
||||
<button class="toolbar-item ml-1"
|
||||
:class="{active: filterSelected == 'unread'}"
|
||||
:aria-pressed="filterSelected == 'unread'"
|
||||
:title="$t('unread')"
|
||||
@click="filterSelected = 'unread'">
|
||||
<v-icon name="circle-full" />
|
||||
</button>
|
||||
<button class="toolbar-item mx-1"
|
||||
:class="{active: filterSelected == 'starred'}"
|
||||
:aria-pressed="filterSelected == 'starred'"
|
||||
:title="$t('starred')"
|
||||
@click="filterSelected = 'starred'">
|
||||
<v-icon name="star-full" />
|
||||
</button>
|
||||
<button class="toolbar-item mr-1"
|
||||
:class="{active: filterSelected == ''}"
|
||||
:aria-pressed="filterSelected == ''"
|
||||
:title="$t('all')"
|
||||
@click="filterSelected = ''">
|
||||
<v-icon name="assorted" />
|
||||
</button>
|
||||
<div class="flex-grow-1"></div>
|
||||
<v-dropdown class="settings-dropdown" toggle-class="btn btn-link toolbar-item px-2" ref="menuDropdown" drop="right" :title="$t('settings')">
|
||||
<template v-slot:button>
|
||||
<v-icon name="more-horizontal" />
|
||||
</template>
|
||||
|
||||
<button class="dropdown-item" @click="showSettings('create')">
|
||||
<v-icon class="mr-1" name="plus" />
|
||||
{{ $t('new_feed') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" @click="fetchAllFeeds()">
|
||||
<v-icon class="mr-1" name="rotate-cw" />
|
||||
{{ $t('refresh_feeds') }}
|
||||
</button>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('theme') }}</header>
|
||||
<div class="row text-center m-0">
|
||||
<button class="btn btn-link col-4 px-0 rounded-0"
|
||||
:class="'theme-'+t"
|
||||
:title="t"
|
||||
:aria-label="t"
|
||||
:aria-pressed="theme.name == t"
|
||||
@click.stop="theme.name = t"
|
||||
v-for="t in ['light', 'sepia', 'night']">
|
||||
<v-icon v-if="theme.name == t" name="check" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('auto_refresh') }}</header>
|
||||
<div class="row text-center m-0">
|
||||
<button class="dropdown-item col-4 px-0"
|
||||
@click.stop="changeRefreshRate(-1)"
|
||||
:disabled="!refreshRate">
|
||||
<v-icon name="chevron-down" />
|
||||
</button>
|
||||
<div class="col-4 d-flex align-items-center justify-content-center">{{ refreshRateTitle }}</div>
|
||||
<button class="dropdown-item col-4 px-0"
|
||||
@click.stop="changeRefreshRate(1)" :disabled="refreshRate === refreshRateOptions.at(-1).value">
|
||||
<v-icon name="chevron-up" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="dropdown-divider"></div>
|
||||
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('show_first') }}</header>
|
||||
<div class="d-flex text-center">
|
||||
<button class="dropdown-item px-0" :aria-pressed="itemSortNewestFirst" :class="{active: itemSortNewestFirst}" @click.stop="itemSortNewestFirst=true">{{ $t('new') }}</button>
|
||||
<button class="dropdown-item px-0" :aria-pressed="!itemSortNewestFirst" :class="{active: !itemSortNewestFirst}" @click.stop="itemSortNewestFirst=false">{{ $t('old') }}</button>
|
||||
</div>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('subscriptions') }}</header>
|
||||
<form id="opml-import-form" enctype="multipart/form-data" tabindex="-1">
|
||||
<input type="file"
|
||||
id="opml-import"
|
||||
@change="importOPML"
|
||||
name="opml"
|
||||
style="opacity: 0; width: 1px; height: 0; position: absolute; z-index: -1;">
|
||||
<label class="dropdown-item mb-0 cursor-pointer" for="opml-import" @click.stop="">
|
||||
<v-icon class="mr-1" name="download" />
|
||||
{{ $t('import') }}
|
||||
</label>
|
||||
</form>
|
||||
<a class="dropdown-item" href="./opml/export">
|
||||
<v-icon class="mr-1" name="upload" />
|
||||
{{ $t('export') }}
|
||||
</a>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item" @click="showSettings('shortcuts')">
|
||||
<v-icon class="mr-1" name="help-circle" />
|
||||
{{ $t('shortcuts') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">A / あ / 文</header>
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<button
|
||||
v-for="lang in languages"
|
||||
class="dropdown-item text-center col-3 px-0"
|
||||
:aria-label="lang.name"
|
||||
:title="lang.name"
|
||||
:class="{active: language==lang.code}"
|
||||
@click.stop="changeLanguage(lang.code)">
|
||||
{{ lang.code }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dropdown-divider" v-if="authenticated"></div>
|
||||
<button class="dropdown-item" v-if="authenticated" @click="logout()">
|
||||
<v-icon class="mr-1" name="log-out" />
|
||||
{{ $t('log_out') }}
|
||||
</button>
|
||||
</v-dropdown>
|
||||
</div>
|
||||
<div id="feed-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1">
|
||||
<label class="selectgroup">
|
||||
<input type="radio" name="feed" value="" v-model="feedSelected">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||
<v-icon class="mr-2" name="layers" />
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='unread'">{{ $t('all_unread') }}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected=='starred'">{{ $t('all_starred') }}</span>
|
||||
<span class="flex-fill text-left text-truncate" v-if="filterSelected==''">{{ $t('all_feeds') }}</span>
|
||||
<span class="counter text-right">{{ filteredTotalStats }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div v-for="folder in foldersWithFeeds">
|
||||
<label class="selectgroup mt-1"
|
||||
:class="{'d-none': mustHideFolder(folder)}"
|
||||
v-if="folder.id">
|
||||
<input type="radio" name="feed" :value="'folder:'+folder.id" v-model="feedSelected" v-if="folder.id">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100" v-if="folder.id">
|
||||
<div @click.prevent="toggleFolderExpanded(folder)" class="m-n1 p-1">
|
||||
<v-icon class="mr-2" :class="{expanded: folder.is_expanded}" name="chevron-right" />
|
||||
</div>
|
||||
<span class="flex-fill text-left text-truncate">{{ folder.title }}</span>
|
||||
<span class="counter text-right">{{ filteredFolderStats[folder.id] || '' }}</span>
|
||||
</div>
|
||||
</label>
|
||||
<div v-show="!folder.id || folder.is_expanded" class="mt-1" :class="{'pl-3': folder.id}">
|
||||
<label class="selectgroup"
|
||||
:class="{'d-none': mustHideFeed(feed)}"
|
||||
v-for="feed in folder.feeds">
|
||||
<input type="radio" name="feed" :value="'feed:'+feed.id" v-model="feedSelected">
|
||||
<div class="selectgroup-label d-flex align-items-center w-100">
|
||||
<v-icon class="mr-2" name="rss" v-if="!feed.has_icon" />
|
||||
<span class="icon mr-2" v-else><img :src="'./api/feeds/'+feed.id+'/icon'" alt="" loading="lazy"></span>
|
||||
<span class="flex-fill text-left text-truncate">{{ feed.title }}</span>
|
||||
<span class="counter text-right">{{ filteredFeedStats[feed.id] || '' }}</span>
|
||||
<v-icon class="flex-shrink-0 mx-2"
|
||||
:title="feed_errors[feed.id]"
|
||||
v-if="!filterSelected && feed_errors[feed.id]"
|
||||
name="alert-circle" />
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-2 toolbar d-flex align-items-center border-top flex-shrink-0" v-if="loading.feeds">
|
||||
<span class="icon loading mx-2"></span>
|
||||
<span class="text-truncate cursor-default noselect">{{ $t('refreshing_progress', {count: loading.feeds}) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- item list -->
|
||||
<div id="col-item-list" class="vh-100 position-relative d-flex flex-column border-right flex-shrink-0" :style="{width: itemListWidth+'px'}">
|
||||
<v-drag :width="itemListWidth" @resize="resizeItemList"></v-drag>
|
||||
<div class="px-2 toolbar d-flex align-items-center">
|
||||
<button class="toolbar-item mr-2 d-block d-md-none"
|
||||
@click="feedSelected = null"
|
||||
:title="$t('show_feeds')">
|
||||
<v-icon name="chevron-left" />
|
||||
</button>
|
||||
<div class="input-icon flex-grow-1">
|
||||
<v-icon name="search" />
|
||||
<!-- id used by keybindings -->
|
||||
<input id="searchbar" type="" class="d-block toolbar-search" v-model="itemSearch" @keydown.enter="$event.target.blur()">
|
||||
</div>
|
||||
<button class="toolbar-item ml-2"
|
||||
@click="markItemsRead()"
|
||||
v-if="filterSelected == 'unread'"
|
||||
:title="$t('mark_all_read')">
|
||||
<v-icon name="check" />
|
||||
</button>
|
||||
|
||||
|
||||
<button class="btn btn-link toolbar-item px-2 ml-2" v-if="!current.type" disabled>
|
||||
<v-icon name="more-horizontal" />
|
||||
</button>
|
||||
<v-dropdown class="settings-dropdown"
|
||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||
drop="right"
|
||||
:title="$t('feed_settings')"
|
||||
v-if="current.type == 'feed'">
|
||||
<template v-slot:button>
|
||||
<v-icon name="more-horizontal" />
|
||||
</template>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.feed.title }}</header>
|
||||
<a class="dropdown-item" :href="current.feed.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.link">
|
||||
<v-icon class="mr-1" name="globe" />
|
||||
{{ $t('website') }}
|
||||
</a>
|
||||
<a class="dropdown-item" :href="current.feed.feed_link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" v-if="current.feed.feed_link">
|
||||
<v-icon class="mr-1" name="rss" />
|
||||
{{ $t('feed_link') }}
|
||||
</a>
|
||||
<div class="dropdown-divider" v-if="current.feed.link || current.feed.feed_link"></div>
|
||||
<button class="dropdown-item" @click="renameFeed(current.feed)">
|
||||
<v-icon class="mr-1" name="edit" />
|
||||
{{ $t('rename') }}
|
||||
</button>
|
||||
<button class="dropdown-item" @click="updateFeedLink(current.feed)" v-if="current.feed.feed_link">
|
||||
<v-icon class="mr-1" name="edit" />
|
||||
{{ $t('change_link') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ $t('move_to') }}</header>
|
||||
<button class="dropdown-item"
|
||||
v-if="folder.id != current.feed.folder_id"
|
||||
v-for="folder in folders"
|
||||
@click="moveFeed(current.feed, folder)">
|
||||
<v-icon class="mr-1" name="folder" />
|
||||
{{ folder.title }}
|
||||
</button>
|
||||
<button class="dropdown-item text-muted" @click="moveFeed(current.feed, null)" v-if="current.feed.folder_id">
|
||||
<v-icon class="mr-1" name="folder-minus" />
|
||||
──
|
||||
</button>
|
||||
<button class="dropdown-item text-muted" @click="moveFeedToNewFolder(current.feed)">
|
||||
<v-icon class="mr-1" name="folder-plus" />
|
||||
{{ $t('new_folder') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @click.prevent="deleteFeed(current.feed)">
|
||||
<v-icon class="mr-1" name="trash" />
|
||||
{{ $t('delete') }}
|
||||
</button>
|
||||
</v-dropdown>
|
||||
<v-dropdown class="settings-dropdown"
|
||||
toggle-class="btn btn-link toolbar-item px-2 ml-2"
|
||||
:title="$t('folder_settings')"
|
||||
drop="right"
|
||||
v-if="current.type == 'folder'">
|
||||
<template v-slot:button>
|
||||
<v-icon name="more-horizontal" />
|
||||
</template>
|
||||
<header class="dropdown-header" role="heading" aria-level="2">{{ current.folder.title }}</header>
|
||||
<button class="dropdown-item" @click="renameFolder(current.folder)">
|
||||
<v-icon class="mr-1" name="edit" />
|
||||
{{ $t('rename') }}
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
<button class="dropdown-item text-danger" @click="deleteFolder(current.folder)">
|
||||
<v-icon class="mr-1" name="trash" />
|
||||
{{ $t('delete') }}
|
||||
</button>
|
||||
</v-dropdown>
|
||||
</div>
|
||||
<div id="item-list-scroll" class="p-2 overflow-auto scroll-touch border-top flex-grow-1" v-scroll="loadMoreItems" ref="itemlist">
|
||||
<label v-for="item in items" :key="item.id"
|
||||
class="selectgroup">
|
||||
<input type="radio" name="item" :value="item.id" v-model="itemSelected">
|
||||
<div class="selectgroup-label d-flex flex-column">
|
||||
<div style="line-height: 100%; opacity: .7; margin-bottom: .1rem;" class="d-flex align-items-center">
|
||||
<transition name="indicator">
|
||||
<v-icon class="icon-small mr-1" name="circle-full" v-if="item.status=='unread'" />
|
||||
<v-icon class="icon-small mr-1" name="star-full" v-if="item.status=='starred'" />
|
||||
</transition>
|
||||
<small class="flex-fill text-truncate mr-1">
|
||||
{{ (feedsById[item.feed_id] || {}).title }}
|
||||
</small>
|
||||
<small class="flex-shrink-0"><v-relative-time v-bind:title="formatDate(item.date)" :val="item.date"/></small>
|
||||
</div>
|
||||
<div>{{ item.title || $t('untitled') }}</div>
|
||||
</div>
|
||||
</label>
|
||||
<button class="btn btn-link btn-block loading my-3" v-if="itemsHasMore"></button>
|
||||
</div>
|
||||
<div class="px-3 py-2 border-top text-danger text-break" v-if="feed_errors[current.feed.id]">
|
||||
{{ feed_errors[current.feed.id] }}
|
||||
</div>
|
||||
</div>
|
||||
<!-- item show -->
|
||||
<div id="col-item" class="vh-100 d-flex flex-column w-100" style="min-width: 0;">
|
||||
<div class="toolbar px-2 d-flex align-items-center" v-if="itemSelectedDetails">
|
||||
<button class="toolbar-item"
|
||||
@click="toggleItemStarred(itemSelectedDetails)"
|
||||
:title="$t('mark_starred')">
|
||||
<v-icon name="star-full" v-if="itemSelectedDetails.status=='starred'" />
|
||||
<v-icon name="star" v-else-if="itemSelectedDetails.status!='starred'" />
|
||||
</button>
|
||||
<button class="toolbar-item"
|
||||
:title="$t('mark_unread')"
|
||||
@click="toggleItemRead(itemSelectedDetails)">
|
||||
<v-icon name="circle-full" v-if="itemSelectedDetails.status=='unread'" />
|
||||
<v-icon name="circle" v-if="itemSelectedDetails.status!='unread'" />
|
||||
</button>
|
||||
<v-dropdown class="settings-dropdown" toggle-class="toolbar-item px-2" drop="center" :title="$t('appearance')">
|
||||
<template v-slot:button>
|
||||
<v-icon name="sliders" />
|
||||
</template>
|
||||
|
||||
<button class="dropdown-item" :class="{active: !theme.font}" @click.stop="theme.font = ''">{{ $t('sans_serif') }}</button>
|
||||
<button class="dropdown-item font-serif" :class="{active: theme.font == 'serif'}" @click.stop="theme.font = 'serif'">{{ $t('serif') }}</button>
|
||||
<button class="dropdown-item font-monospace" :class="{active: theme.font == 'monospace'}" @click.stop="theme.font = 'monospace'">{{ $t('monospace') }}</button>
|
||||
|
||||
<div class="d-flex text-center">
|
||||
<button class="dropdown-item" style="font-size: 0.8rem" @click.stop="incrFont(-1)">A</button>
|
||||
<button class="dropdown-item" style="font-size: 1.2rem" @click.stop="incrFont(1)">A</button>
|
||||
</div>
|
||||
</v-dropdown>
|
||||
<button class="toolbar-item"
|
||||
:class="{active: itemSelectedReadability}"
|
||||
@click="toggleReadability()"
|
||||
:title="$t('read_here')">
|
||||
<v-icon :class="{'icon-loading': loading.readability}" name="book-open" />
|
||||
</button>
|
||||
<a class="toolbar-item" :href="itemSelectedDetails.link" rel="noopener noreferrer" target="_blank" referrerpolicy="no-referrer" :title="$t('open_link')">
|
||||
<v-icon name="external-link" />
|
||||
</a>
|
||||
<div class="flex-grow-1"></div>
|
||||
<button class="toolbar-item" @click="navigateToItem(-1)" :title="$t('previous_article')" :disabled="!items.length || itemSelected == items[0].id">
|
||||
<v-icon name="chevron-left" />
|
||||
</button>
|
||||
<button class="toolbar-item" @click="navigateToItem(+1)" :title="$t('next_article')" :disabled="!items.length || itemSelected == items[items.length - 1].id">
|
||||
<v-icon name="chevron-right" />
|
||||
</button>
|
||||
<button class="toolbar-item" @click="itemSelected=null" :title="$t('close_article')">
|
||||
<v-icon name="x" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="itemSelectedDetails"
|
||||
ref="content"
|
||||
class="content px-4 pt-3 pb-5 border-top overflow-auto scroll-touch"
|
||||
:class="{'font-serif': theme.font == 'serif', 'font-monospace': theme.font == 'monospace'}"
|
||||
:style="{'font-size': theme.size + 'rem'}">
|
||||
<div class="content-wrapper">
|
||||
<h1><b>{{ itemSelectedDetails.title || $t('untitled') }}</b></h1>
|
||||
<div class="text-muted">
|
||||
<div>
|
||||
<span class="cursor-pointer" @click="feedSelected = 'feed:'+(feedsById[itemSelectedDetails.feed_id] || {}).id">
|
||||
{{ (feedsById[itemSelectedDetails.feed_id] || {}).title }}
|
||||
</span>
|
||||
</div>
|
||||
<time>{{ formatDate(itemSelectedDetails.date) }}</time>
|
||||
</div>
|
||||
<hr>
|
||||
<div v-if="!itemSelectedReadability">
|
||||
<div v-if="contentImages.length">
|
||||
<figure v-for="media in contentImages">
|
||||
<img :src="media.url" loading="lazy">
|
||||
<figcaption v-if="media.description">{{ media.description }}</figcaption>
|
||||
</figure>
|
||||
</div>
|
||||
<audio class="w-100" controls v-for="media in contentAudios" :src="media.url"></audio>
|
||||
<video class="w-100" controls v-for="media in contentVideos" :src="media.url"></video>
|
||||
</div>
|
||||
<div v-html="itemSelectedContent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<v-modal :open="!!settings" @hide="settings = ''">
|
||||
<button class="btn btn-link outline-none float-right p-2 mr-n2 mt-n2" style="line-height: 1" @click="settings = ''">
|
||||
<v-icon name="x" />
|
||||
</button>
|
||||
<div v-if="settings=='create'">
|
||||
<p class="cursor-default"><b>{{ $t('new_feed') }}</b></p>
|
||||
<form action="" @submit.prevent="createFeed(event)" class="mt-4">
|
||||
<label for="feed-url">{{ $t('url') }}</label>
|
||||
<input id="feed-url" name="url" type="url" class="form-control" required autocomplete="off" :readonly="feedNewChoice.length > 0" placeholder="https://example.com/feed" v-focus>
|
||||
<label for="feed-folder" class="mt-3 d-block">
|
||||
{{ $t('folder') }}
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="createNewFeedFolder()">{{ $t('new_folder') }}</a>
|
||||
</label>
|
||||
<select class="form-control" id="feed-folder" name="folder_id" ref="newFeedFolder">
|
||||
<option value="">---</option>
|
||||
<option :value="folder.id" v-for="folder in folders" :selected="folder.id === current.feed.folder_id || folder.id === current.folder.id">{{ folder.title }}</option>
|
||||
</select>
|
||||
<div class="mt-4" v-if="feedNewChoice.length">
|
||||
<p class="mb-2">
|
||||
{{ $t('multiple_feeds_found') }}
|
||||
<a href="#" class="float-right text-decoration-none" @click.prevent="resetFeedChoice()">{{ $t('cancel') }}</a>
|
||||
</p>
|
||||
<label class="selectgroup" v-for="choice in feedNewChoice">
|
||||
<input type="radio" name="feedToAdd" :value="choice.url" v-model="feedNewChoiceSelected">
|
||||
<div class="selectgroup-label">
|
||||
<div class="text-truncate">{{ choice.title }}</div>
|
||||
<div class="text-truncate" :class="{light: choice.title}">{{ choice.url }}</div>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn btn-block btn-default mt-3" :class="{loading: loading.newfeed}" type="submit">{{ $t('add') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
<div v-else-if="settings=='shortcuts'">
|
||||
<p class="cursor-default"><b>{{ $t('keyboard_shortcuts') }}</b></p>
|
||||
|
||||
<table class="table table-borderless table-sm table-compact m-0">
|
||||
<tr><td><kbd>1</kbd> <kbd>2</kbd> <kbd>3</kbd></td>
|
||||
<td>{{ $t('kb_show_filters') }}</td></tr>
|
||||
<tr><td><kbd>/</kbd></td> <td>{{ $t('kb_focus_search') }}</td></tr>
|
||||
|
||||
<tr><td colspan=2> </td></tr>
|
||||
<tr><td><kbd>j</kbd> <kbd>k</kbd></td> <td>{{ $t('kb_next_prev_article') }}</td></tr>
|
||||
<tr><td><kbd>l</kbd> <kbd>h</kbd></td> <td>{{ $t('kb_next_prev_feed') }}</td></tr>
|
||||
<tr><td><kbd>q</kbd></td> <td>{{ $t('kb_close_article') }}</td></tr>
|
||||
|
||||
<tr><td colspan=2> </td></tr>
|
||||
<tr><td><kbd>R</kbd></td> <td>{{ $t('kb_mark_all_read') }}</td></tr>
|
||||
<tr><td><kbd>r</kbd></td> <td>{{ $t('kb_mark_read') }}</td></tr>
|
||||
<tr><td><kbd>s</kbd></td> <td>{{ $t('kb_mark_starred') }}</td></tr>
|
||||
<tr><td><kbd>o</kbd></td> <td>{{ $t('kb_open_link') }}</td></tr>
|
||||
<tr><td><kbd>i</kbd></td> <td>{{ $t('kb_read_here') }}</td> </tr>
|
||||
<tr><td><kbd>f</kbd> <kbd>b</kbd></td> <td>{{ $t('kb_scroll_content') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</v-modal>
|
||||
</div>
|
||||
15
src/frontend/js/templates/login.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<div class="login-page">
|
||||
<form @submit.prevent="login">
|
||||
<img src="./static/graphicarts/anchor.svg" alt="">
|
||||
<div class="text-danger text-center my-3" v-if="hasError">{{ $t('login_error') }}</div>
|
||||
<div class="form-group">
|
||||
<label for="username">{{ $t('username') }}</label>
|
||||
<input name="username" class="form-control" id="username" autocomplete="off" required autofocus>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">{{ $t('password') }}</label>
|
||||
<input name="password" class="form-control" id="password" type="password" required>
|
||||
</div>
|
||||
<button class="btn btn-block btn-default" type="submit">{{ $t('login') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
49
src/frontend/js/utils.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
export function scrollto(target, scroll) {
|
||||
var padding = 10
|
||||
var targetRect = target.getBoundingClientRect()
|
||||
var scrollRect = scroll.getBoundingClientRect()
|
||||
|
||||
// target
|
||||
var relativeOffset = targetRect.y - scrollRect.y
|
||||
var absoluteOffset = relativeOffset + scroll.scrollTop
|
||||
|
||||
if (padding <= relativeOffset && relativeOffset + targetRect.height <= scrollRect.height - padding) return
|
||||
|
||||
var newPos = scroll.scrollTop
|
||||
if (relativeOffset < padding) {
|
||||
newPos = absoluteOffset - padding
|
||||
} else {
|
||||
newPos = absoluteOffset - scrollRect.height + targetRect.height + padding
|
||||
}
|
||||
scroll.scrollTop = Math.round(newPos)
|
||||
}
|
||||
|
||||
export function debounce(callback, wait) {
|
||||
var timeout
|
||||
return function() {
|
||||
var ctx = this, args = arguments
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(function() {
|
||||
callback.apply(ctx, args)
|
||||
}, wait)
|
||||
}
|
||||
}
|
||||
|
||||
export function dateRepr(d) {
|
||||
var sec = (new Date().getTime() - d.getTime()) / 1000
|
||||
var neg = sec < 0
|
||||
var out = ''
|
||||
|
||||
sec = Math.abs(sec)
|
||||
if (sec < 2700) // less than 45 minutes
|
||||
out = Math.round(sec / 60) + 'm'
|
||||
else if (sec < 86400) // less than 24 hours
|
||||
out = Math.round(sec / 3600) + 'h'
|
||||
else if (sec < 604800) // less than a week
|
||||
out = Math.round(sec / 86400) + 'd'
|
||||
else
|
||||
out = d.toLocaleDateString(undefined, {year: "numeric", month: "long", day: "numeric"})
|
||||
|
||||
if (neg) return '-' + out
|
||||
return out
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/nkanaev/yarr/src/assets"
|
||||
"github.com/nkanaev/yarr/src/server/router"
|
||||
"github.com/nkanaev/yarr/src/storage"
|
||||
)
|
||||
@@ -26,34 +25,7 @@ func (m *Middleware) Handler(c *router.Context) {
|
||||
}
|
||||
if IsAuthenticated(c.Req, m.Username, m.Password) {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
rootUrl := m.BasePath + "/"
|
||||
|
||||
if c.Req.URL.Path != rootUrl {
|
||||
} else {
|
||||
c.Out.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
if c.Req.Method == "POST" {
|
||||
username := c.Req.FormValue("username")
|
||||
password := c.Req.FormValue("password")
|
||||
if StringsEqual(username, m.Username) && StringsEqual(password, m.Password) {
|
||||
Authenticate(c.Out, m.Username, m.Password, m.BasePath)
|
||||
c.Redirect(rootUrl)
|
||||
return
|
||||
} else {
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||
"username": username,
|
||||
"hasError": true,
|
||||
"settings": m.DB.GetSettings().Map(),
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
c.HTML(http.StatusOK, assets.Template("login.html"), map[string]any{
|
||||
"hasError": false,
|
||||
"settings": m.DB.GetSettings().Map(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func (s *Server) handler() http.Handler {
|
||||
BasePath: s.BasePath,
|
||||
Username: s.Username,
|
||||
Password: s.Password,
|
||||
Public: []string{"/static", "/fever", "/manifest.json"},
|
||||
Public: []string{"/", "/login", "/static", "/fever", "/manifest.json"},
|
||||
DB: s.db,
|
||||
}
|
||||
r.Use(a.Handler)
|
||||
@@ -58,6 +58,7 @@ func (s *Server) handler() http.Handler {
|
||||
r.For("/opml/import", s.handleOPMLImport)
|
||||
r.For("/opml/export", s.handleOPMLExport)
|
||||
r.For("/page", s.handlePageCrawl)
|
||||
r.For("/login", s.handleLogin)
|
||||
r.For("/logout", s.handleLogout)
|
||||
r.For("/fever/", s.handleFever)
|
||||
|
||||
@@ -65,9 +66,24 @@ func (s *Server) handler() http.Handler {
|
||||
}
|
||||
|
||||
func (s *Server) handleIndex(c *router.Context) {
|
||||
c.HTML(http.StatusOK, assets.Template("index.html"), map[string]any{
|
||||
"settings": s.db.GetSettings().Map(),
|
||||
"authenticated": s.Username != "" && s.Password != "",
|
||||
isAuthenticated := false
|
||||
if s.Username == "" && s.Password == "" {
|
||||
isAuthenticated = true
|
||||
} else {
|
||||
isAuthenticated = auth.IsAuthenticated(c.Req, s.Username, s.Password)
|
||||
}
|
||||
|
||||
settings := s.db.GetSettings()
|
||||
if !isAuthenticated {
|
||||
settings = model.Settings{
|
||||
Language: settings.Language,
|
||||
ThemeName: settings.ThemeName,
|
||||
}
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, assets.Templates().Lookup("index.html"), map[string]any{
|
||||
"settings": settings.Map(),
|
||||
"authenticated": isAuthenticated,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -78,7 +94,7 @@ func (s *Server) handleStatic(c *router.Context) {
|
||||
c.Out.WriteHeader(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
http.StripPrefix(s.BasePath+"/static/", http.FileServer(http.FS(assets.FS))).
|
||||
http.StripPrefix(s.BasePath+"/static/", http.FileServer(http.FS(assets.StaticFS()))).
|
||||
ServeHTTP(c.Out, c.Req)
|
||||
}
|
||||
|
||||
@@ -557,6 +573,22 @@ func (s *Server) handlePageCrawl(c *router.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Server) handleLogin(c *router.Context) {
|
||||
if c.Req.Method == "POST" {
|
||||
username := c.Req.FormValue("username")
|
||||
password := c.Req.FormValue("password")
|
||||
if auth.StringsEqual(username, s.Username) && auth.StringsEqual(password, s.Password) {
|
||||
auth.Authenticate(c.Out, s.Username, s.Password, s.BasePath)
|
||||
return
|
||||
} else {
|
||||
c.Out.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
c.Out.WriteHeader(http.StatusMethodNotAllowed)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handleLogout(c *router.Context) {
|
||||
auth.Logout(c.Out, s.BasePath)
|
||||
c.Out.WriteHeader(http.StatusNoContent)
|
||||
|
||||