Merge branch 'frontend-refactor'

This commit is contained in:
nkanaev
2026-07-06 23:34:21 +01:00
66 changed files with 1649 additions and 2107 deletions

View File

@@ -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
View 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
View 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"
}
}

View File

@@ -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")
}

View File

@@ -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
}

View File

@@ -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>&nbsp;</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>&nbsp;</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>

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -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
View File

@@ -0,0 +1,2 @@
bundle.js
bundle.css

View File

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

View File

Before

Width:  |  Height:  |  Size: 603 B

After

Width:  |  Height:  |  Size: 603 B

File diff suppressed because one or more lines are too long

View 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>

View File

@@ -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;
}

View File

Before

Width:  |  Height:  |  Size: 356 B

After

Width:  |  Height:  |  Size: 356 B

View File

Before

Width:  |  Height:  |  Size: 345 B

After

Width:  |  Height:  |  Size: 345 B

View File

Before

Width:  |  Height:  |  Size: 353 B

After

Width:  |  Height:  |  Size: 353 B

View File

Before

Width:  |  Height:  |  Size: 339 B

After

Width:  |  Height:  |  Size: 339 B

View File

Before

Width:  |  Height:  |  Size: 262 B

After

Width:  |  Height:  |  Size: 262 B

View File

Before

Width:  |  Height:  |  Size: 269 B

After

Width:  |  Height:  |  Size: 269 B

View File

Before

Width:  |  Height:  |  Size: 270 B

After

Width:  |  Height:  |  Size: 270 B

View File

Before

Width:  |  Height:  |  Size: 270 B

After

Width:  |  Height:  |  Size: 270 B

View File

Before

Width:  |  Height:  |  Size: 268 B

After

Width:  |  Height:  |  Size: 268 B

View File

Before

Width:  |  Height:  |  Size: 267 B

After

Width:  |  Height:  |  Size: 267 B

View File

Before

Width:  |  Height:  |  Size: 258 B

After

Width:  |  Height:  |  Size: 258 B

View File

Before

Width:  |  Height:  |  Size: 370 B

After

Width:  |  Height:  |  Size: 370 B

View File

Before

Width:  |  Height:  |  Size: 365 B

After

Width:  |  Height:  |  Size: 365 B

View File

Before

Width:  |  Height:  |  Size: 388 B

After

Width:  |  Height:  |  Size: 388 B

View File

Before

Width:  |  Height:  |  Size: 361 B

After

Width:  |  Height:  |  Size: 361 B

View File

Before

Width:  |  Height:  |  Size: 405 B

After

Width:  |  Height:  |  Size: 405 B

View File

Before

Width:  |  Height:  |  Size: 311 B

After

Width:  |  Height:  |  Size: 311 B

View File

Before

Width:  |  Height:  |  Size: 409 B

After

Width:  |  Height:  |  Size: 409 B

View File

Before

Width:  |  Height:  |  Size: 365 B

After

Width:  |  Height:  |  Size: 365 B

View File

Before

Width:  |  Height:  |  Size: 365 B

After

Width:  |  Height:  |  Size: 365 B

View File

Before

Width:  |  Height:  |  Size: 367 B

After

Width:  |  Height:  |  Size: 367 B

View File

Before

Width:  |  Height:  |  Size: 343 B

After

Width:  |  Height:  |  Size: 343 B

View File

Before

Width:  |  Height:  |  Size: 304 B

After

Width:  |  Height:  |  Size: 304 B

View File

Before

Width:  |  Height:  |  Size: 321 B

After

Width:  |  Height:  |  Size: 321 B

View File

Before

Width:  |  Height:  |  Size: 330 B

After

Width:  |  Height:  |  Size: 330 B

View File

Before

Width:  |  Height:  |  Size: 308 B

After

Width:  |  Height:  |  Size: 308 B

View File

Before

Width:  |  Height:  |  Size: 611 B

After

Width:  |  Height:  |  Size: 611 B

View File

Before

Width:  |  Height:  |  Size: 348 B

After

Width:  |  Height:  |  Size: 348 B

View File

Before

Width:  |  Height:  |  Size: 339 B

After

Width:  |  Height:  |  Size: 339 B

View File

Before

Width:  |  Height:  |  Size: 356 B

After

Width:  |  Height:  |  Size: 356 B

View File

Before

Width:  |  Height:  |  Size: 365 B

After

Width:  |  Height:  |  Size: 365 B

View File

Before

Width:  |  Height:  |  Size: 299 B

After

Width:  |  Height:  |  Size: 299 B

View File

@@ -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)
}
}
})()

View File

@@ -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')
}

View 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)
})
},
}

View 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()
}
},
}

View 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] || '' }
}
}

View 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')
},
},
}

View 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)
},
}

View File

@@ -0,0 +1,5 @@
export default {
inserted: function(el) {
el.focus()
}
}

View 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))
},
}

View File

@@ -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
View 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,
}

View File

@@ -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
View 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
View 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')

View 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>&nbsp;</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>&nbsp;</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>

View 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
View 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
}

View File

@@ -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(),
})
}

View File

@@ -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)