Basic UI functionality (#365)

* begin implementing home timeline

* up

* yarn up

* flake.lock: Update

Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/f5892ddac112a1e9b3612c39af1b72987ee5783a' (2023-09-29)
  → 'github:nixos/nixpkgs/81e8f48ebdecf07aab321182011b067aafc78896' (2023-10-03)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/611ccdceed92b4d94ae75328148d84ee4a5b462d' (2023-10-03)
  → 'github:oxalica/rust-overlay/126829788e99c188be4eeb805f144d73d8a00f2c' (2023-10-07)

* begin implementing post component

* virtual scrolling

* infinite scrolling

* cleanup

* nice effects, post progress

* add attachment rendering

* add post page

* add changing titles

* some i18n, new post modal

* add move to thread view

* add character limit calculation, fix bundle size

* up

* progress

* flake.lock: Update

Flake lock file updates:

• Updated input 'nixpkgs':
    'github:nixos/nixpkgs/81e8f48ebdecf07aab321182011b067aafc78896' (2023-10-03)
  → 'github:nixos/nixpkgs/f99e5f03cc0aa231ab5950a15ed02afec45ed51a' (2023-10-09)
• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/126829788e99c188be4eeb805f144d73d8a00f2c' (2023-10-07)
  → 'github:oxalica/rust-overlay/aa7584f5bbf5947716ad8ec14eccc0334f0d28f0' (2023-10-12)

* remove obsolete tokens

* create glitchedelement wrapper that respects the reduced motion directive

* remove vue logo

* fix lints

* remove templated html pages

* remove conditional wrapper

* remove unused css and templates

* remove google fonts reference

* update oauth forms

* fix templates up

* up

* flake.lock: Update

Flake lock file updates:

• Updated input 'rust-overlay':
    'github:oxalica/rust-overlay/dce60ca7fca201014868c08a612edb73a998310f' (2023-10-14)
  → 'github:oxalica/rust-overlay/e494404d36a41247987eeb1bfc2f1ca903e97764' (2023-10-15)

* add local timeline, add global timeline, add infinite scrolling

* fix infinite scroll

* fix up styling

* fix modal style

* up
This commit is contained in:
aumetra 2023-10-21 16:51:50 +02:00 committed by GitHub
parent 3cd82fc03d
commit cc1aa20f9e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
84 changed files with 2781 additions and 1249 deletions

View File

@ -12,4 +12,4 @@
"rust-analyzer.server.extraEnv": {
"CARGO_TARGET_DIR": "target-analyzer"
}
}
}

88
Cargo.lock generated
View File

@ -276,9 +276,9 @@ dependencies = [
[[package]]
name = "async-graphql"
version = "6.0.7"
version = "6.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1addb0b551c59640e15de99e7566a4e3a1186cf42269e160c485ba6d8b43fe30"
checksum = "117113a7ff4a98f2a864fa7a5274033b0907fce65dc8464993c75033f8074f90"
dependencies = [
"async-graphql-derive",
"async-graphql-parser",
@ -312,9 +312,9 @@ dependencies = [
[[package]]
name = "async-graphql-axum"
version = "6.0.7"
version = "6.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c21af134ab9419aae6658298f819a28e4737ac81f96cde8008f9d49db1802662"
checksum = "0ddd7767f83c3273a6d26a36cbdd562c6009aae87c6fa2c0f1eebb76f60c4151"
dependencies = [
"async-graphql",
"async-trait",
@ -330,9 +330,9 @@ dependencies = [
[[package]]
name = "async-graphql-derive"
version = "6.0.7"
version = "6.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e1121ff0be2feea705c24f6940162c4f14a077e50a217b16e091e6534a8c08a"
checksum = "6e4bb7b7b2344d24af860776b7fe4e4ee4a67cd965f076048d023f555703b854"
dependencies = [
"Inflector",
"async-graphql-parser",
@ -347,9 +347,9 @@ dependencies = [
[[package]]
name = "async-graphql-parser"
version = "6.0.7"
version = "6.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0b6713fd4ffd610b8b6f6e911bf31277cbb84b7c2a9cdeeb39d1b3eed3b88e4"
checksum = "c47e1c1ff6cb7cae62c9cd768d76475cc68f156d8234b024fd2499ad0e91da21"
dependencies = [
"async-graphql-value",
"pest",
@ -359,9 +359,9 @@ dependencies = [
[[package]]
name = "async-graphql-value"
version = "6.0.7"
version = "6.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7d74240f9daa8c1e8f73e9cfcc338d20a88d00bbeb83ded49ce8e5b4dcec0f5"
checksum = "2270df3a642efce860ed06fbcf61fc6db10f83c2ecb5613127fb453c82e012a4"
dependencies = [
"bytes",
"indexmap 2.0.2",
@ -1187,9 +1187,9 @@ checksum = "e496a50fda8aacccc86d7529e2c1e0892dbd0f898a6b5645b5561b89c3210efa"
[[package]]
name = "cpufeatures"
version = "0.2.9"
version = "0.2.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a17b76ff3a4162b0b27f354a0c87015ddad39d35f9c0c36607a3bdd175dde1f1"
checksum = "3fbc60abd742b35f2492f808e1abbb83d45f72db402e14c55057edc9c7b1e9e4"
dependencies = [
"libc",
]
@ -1338,7 +1338,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
"socket2 0.4.9",
"socket2 0.4.10",
"winapi",
]
@ -1463,7 +1463,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856"
dependencies = [
"cfg-if",
"hashbrown 0.14.1",
"hashbrown 0.14.2",
"lock_api",
"once_cell",
"parking_lot_core",
@ -1625,9 +1625,9 @@ dependencies = [
[[package]]
name = "diesel_full_text_search"
version = "2.1.0"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3975708a44577929dd7d921e5a4c1f95eef7c6a22d3e236d7d92fa108b535fdd"
checksum = "8b44a057e441a6a5d802feea2646a6730cc6df99c75b7e02857a41ea90f2b30b"
dependencies = [
"diesel",
]
@ -2305,9 +2305,9 @@ dependencies = [
[[package]]
name = "hashbrown"
version = "0.14.1"
version = "0.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7dfda62a12f55daeae5015f81b0baea145391cb4520f86c248fc615d72640d12"
checksum = "f93e7192158dbcda357bdec5fb5788eebf8bbac027f3f33e719d29135ae84156"
[[package]]
name = "headers"
@ -2486,7 +2486,7 @@ dependencies = [
"httpdate",
"itoa",
"pin-project-lite",
"socket2 0.4.9",
"socket2 0.4.10",
"tokio",
"tower-service",
"tracing",
@ -2593,7 +2593,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8adf3ddd720272c6ea8bf59463c04e0f93d0bbf7c5439b691bca2987e0270897"
dependencies = [
"equivalent",
"hashbrown 0.14.1",
"hashbrown 0.14.2",
"serde",
]
@ -3035,7 +3035,7 @@ dependencies = [
"pem",
"pkcs8",
"rayon",
"ring 0.17.4",
"ring 0.17.5",
"thiserror",
"time",
"tokio",
@ -3192,7 +3192,7 @@ dependencies = [
"quoted_printable",
"rustls",
"rustls-pemfile",
"socket2 0.5.4",
"socket2 0.5.5",
"tokio",
"tokio-rustls",
"tracing",
@ -4609,7 +4609,7 @@ dependencies = [
"rustls-native-certs",
"ryu",
"sha1_smol",
"socket2 0.4.9",
"socket2 0.4.10",
"tokio",
"tokio-retry",
"tokio-rustls",
@ -4734,9 +4734,9 @@ dependencies = [
[[package]]
name = "ring"
version = "0.17.4"
version = "0.17.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fce3045ffa7c981a6ee93f640b538952e155f1ae3a1a02b84547fc7a56b7059a"
checksum = "fb0205304757e5d899b9c2e448b867ffd03ae7f988002e47cd24954391394d0b"
dependencies = [
"cc",
"getrandom",
@ -4860,9 +4860,9 @@ dependencies = [
[[package]]
name = "rustix"
version = "0.38.19"
version = "0.38.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "745ecfa778e66b2b63c88a61cb36e0eea109e803b0b86bf9879fbc77c70e86ed"
checksum = "67ce50cb2e16c2903e30d1cbccfd8387a74b9d4c938b6a4c5ec6cc7556f7a8a0"
dependencies = [
"bitflags 2.4.1",
"errno",
@ -5399,9 +5399,9 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.4.9"
version = "0.4.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64a4a911eed85daf18834cfaa86a79b7d266ff93ff5ba14005426219480ed662"
checksum = "9f7916fc008ca5542385b89a3d3ce689953c143e9304a9bf8beec1de48994c0d"
dependencies = [
"libc",
"winapi",
@ -5409,9 +5409,9 @@ dependencies = [
[[package]]
name = "socket2"
version = "0.5.4"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4031e820eb552adee9295814c0ced9e5cf38ddf1e8b7d566d6de8e2538ea989e"
checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9"
dependencies = [
"libc",
"windows-sys 0.48.0",
@ -5638,18 +5638,18 @@ dependencies = [
[[package]]
name = "thiserror"
version = "1.0.49"
version = "1.0.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1177e8c6d7ede7afde3585fd2513e611227efd6481bd78d2e82ba1ce16557ed4"
checksum = "f9a7210f5c9a7156bb50aa36aed4c95afb51df0df00713949448cf9e97d382d2"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.49"
version = "1.0.50"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "10712f02019e9288794769fba95cd6847df9874d49d871d062172f9dd41bc4cc"
checksum = "266b2e40bc00e5a6c09c3584011e08b06f123c00362c92b975ba9843aaaa14b8"
dependencies = [
"proc-macro2",
"quote",
@ -5736,7 +5736,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2 0.5.4",
"socket2 0.5.5",
"tokio-macros",
"windows-sys 0.48.0",
]
@ -5772,7 +5772,7 @@ dependencies = [
"postgres-protocol",
"postgres-types",
"rand",
"socket2 0.5.4",
"socket2 0.5.5",
"tokio",
"tokio-util",
"whoami",
@ -5955,9 +5955,9 @@ checksum = "b6bc1c9ce2b5135ac7f93c72918fc37feb872bdc6a5533a8b85eb4b86bfdae52"
[[package]]
name = "tracing"
version = "0.1.39"
version = "0.1.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee2ef2af84856a50c1d430afce2fdded0a4ec7eda868db86409b4543df0797f9"
checksum = "c3523ab5a71916ccf420eebdf5521fcef02141234bbc0b8a49f2fdc4544364ef"
dependencies = [
"log",
"pin-project-lite",
@ -6066,16 +6066,18 @@ dependencies = [
[[package]]
name = "typed-builder"
version = "0.17.0"
source = "git+https://github.com/idanarye/rust-typed-builder.git?rev=fd1bd8336aeb0dc334060635e2a6a1d8dc329e3c#fd1bd8336aeb0dc334060635e2a6a1d8dc329e3c"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e47c0496149861b7c95198088cbf36645016b1a0734cf350c50e2a38e070f38a"
dependencies = [
"typed-builder-macro",
]
[[package]]
name = "typed-builder-macro"
version = "0.17.0"
source = "git+https://github.com/idanarye/rust-typed-builder.git?rev=fd1bd8336aeb0dc334060635e2a6a1d8dc329e3c#fd1bd8336aeb0dc334060635e2a6a1d8dc329e3c"
version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "982ee4197351b5c9782847ef5ec1fdcaf50503fb19d68f9771adae314e72b492"
dependencies = [
"proc-macro2",
"quote",

View File

@ -42,4 +42,3 @@ version = "0.0.1-pre.3"
[patch.crates-io]
redis = { git = "https://github.com/aumetra/redis-rs.git", rev = "3c4ee09d432a69e1d87d66dcba14c519467c9b81" }
typed-builder = { git = "https://github.com/idanarye/rust-typed-builder.git", rev = "fd1bd8336aeb0dc334060635e2a6a1d8dc329e3c" }

View File

@ -11,9 +11,9 @@ moka = { version = "0.12.1", features = ["sync"] }
redis = "0.23.3"
serde = "1.0.189"
simd-json = "0.12.0"
thiserror = "1.0.49"
tracing = "0.1.39"
typed-builder = "0.17.0"
thiserror = "1.0.50"
tracing = "0.1.40"
typed-builder = "0.18.0"
[dev-dependencies]
tokio = { version = "1.33.0", features = ["macros", "rt"] }

View File

@ -12,5 +12,5 @@ serde = { version = "1.0.189", features = ["derive"] }
serde_urlencoded = "0.7.1"
simd-json = "0.12.0"
strum = { version = "0.25.0", features = ["derive"] }
thiserror = "1.0.49"
typed-builder = "0.17.0"
thiserror = "1.0.50"
typed-builder = "0.18.0"

View File

@ -62,12 +62,12 @@ sha2 = { version = "0.10.8", features = ["asm"] }
simd-json = "0.12.0"
smol_str = "0.2.0"
speedy-uuid = { path = "../../lib/speedy-uuid", features = ["diesel"] }
thiserror = "1.0.49"
thiserror = "1.0.50"
time = "0.3.30"
tokio = { version = "1.33.0", features = ["macros", "rt"] }
toml = { version = "0.8.2", default-features = false, features = ["parse"] }
tracing = "0.1.39"
typed-builder = "0.17.0"
tracing = "0.1.40"
typed-builder = "0.18.0"
url = "2.4.1"
zxcvbn = { version = "2.2.2", default-features = false }

View File

@ -12,7 +12,7 @@ diesel-async = { version = "0.4.1", features = [
"postgres",
"tokio",
] }
diesel_full_text_search = { version = "2.1.0", default-features = false }
diesel_full_text_search = { version = "2.1.1", default-features = false }
diesel_migrations = "2.1.0"
iso8601-timestamp = { version = "0.2.12", features = ["diesel-pg"] }
kitsune-language = { path = "../kitsune-language" }
@ -22,7 +22,7 @@ num-traits = "0.2.17"
serde = { version = "1.0.189", features = ["derive"] }
simd-json = "0.12.0"
speedy-uuid = { path = "../../lib/speedy-uuid", features = ["diesel"] }
thiserror = "1.0.49"
thiserror = "1.0.50"
tokio = { version = "1.33.0", features = ["rt"] }
tracing-log = "0.1.3"
typed-builder = "0.17.0"
typed-builder = "0.18.0"

View File

@ -23,5 +23,5 @@ mrml = { version = "2.0.0-rc4", default-features = false, features = [
"parse",
"render",
] }
thiserror = "1.0.49"
typed-builder = "0.17.0"
thiserror = "1.0.50"
typed-builder = "0.18.0"

View File

@ -14,5 +14,5 @@ kitsune-http-client = { path = "../kitsune-http-client" }
once_cell = "1.18.0"
scraper = { version = "0.17.1", default-features = false }
smol_str = "0.2.0"
thiserror = "1.0.49"
typed-builder = "0.17.0"
thiserror = "1.0.50"
typed-builder = "0.18.0"

View File

@ -8,14 +8,14 @@ base64-simd = "0.8.0"
derive_builder = "0.12.0"
http = "0.2.9"
rayon = "1.8.0"
ring = { version = "0.17.4", features = ["std"] }
ring = { version = "0.17.5", features = ["std"] }
time = { version = "0.3.30", default-features = false, features = [
"formatting",
"parsing",
] }
thiserror = "1.0.49"
thiserror = "1.0.50"
tokio = { version = "1.33.0", features = ["sync"] }
typed-builder = "0.17.0"
typed-builder = "0.18.0"
[dev-dependencies]
pem = "3.0.2"

View File

@ -18,4 +18,4 @@ serde = "1.0.189"
simd-json = "0.12.0"
tokio = { version = "1.33.0", features = ["macros", "rt", "sync"] }
tokio-stream = { version = "0.1.14", features = ["sync"] }
tracing = "0.1.39"
tracing = "0.1.40"

View File

@ -7,7 +7,7 @@ version.workspace = true
async-trait = "0.1.74"
diesel = "2.1.3"
diesel-async = "0.4.1"
diesel_full_text_search = { version = "2.1.0", default-features = false }
diesel_full_text_search = { version = "2.1.1", default-features = false }
enum_dispatch = "0.3.12"
futures-util = "0.3.28"
kitsune-db = { path = "../kitsune-db" }
@ -15,8 +15,8 @@ kitsune-language = { path = "../kitsune-language" }
serde = { version = "1.0.189", features = ["derive"] }
speedy-uuid = { path = "../../lib/speedy-uuid" }
strum = { version = "0.25.0", features = ["derive"] }
thiserror = "1.0.49"
tracing = "0.1.39"
thiserror = "1.0.50"
tracing = "0.1.40"
# "meilisearch" feature
meilisearch-sdk = { version = "0.24.2", optional = true }

View File

@ -140,11 +140,11 @@
},
"nixpkgs": {
"locked": {
"lastModified": 1697059129,
"narHash": "sha256-9NJcFF9CEYPvHJ5ckE8kvINvI84SZZ87PvqMbH6pro0=",
"lastModified": 1697723726,
"narHash": "sha256-SaTWPkI8a5xSHX/rrKzUe+/uVNy6zCGMXgoeMb7T9rg=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "5e4c2ada4fcd54b99d56d7bd62f384511a7e2593",
"rev": "7c9cc5a6e5d38010801741ac830a3f8fd667a7a0",
"type": "github"
},
"original": {
@ -232,11 +232,11 @@
]
},
"locked": {
"lastModified": 1697249410,
"narHash": "sha256-OmsnxNsjBB1DJlUuJyzDJJ7psbm4/VzokNT+o0ajzFQ=",
"lastModified": 1697854201,
"narHash": "sha256-H+0Wb20PQx/8N7X/OfwwAVPeN9TbfjcyG0sXbdgsh50=",
"owner": "oxalica",
"repo": "rust-overlay",
"rev": "dce60ca7fca201014868c08a612edb73a998310f",
"rev": "6e8e3332433847cd56186b1f6fc8c47603cf5b46",
"type": "github"
},
"original": {

View File

@ -3,7 +3,10 @@
"importOrder": ["^\\w", "^[./|~/]"],
"importOrderSeparation": true,
"importOrderParserPlugins": ["typescript"],
"plugins": ["@trivago/prettier-plugin-sort-imports"],
"plugins": [
"@trivago/prettier-plugin-sort-imports",
"prettier-plugin-css-order"
],
"semi": true,
"singleQuote": true,
"vueIndentScriptAndStyle": true

View File

@ -2,7 +2,7 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<!--<link rel="icon" type="image/svg+xml" href="/vite.svg" />-->
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Kitsune</title>
</head>

View File

@ -19,17 +19,25 @@
"@fortawesome/vue-fontawesome": "^3.0.2",
"@hcaptcha/vue3-hcaptcha": "^1.3.0",
"@mcaptcha/vanilla-glue": "^0.1.0-alpha-3",
"@tiptap/pm": "^2.1.12",
"@tiptap/starter-kit": "^2.1.12",
"@tiptap/vue-3": "^2.1.12",
"@urql/exchange-graphcache": "^6.3.3",
"@urql/vue": "^1.1.2",
"@vueuse/core": "^10.4.1",
"@vueuse/core": "^10.5.0",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
"@zxcvbn-ts/language-en": "^3.0.2",
"floating-vue": "^2.0.0-beta.24",
"graphql": "^16.8.1",
"lodash": "^4.17.21",
"pinia": "^2.1.6",
"pinia": "^2.1.7",
"pinia-plugin-persistedstate": "^3.2.0",
"vue": "^3.2.41",
"tiptap-markdown": "^0.8.2",
"unhead": "^1.7.4",
"vue": "^3.3.6",
"vue-i18n": "^9.5.0",
"vue-powerglitch": "^1.0.0",
"vue-router": "^4.2.5",
"vue-virtual-scroller": "^2.0.0-beta.8"
},
@ -38,20 +46,21 @@
"@graphql-codegen/client-preset": "^4.1.0",
"@parcel/watcher": "^2.3.0",
"@trivago/prettier-plugin-sort-imports": "^4.2.0",
"@types/lodash": "^4.14.199",
"@typescript-eslint/eslint-plugin": "^6.7.4",
"@typescript-eslint/parser": "^6.7.4",
"@types/lodash": "^4.14.200",
"@typescript-eslint/eslint-plugin": "^6.8.0",
"@typescript-eslint/parser": "^6.8.0",
"@vitejs/plugin-vue": "^4.4.0",
"@vue/eslint-config-prettier": "^8.0.0",
"@vue/eslint-config-typescript": "^12.0.0",
"eslint": "^8.50.0",
"eslint": "^8.52.0",
"eslint-config-prettier": "^9.0.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-prettier": "^5.0.1",
"eslint-plugin-vue": "^9.17.0",
"prettier": "^3.0.3",
"sass": "^1.68.0",
"prettier-plugin-css-order": "^2.0.1",
"sass": "^1.69.4",
"typescript": "^5.2.2",
"vite": "^4.4.10",
"vue-tsc": "^1.8.15"
"vite": "^4.5.0",
"vue-tsc": "^1.8.19"
}
}

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -1,9 +1,11 @@
<template>
<NavBar v-if="authStore.isAuthenticated()" />
<router-view></router-view>
<GenericFooter />
</template>
<script setup lang="ts">
import GenericFooter from './components/GenericFooter.vue';
import NavBar from './components/NavBar.vue';
import { useAuthStore } from './store/auth';

View File

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

Before

Width:  |  Height:  |  Size: 496 B

View File

@ -52,7 +52,7 @@
/>
</FormKit>
<BaseModal :closed="!modalData.show" :title="modalData.title">
<BaseModal v-model="modalData.show" :title="modalData.title">
<!-- This is returned from the backend and created from an error type, and only "enhanced" with HTML newlines by us -->
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="modalData.content" />
@ -67,13 +67,16 @@
<script setup lang="ts">
import { useMutation } from '@urql/vue';
import { reactive } from 'vue';
import { defineAsyncComponent, reactive } from 'vue';
import { useInstanceInfo } from '../graphql/instance-info';
import { graphql } from '../graphql/types';
import { authorizationUrl } from '../lib/oauth2';
import BaseModal from './BaseModal.vue';
import CaptchaComponent from './CaptchaComponent.vue';
import BaseModal from './modal/BaseModal.vue';
const CaptchaComponent = defineAsyncComponent(
() => import('./CaptchaComponent.vue'),
);
const modalData = reactive({
show: false,
@ -158,15 +161,15 @@
}
.formkit-form {
background-color: $dark2;
display: flex;
flex-direction: column;
justify-content: space-between;
width: 90%;
margin: 0 auto;
padding: 3vh;
border-radius: 5px;
border: 0.2px solid $shade1dark;
border-radius: 5px;
background-color: $dark2;
padding: 3vh;
width: 90%;
}
.formkit-wrapper {
@ -174,14 +177,14 @@
}
.formkit-input[type='submit'] {
border: 0;
background-color: $shade1dark;
border-radius: 5px;
padding: 10px;
font-size: 16px;
width: 100px;
cursor: pointer;
transition: 0.5s;
cursor: pointer;
border: 0;
border-radius: 5px;
background-color: $shade1dark;
padding: 10px;
width: 100px;
font-size: 16px;
&:hover {
background-color: $shade2dark;
@ -189,13 +192,13 @@
}
.formkit-input:not([type='submit']) {
width: 100%;
border: 0.5px solid $shade1dark;
background-color: $dark1;
border-radius: 2px;
font-size: 20px;
color: white;
background-color: $dark1;
padding: 5px;
width: 100%;
color: white;
font-size: 20px;
}
.forms {
@ -203,9 +206,9 @@
flex-direction: column;
justify-content: center;
align-items: flex-end;
width: 40%;
padding: 1vw;
gap: 20px;
padding: 1vw;
width: 40%;
@media only screen and (max-width: 1367px) {
align-items: center;
@ -218,13 +221,13 @@
}
.formkit-messages {
padding-left: 0;
color: red;
list-style: none;
padding-left: 0;
}
.formkit-label {
text-transform: uppercase;
margin-bottom: 5px;
text-transform: uppercase;
}
</style>

View File

@ -1,40 +0,0 @@
<template>
<div v-if="!closed" class="modal">
<fieldset class="modal-content">
<legend>
{{ title }}
</legend>
<slot />
</fieldset>
</div>
</template>
<script lang="ts" setup>
defineProps<{
closed: boolean;
title: string;
}>();
</script>
<style lang="scss" scoped>
@use '../styles/colours' as *;
.modal {
position: absolute;
display: flex;
justify-content: center;
align-items: center;
top: 0;
bottom: 0;
left: 0;
right: 0;
background-color: rgba(0, 0, 0, 0.75);
&-content {
background-color: $dark1;
height: fit-content;
}
}
</style>

View File

@ -0,0 +1,83 @@
<template>
<fieldset ref="scroller" class="timeline">
<legend class="timeline-legend">
{{ $t('messages.timeline.title') }}
</legend>
<DynamicScroller class="scroller" :items="posts" :min-item-size="50">
<template
#default="{
item,
index,
active,
}: {
item: PostType;
index: number;
active: boolean;
}"
>
<DynamicScrollerItem
class="post-container"
:item="item"
:active="active"
:size-dependencies="[item.subject, item.content, item.attachments]"
:data-index="index"
>
<Post
:id="item.id"
:account="item.account"
:subject="item.subject"
:content="item.content"
:attachments="item.attachments"
/>
<!-- Load bearing little div -->
<!-- Without this div, the height computation is all messed up and the margin of the post gets ignored -->
<div style="height: 1px"></div>
</DynamicScrollerItem>
</template>
</DynamicScroller>
</fieldset>
</template>
<script lang="ts" setup>
import { useInfiniteScroll } from '@vueuse/core';
import { ref } from 'vue';
import { DynamicScroller, DynamicScrollerItem } from 'vue-virtual-scroller';
import Post, { Post as PostType } from './Post.vue';
const props = defineProps<{
posts: PostType[];
loadMore: () => Promise<void>;
}>();
const scroller = ref<HTMLElement>();
useInfiniteScroll(scroller, async () => {
if (props.posts.length !== 0) {
await props.loadMore();
}
});
</script>
<style lang="scss" scoped>
.timeline {
margin: auto;
border-color: grey;
max-width: 100ch;
max-height: 82vh;
overflow-y: scroll;
&-legend {
text-transform: uppercase;
}
}
.post-container * {
margin-bottom: 15px;
}
.scroller {
height: 100%;
}
</style>

View File

@ -7,7 +7,7 @@
/>
<vue-hcaptcha
v-if="backend == CaptchaBackend.HCaptcha"
v-if="backend === CaptchaBackend.HCaptcha"
:sitekey="sitekey"
@verify="onVerify"
@expired="onExpire"
@ -58,7 +58,7 @@
}
onMounted(async () => {
if (props.backend == CaptchaBackend.MCaptcha) {
if (props.backend === CaptchaBackend.MCaptcha) {
const config = {
widgetLink: new URL(props.sitekey),
};
@ -77,7 +77,7 @@
@use '../styles/colours' as *;
#mcaptcha__widget-container {
height: 80px;
border: 1px solid $shade1dark;
height: 80px;
}
</style>

View File

@ -1,7 +1,10 @@
<template>
<footer>
<div class="footer">
<LanguageSelector />
<span>Kitsune v{{ instanceData?.version }}</span>
<a target="_blank" href="https://github.com/kitsune-soc/kitsune">
Source code
</a>
@ -11,6 +14,7 @@
<script setup lang="ts">
import { useInstanceInfo } from '../graphql/instance-info';
import LanguageSelector from './LanguageSelector.vue';
const instanceData = useInstanceInfo();
</script>
@ -22,8 +26,8 @@
display: flex;
justify-content: center;
align-items: flex-end;
padding: 10px 0;
gap: 25px;
margin: 25px 0;
padding: 10px 0;
}
</style>

View File

@ -0,0 +1,21 @@
<template>
<slot v-if="reduceMotion" />
<GlitchedElement v-else :options="options">
<slot />
</GlitchedElement>
</template>
<script lang="ts" setup>
import { usePreferredReducedMotion } from '@vueuse/core';
import { computed } from 'vue';
import { GlitchedElement } from 'vue-powerglitch';
defineProps<{ options?: object }>();
const preferredReducedMotion = usePreferredReducedMotion();
const reduceMotion = computed(
() => preferredReducedMotion.value === 'reduce',
);
</script>

View File

@ -0,0 +1,7 @@
<template>
<select v-model="$i18n.locale">
<option v-for="(lang, i) in $i18n.availableLocales" :key="i" :value="lang">
{{ lang }}
</option>
</select>
</template>

View File

@ -5,22 +5,29 @@
<NavBarLink :to="route" :icon="details.icon" :detail="details.detail" />
</template>
</div>
<div class="nav-bar-profile">
<div class="nav-bar-element profile-menu-button">
<!-- Without this weird double quote stuff Vite would have tried to do some fucked up shit -->
<img :src="'/public/assets/default-avatar.png'" />
<img :src="DEFAULT_PROFILE_PICTURE_URL" />
</div>
<div class="nav-bar-element">
<font-awesome-icon
class="icon create-status"
icon="fa-pen-to-square fa-solid"
@click="showPostModal = true"
/>
</div>
</div>
</nav>
<NewPostModal v-model="showPostModal" />
</template>
<script setup lang="ts">
import { defineAsyncComponent, ref } from 'vue';
import { DEFAULT_PROFILE_PICTURE_URL } from '../consts';
import NavBarLink from './NavBarLink.vue';
type RouteInfo = {
@ -50,6 +57,11 @@
detail: 'Federated',
},
};
const NewPostModal = defineAsyncComponent(
() => import('./modal/NewPostModal.vue'),
);
const showPostModal = ref(false);
</script>
<style scoped lang="scss">
@ -57,17 +69,18 @@
@use '../styles/mixins' as *;
.nav-bar {
display: flex;
position: fixed;
top: 0;
left: 0;
right: 0;
left: 0;
justify-content: space-between;
align-items: center;
z-index: 999;
margin-bottom: 100px;
background-color: $dark2;
padding: 0 25px;
padding-top: 5px;
margin-bottom: 100px;
display: flex;
justify-content: space-between;
align-items: center;
@include only-on-mobile {
padding: 0;
@ -87,13 +100,14 @@
gap: 10px;
.create-status {
cursor: pointer;
height: 25px;
}
.profile-menu-button {
border-radius: 4px;
display: flex;
align-items: center;
border-radius: 4px;
img {
height: 30px;

View File

@ -4,12 +4,16 @@
:to="to"
draggable="false"
>
<font-awesome-icon class="icon" :icon="icon" />
<span v-if="detail" class="detail">{{ detail }}</span>
<GlitchedElement :options="{ playMode: 'hover' }">
<font-awesome-icon class="icon" :icon="icon" />
<span v-if="detail" class="detail">{{ detail }}</span>
</GlitchedElement>
</router-link>
</template>
<script lang="ts" setup>
import GlitchedElement from './GlitchedElement.vue';
defineProps<{
class?: string;
to: string;
@ -24,14 +28,14 @@
.nav-bar-link {
display: inline-block;
padding: 15px;
color: $shade1dark;
cursor: pointer;
user-select: none;
border-bottom: 4px solid;
border-color: transparent;
padding: 15px;
color: $shade1dark;
user-select: none;
&:hover {
color: white;
@ -52,9 +56,9 @@
}
&.router-link-active {
color: $shade2light;
border-image-slice: 1;
border-image-source: linear-gradient(to left, $shade2light, $shade2dark);
border-image-slice: 1;
color: $shade2light;
}
}
</style>

View File

@ -0,0 +1,144 @@
<template>
<article class="post" @click="goToThreadView">
<a class="account-info" :href="account.url">
<img
class="account-info-profile-picture"
:src="profilePictureUrl"
:alt="`${account.username}'s profile picture`"
/>
<div class="account-info-names">
<strong class="account-info-names-displayname">
{{ account.displayName ?? account.username }}
</strong>
<span class="account-info-names-username">
@{{ account.username }}
</span>
</div>
</a>
<p v-if="subject">
<strong>
<!-- Cleaned on the backend -->
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="subject" />
</strong>
</p>
<!-- Cleaned on the backend -->
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="post-content" v-html="content" />
<div class="post-attachments">
<div
v-for="attachment in attachments"
:key="attachment.url"
:title="attachment.description!"
>
<audio
v-if="attachment.contentType.startsWith('audio')"
:src="attachment.url"
controls
/>
<video
v-else-if="attachment.contentType.startsWith('video')"
:src="attachment.url"
controls
/>
<img v-else :src="attachment.url" :alt="attachment.description!" />
</div>
</div>
</article>
</template>
<script lang="ts" setup>
import { computed } from 'vue';
import { useRouter } from 'vue-router';
import { DEFAULT_PROFILE_PICTURE_URL } from '../consts';
export type PostAccount = {
displayName?: string | null;
username: string;
avatar?: {
url: string;
} | null;
url: string;
};
export type PostAttachment = {
contentType: string;
description?: string | null;
url: string;
};
export type Post = {
id: string;
subject?: string | null;
content: string;
account: PostAccount;
attachments: PostAttachment[];
};
const props = defineProps<Post>();
const profilePictureUrl = computed(
() => props.account.avatar?.url ?? DEFAULT_PROFILE_PICTURE_URL,
);
const router = useRouter();
function goToThreadView() {
router.push(`/posts/${props.id}`);
}
</script>
<style lang="scss" scoped>
@use '../styles/colours' as *;
.post {
border: 1px solid white;
border-radius: 3px;
background-color: $dark2;
padding: 1em;
& .account-info {
display: flex;
align-items: center;
gap: 0.5em;
width: fit-content;
line-height: 100%;
&-profile-picture {
width: 3em;
}
&-names {
display: flex;
flex-direction: column;
&-displayname {
font-size: large;
}
}
}
&-content {
word-wrap: break-word;
white-space: pre-wrap;
}
&-attachments {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.25em;
& * {
width: 100%;
max-height: 30ch;
}
}
}
</style>

View File

@ -0,0 +1,59 @@
<template>
<div v-if="modelValue" class="modal">
<fieldset ref="modalContent" class="modal-content">
<legend class="modal-title">
{{ title }}
</legend>
<slot />
</fieldset>
</div>
</template>
<script lang="ts" setup>
import { onClickOutside } from '@vueuse/core';
import { ref } from 'vue';
defineProps<{
modelValue: boolean;
title: string;
}>();
const emit = defineEmits<{
(event: 'update:modelValue', modelValue: boolean): void;
}>();
const modalContent = ref();
onClickOutside(modalContent, () => {
emit('update:modelValue', false);
});
</script>
<style lang="scss" scoped>
@use '../../styles/colours' as *;
.modal {
display: flex;
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
justify-content: center;
align-items: center;
z-index: 999;
background-color: rgba(0, 0, 0, 0.75);
&-title {
text-transform: uppercase;
}
&-content {
background-color: $dark1;
height: fit-content;
}
}
</style>

View File

@ -0,0 +1,123 @@
<template>
<BaseModal
:model-value="modelValue"
:title="$t('messages.newPost.title')"
@update:model-value="$emit('update:modelValue', $event)"
>
<BubbleMenu v-if="editor" :editor="editor" :tippy-options="tippyOptions">
<button
:class="{ 'is-active': editor.isActive('bold') }"
@click="editor.chain().focus().toggleBold().run()"
>
Bold
</button>
<button
:class="{ 'is-active': editor.isActive('codeBlock') }"
@click="editor.chain().focus().toggleCodeBlock().run()"
>
Code block
</button>
</BubbleMenu>
<FloatingMenu v-if="editor" :editor="editor" :tippy-options="tippyOptions">
<button
:class="{ 'is-active': editor.isActive('heading', { level: 1 }) }"
@click="editor.chain().focus().toggleHeading({ level: 1 }).run()"
>
H1
</button>
<button
:class="{ 'is-active': editor.isActive('heading', { level: 2 }) }"
@click="editor.chain().focus().toggleHeading({ level: 2 }).run()"
>
H2
</button>
<button
:class="{ 'is-active': editor.isActive('bulletList') }"
@click="editor.chain().focus().toggleBulletList().run()"
>
Toggle list
</button>
</FloatingMenu>
<EditorContent :editor="editor" />
<div class="post-modal-controls">
<div class="post-modal-controls-modifiers">lmao</div>
<div class="post-modal-controls-post">
{{ remainingCharacters }}
<button class="post-modal-controls-post-button">Post!</button>
</div>
</div>
</BaseModal>
</template>
<script lang="ts" setup>
import StarterKit from '@tiptap/starter-kit';
import {
useEditor,
BubbleMenu,
EditorContent,
FloatingMenu,
} from '@tiptap/vue-3';
import { Markdown } from 'tiptap-markdown';
import { computed, reactive } from 'vue';
import { useInstanceInfo } from '../../graphql/instance-info';
import BaseModal from './BaseModal.vue';
defineEmits<{
(event: 'update:modelValue', modelValue: boolean): void;
}>();
defineProps<{
modelValue: boolean;
}>();
const editor = useEditor({
extensions: [Markdown, StarterKit],
editorProps: {
attributes: {
class: 'post-modal-editor',
},
},
});
const tippyOptions = reactive({ duration: 200 });
const instanceData = useInstanceInfo();
const remainingCharacters = computed(() => {
if (instanceData.value) {
const markdownText = editor.value?.storage.markdown.getMarkdown();
return instanceData.value.characterLimit - markdownText.length;
} else {
return 0;
}
});
</script>
<style lang="scss">
.post-modal {
&-editor {
margin-bottom: 1em;
border: 1px solid white;
padding: 0 1em;
width: 700px;
max-width: 90vw;
height: fit-content;
min-height: 250px;
&:focus {
outline: none;
}
}
&-controls {
display: flex;
justify-content: space-between;
}
}
</style>

View File

@ -1,2 +1,22 @@
export const BACKEND_PREFIX =
const BACKEND_PREFIX =
import.meta.env.VITE_BACKEND_PREFIX ?? window.location.origin;
const DEFAULT_PROFILE_PICTURE_URL =
BACKEND_PREFIX + '/public/assets/default-avatar.png';
const MAX_UUID = 'FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF';
const TEMPLATE_PARAMS = {
separator: '-',
siteName: 'Kitsune',
};
const TITLE_TEMPLATE = '%s %separator %siteName';
export {
BACKEND_PREFIX,
DEFAULT_PROFILE_PICTURE_URL,
MAX_UUID,
TEMPLATE_PARAMS,
TITLE_TEMPLATE,
};

View File

@ -9,6 +9,11 @@ function useInstanceInfo() {
query: graphql(`
query getInstanceInfo {
instance {
captcha {
backend
key
}
characterLimit
description
domain
localPostCount
@ -16,10 +21,6 @@ function useInstanceInfo() {
name
userCount
version
captcha {
backend
key
}
}
}
`),

View File

@ -0,0 +1,40 @@
import { useQuery } from '@urql/vue';
import { computed } from 'vue';
import { graphql } from './types';
function getPostById(id: string) {
const { data } = useQuery({
query: graphql(`
query getPostById($id: UUID!) {
getPostById(id: $id) {
id
subject
content
account {
id
displayName
username
avatar {
url
}
url
}
attachments {
contentType
description
url
}
}
}
`),
variables: {
id,
},
});
return computed(() => data.value?.getPostById);
}
export { getPostById };

View File

@ -0,0 +1,89 @@
import { useQuery } from '@urql/vue';
import { Ref } from 'vue';
import { graphql } from './types';
function getHome(after: Ref<string>) {
const { data } = useQuery({
query: graphql(`
query getHomeTimeline($after: String!) {
homeTimeline(after: $after) @_relayPagination(mergeMode: "after") {
nodes {
id
subject
content
url
account {
id
avatar {
url
}
displayName
username
url
}
attachments {
contentType
description
url
}
}
pageInfo {
startCursor
endCursor
}
}
}
`),
variables: {
after: after as unknown as string, // Weird cast to allow reactivity
},
});
return data;
}
function getPublic(after: Ref<string>, onlyLocal: boolean) {
const { data } = useQuery({
query: graphql(`
query getPublicTimeline($after: String!, $onlyLocal: Boolean!) {
publicTimeline(after: $after, onlyLocal: $onlyLocal)
@_relayPagination(mergeMode: "after") {
nodes {
id
subject
content
url
account {
id
avatar {
url
}
displayName
username
url
}
attachments {
contentType
description
url
}
}
pageInfo {
startCursor
endCursor
}
}
}
`),
variables: {
after: after as unknown as string,
onlyLocal,
},
});
return data;
}
export { getHome, getPublic };

View File

@ -16,8 +16,14 @@ import * as types from './graphql';
const documents = {
'\n mutation registerUser(\n $username: String!\n $email: String!\n $password: String!\n $captchaToken: String\n ) {\n registerUser(\n username: $username\n email: $email\n password: $password\n captchaToken: $captchaToken\n ) {\n id\n }\n }\n ':
types.RegisterUserDocument,
'\n query getInstanceInfo {\n instance {\n description\n domain\n localPostCount\n registrationsOpen\n name\n userCount\n version\n captcha {\n backend\n key\n }\n }\n }\n ':
'\n query getInstanceInfo {\n instance {\n captcha {\n backend\n key\n }\n characterLimit\n description\n domain\n localPostCount\n registrationsOpen\n name\n userCount\n version\n }\n }\n ':
types.GetInstanceInfoDocument,
'\n query getPostById($id: UUID!) {\n getPostById(id: $id) {\n id\n subject\n content\n account {\n id\n displayName\n username\n avatar {\n url\n }\n url\n }\n attachments {\n contentType\n description\n url\n }\n }\n }\n ':
types.GetPostByIdDocument,
'\n query getHomeTimeline($after: String!) {\n homeTimeline(after: $after) @_relayPagination(mergeMode: "after") {\n nodes {\n id\n subject\n content\n url\n account {\n id\n avatar {\n url\n }\n displayName\n username\n url\n }\n attachments {\n contentType\n description\n url\n }\n }\n pageInfo {\n startCursor\n endCursor\n }\n }\n }\n ':
types.GetHomeTimelineDocument,
'\n query getPublicTimeline($after: String!, $onlyLocal: Boolean!) {\n publicTimeline(after: $after, onlyLocal: $onlyLocal)\n @_relayPagination(mergeMode: "after") {\n nodes {\n id\n subject\n content\n url\n account {\n id\n avatar {\n url\n }\n displayName\n username\n url\n }\n attachments {\n contentType\n description\n url\n }\n }\n pageInfo {\n startCursor\n endCursor\n }\n }\n }\n ':
types.GetPublicTimelineDocument,
'\n mutation registerOauthApplication(\n $name: String!\n $redirect_uri: String!\n ) {\n registerOauthApplication(name: $name, redirectUri: $redirect_uri) {\n id\n secret\n redirectUri\n }\n }\n ':
types.RegisterOauthApplicationDocument,
};
@ -46,8 +52,26 @@ export function graphql(
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n query getInstanceInfo {\n instance {\n description\n domain\n localPostCount\n registrationsOpen\n name\n userCount\n version\n captcha {\n backend\n key\n }\n }\n }\n ',
): (typeof documents)['\n query getInstanceInfo {\n instance {\n description\n domain\n localPostCount\n registrationsOpen\n name\n userCount\n version\n captcha {\n backend\n key\n }\n }\n }\n '];
source: '\n query getInstanceInfo {\n instance {\n captcha {\n backend\n key\n }\n characterLimit\n description\n domain\n localPostCount\n registrationsOpen\n name\n userCount\n version\n }\n }\n ',
): (typeof documents)['\n query getInstanceInfo {\n instance {\n captcha {\n backend\n key\n }\n characterLimit\n description\n domain\n localPostCount\n registrationsOpen\n name\n userCount\n version\n }\n }\n '];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n query getPostById($id: UUID!) {\n getPostById(id: $id) {\n id\n subject\n content\n account {\n id\n displayName\n username\n avatar {\n url\n }\n url\n }\n attachments {\n contentType\n description\n url\n }\n }\n }\n ',
): (typeof documents)['\n query getPostById($id: UUID!) {\n getPostById(id: $id) {\n id\n subject\n content\n account {\n id\n displayName\n username\n avatar {\n url\n }\n url\n }\n attachments {\n contentType\n description\n url\n }\n }\n }\n '];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n query getHomeTimeline($after: String!) {\n homeTimeline(after: $after) @_relayPagination(mergeMode: "after") {\n nodes {\n id\n subject\n content\n url\n account {\n id\n avatar {\n url\n }\n displayName\n username\n url\n }\n attachments {\n contentType\n description\n url\n }\n }\n pageInfo {\n startCursor\n endCursor\n }\n }\n }\n ',
): (typeof documents)['\n query getHomeTimeline($after: String!) {\n homeTimeline(after: $after) @_relayPagination(mergeMode: "after") {\n nodes {\n id\n subject\n content\n url\n account {\n id\n avatar {\n url\n }\n displayName\n username\n url\n }\n attachments {\n contentType\n description\n url\n }\n }\n pageInfo {\n startCursor\n endCursor\n }\n }\n }\n '];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(
source: '\n query getPublicTimeline($after: String!, $onlyLocal: Boolean!) {\n publicTimeline(after: $after, onlyLocal: $onlyLocal)\n @_relayPagination(mergeMode: "after") {\n nodes {\n id\n subject\n content\n url\n account {\n id\n avatar {\n url\n }\n displayName\n username\n url\n }\n attachments {\n contentType\n description\n url\n }\n }\n pageInfo {\n startCursor\n endCursor\n }\n }\n }\n ',
): (typeof documents)['\n query getPublicTimeline($after: String!, $onlyLocal: Boolean!) {\n publicTimeline(after: $after, onlyLocal: $onlyLocal)\n @_relayPagination(mergeMode: "after") {\n nodes {\n id\n subject\n content\n url\n account {\n id\n avatar {\n url\n }\n displayName\n username\n url\n }\n attachments {\n contentType\n description\n url\n }\n }\n pageInfo {\n startCursor\n endCursor\n }\n }\n }\n '];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

View File

@ -87,6 +87,7 @@ export type CaptchaInfo = {
export type Instance = {
__typename?: 'Instance';
captcha?: Maybe<CaptchaInfo>;
characterLimit: Scalars['Int']['output'];
description: Scalars['String']['output'];
domain: Scalars['String']['output'];
localPostCount: Scalars['Int']['output'];
@ -270,6 +271,7 @@ export type GetInstanceInfoQuery = {
__typename?: 'RootQuery';
instance: {
__typename?: 'Instance';
characterLimit: number;
description: string;
domain: string;
localPostCount: number;
@ -285,6 +287,109 @@ export type GetInstanceInfoQuery = {
};
};
export type GetPostByIdQueryVariables = Exact<{
id: Scalars['UUID']['input'];
}>;
export type GetPostByIdQuery = {
__typename?: 'RootQuery';
getPostById: {
__typename?: 'Post';
id: any;
subject?: string | null;
content: string;
account: {
__typename?: 'Account';
id: any;
displayName?: string | null;
username: string;
url: string;
avatar?: { __typename?: 'MediaAttachment'; url: string } | null;
};
attachments: Array<{
__typename?: 'MediaAttachment';
contentType: string;
description?: string | null;
url: string;
}>;
};
};
export type GetHomeTimelineQueryVariables = Exact<{
after: Scalars['String']['input'];
}>;
export type GetHomeTimelineQuery = {
__typename?: 'RootQuery';
homeTimeline: {
__typename?: 'PostConnection';
nodes: Array<{
__typename?: 'Post';
id: any;
subject?: string | null;
content: string;
url: string;
account: {
__typename?: 'Account';
id: any;
displayName?: string | null;
username: string;
url: string;
avatar?: { __typename?: 'MediaAttachment'; url: string } | null;
};
attachments: Array<{
__typename?: 'MediaAttachment';
contentType: string;
description?: string | null;
url: string;
}>;
}>;
pageInfo: {
__typename?: 'PageInfo';
startCursor?: string | null;
endCursor?: string | null;
};
};
};
export type GetPublicTimelineQueryVariables = Exact<{
after: Scalars['String']['input'];
onlyLocal: Scalars['Boolean']['input'];
}>;
export type GetPublicTimelineQuery = {
__typename?: 'RootQuery';
publicTimeline: {
__typename?: 'PostConnection';
nodes: Array<{
__typename?: 'Post';
id: any;
subject?: string | null;
content: string;
url: string;
account: {
__typename?: 'Account';
id: any;
displayName?: string | null;
username: string;
url: string;
avatar?: { __typename?: 'MediaAttachment'; url: string } | null;
};
attachments: Array<{
__typename?: 'MediaAttachment';
contentType: string;
description?: string | null;
url: string;
}>;
}>;
pageInfo: {
__typename?: 'PageInfo';
startCursor?: string | null;
endCursor?: string | null;
};
};
};
export type RegisterOauthApplicationMutationVariables = Exact<{
name: Scalars['String']['input'];
redirect_uri: Scalars['String']['input'];
@ -430,19 +535,6 @@ export const GetInstanceInfoDocument = {
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'domain' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'localPostCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'registrationsOpen' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'userCount' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'captcha' },
@ -457,6 +549,23 @@ export const GetInstanceInfoDocument = {
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'characterLimit' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'description' } },
{ kind: 'Field', name: { kind: 'Name', value: 'domain' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'localPostCount' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'registrationsOpen' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'userCount' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
],
},
},
@ -468,6 +577,455 @@ export const GetInstanceInfoDocument = {
GetInstanceInfoQuery,
GetInstanceInfoQueryVariables
>;
export const GetPostByIdDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'getPostById' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } },
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'UUID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'getPostById' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'id' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'id' },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'subject' } },
{ kind: 'Field', name: { kind: 'Name', value: 'content' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'account' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'displayName' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'username' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'avatar' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'url' },
},
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'attachments' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'contentType' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'description' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
],
},
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<GetPostByIdQuery, GetPostByIdQueryVariables>;
export const GetHomeTimelineDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'getHomeTimeline' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'after' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'homeTimeline' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'after' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'after' },
},
},
],
directives: [
{
kind: 'Directive',
name: { kind: 'Name', value: '_relayPagination' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'mergeMode' },
value: {
kind: 'StringValue',
value: 'after',
block: false,
},
},
],
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'nodes' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'subject' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'content' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'account' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'id' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'avatar' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'url' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'displayName' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'username' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'url' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'attachments' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'contentType' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'description' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'url' },
},
],
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'pageInfo' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'startCursor' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'endCursor' },
},
],
},
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
GetHomeTimelineQuery,
GetHomeTimelineQueryVariables
>;
export const GetPublicTimelineDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'getPublicTimeline' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'after' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'String' },
},
},
},
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'onlyLocal' },
},
type: {
kind: 'NonNullType',
type: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Boolean' },
},
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'publicTimeline' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'after' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'after' },
},
},
{
kind: 'Argument',
name: { kind: 'Name', value: 'onlyLocal' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'onlyLocal' },
},
},
],
directives: [
{
kind: 'Directive',
name: { kind: 'Name', value: '_relayPagination' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'mergeMode' },
value: {
kind: 'StringValue',
value: 'after',
block: false,
},
},
],
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'nodes' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'subject' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'content' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'url' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'account' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'id' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'avatar' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'url' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'displayName' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'username' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'url' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'attachments' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'contentType' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'description' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'url' },
},
],
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'pageInfo' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'startCursor' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'endCursor' },
},
],
},
},
],
},
},
],
},
},
],
} as unknown as DocumentNode<
GetPublicTimelineQuery,
GetPublicTimelineQueryVariables
>;
export const RegisterOauthApplicationDocument = {
kind: 'Document',
definitions: [

View File

@ -0,0 +1,22 @@
export default {
messages: {
mainPage: {
aboutInstance: "Deets 'bout this megacorp",
},
newPost: {
title: 'New Transmission',
},
notFoundPage: {
main: 'Seems like ya wandered behind the blackwall, choom',
link: 'Back to the main page',
},
timeline: {
title: 'Incoming Transmissions',
},
},
stats: {
instance: 'megacorp | megacorps',
post: 'transmission | transmissions',
user: 'choom | chooms',
},
};

23
kitsune-fe/src/i18n/en.ts Normal file
View File

@ -0,0 +1,23 @@
export default {
messages: {
mainPage: {
aboutInstance: 'About this instance',
},
newPost: {
title: 'New Post',
},
notFoundPage: {
main: 'Whoops! It seems that you are lost',
link: 'Back to the main page',
},
timeline: {
title: 'Posts',
},
},
stats: {
title: 'statistics',
instance: 'instance | instances',
post: 'post | posts',
user: 'user | users',
},
};

View File

@ -0,0 +1,32 @@
import { usePreferredLanguages } from '@vueuse/core';
import { computed, watch } from 'vue';
import { createI18n, useI18n } from 'vue-i18n';
import en from './en';
import enCyberpunk from './en-cyberpunk';
const messages = {
en,
'en-cyberpunk': enCyberpunk,
};
const preferredLanguages = usePreferredLanguages();
const preferredLanguage = computed(
() => (preferredLanguages.value[0] ?? 'en').split('-')[0],
);
watch(preferredLanguage, (newPreferredLanguage) => {
const i18n = useI18n();
if (newPreferredLanguage in i18n.availableLocales) {
i18n.locale.value = newPreferredLanguage;
}
});
const i18n = createI18n({
locale: preferredLanguage.value,
fallbackLocale: 'en',
messages,
});
export { i18n };

View File

@ -7,15 +7,20 @@ import urql from '@urql/vue';
import { createPinia } from 'pinia';
import piniaPluginPersistedState from 'pinia-plugin-persistedstate';
import { createHead } from 'unhead';
import { createApp } from 'vue';
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css';
import App from './App.vue';
import { i18n } from './i18n';
import './icons';
import { router } from './router';
import './styles/root.scss';
import { urqlClient } from './urql';
import { zxcvbnRule, zxcvbnValidationMessage } from './zxcvbn';
createHead(); // We need to initialize `unhead` somewhere near the entry point, so yeah
const pinia = createPinia().use(piniaPluginPersistedState);
createApp(App)
@ -35,6 +40,7 @@ createApp(App)
},
}),
)
.use(i18n)
.use(pinia)
.use(router)
.use(urql, urqlClient)

View File

@ -1,29 +1,77 @@
import { useHead } from 'unhead';
import { createRouter, createWebHistory } from 'vue-router';
import { TEMPLATE_PARAMS, TITLE_TEMPLATE } from './consts';
const routes = [
{ path: '/', component: () => import('./views/MainPage.vue') },
{ path: '/about', component: () => import('./views/AboutPage.vue') },
{ path: '/messages', component: () => import('./views/MainPage.vue') },
{ path: '/notifications', component: () => import('./views/MainPage.vue') },
{
path: '/about',
component: () => import('./views/AboutPage.vue'),
meta: { title: 'About' },
},
{
path: '/messages',
component: () => import('./views/MessagePage.vue'),
meta: { title: 'Messages' },
},
{
path: '/notifications',
component: () => import('./views/NotificationPage.vue'),
meta: { title: 'Notifications' },
},
{
path: '/timeline',
children: [
{ path: 'home', component: () => import('./views/MainPage.vue') },
{ path: 'local', component: () => import('./views/MainPage.vue') },
{ path: 'federated', component: () => import('./views/MainPage.vue') },
{
path: 'home',
component: () => import('./views/timeline/HomePage.vue'),
meta: { title: 'Home' },
},
{
path: 'local',
component: () => import('./views/timeline/LocalPage.vue'),
meta: { title: 'Local' },
},
{
path: 'federated',
component: () => import('./views/timeline/FederatedPage.vue'),
meta: { title: 'Federated' },
},
],
},
{
path: '/oauth-callback',
component: () => import('./views/OAuthCallback.vue'),
},
{
path: '/posts/:id',
component: () => import('./views/PostPage.vue'),
meta: { title: 'Thread view' },
},
{
path: '/:catchAll(.*)',
component: () => import('./views/NotFound.vue'),
meta: { title: '404 Not found' },
},
];
export const router = createRouter({
const router = createRouter({
history: createWebHistory(),
routes,
});
router.afterEach((to) => {
const title = to.meta.title;
if (title) {
useHead({
title,
titleTemplate: TITLE_TEMPLATE,
templateParams: TEMPLATE_PARAMS,
});
} else {
useHead({ title: TEMPLATE_PARAMS.siteName, titleTemplate: '%s' });
}
});
export { router };

View File

@ -0,0 +1,9 @@
// Source: Tailwind <https://tailwindcss.com/docs/screens>
$breakpoints: (
sm: 640px,
md: 768px,
lg: 1024px,
xl: 1280px,
xxl: 1536px,
);

View File

@ -1,6 +1,6 @@
// COPYRIGHT INFO
@font-face {
font-family: 'BlockZone';
src: url('/BlockZone.ttf') format('truetype');
font-family: 'BlockZone';
}

View File

@ -1,5 +1,8 @@
@use './breakpoints' as *;
@use 'sass:map';
@mixin only-on-mobile {
@media only screen and (max-width: 850px) {
@media only screen and (max-width: map.get($breakpoints, md)) {
@content;
}
}

View File

@ -2,18 +2,19 @@
@use 'fonts' as *;
* {
font-family: BlockZone;
// font-family: BlockZone;
box-sizing: inherit;
font-family: 'Courier New', Courier, monospace;
}
:root {
font-size: 16px;
line-height: 24px;
font-weight: 400;
background-color: $dark1;
color: white;
color-scheme: light dark;
color: white;
background-color: $dark1;
font-weight: 400;
font-size: 16px;
line-height: 24px;
font-synthesis: none;
-webkit-font-smoothing: antialiased;
@ -24,8 +25,8 @@
}
a {
font-weight: 500;
color: $shade2light;
font-weight: 500;
text-decoration: inherit;
}

View File

@ -1,10 +1,9 @@
import {
Client,
Exchange,
cacheExchange,
fetchExchange,
mapExchange,
} from '@urql/vue';
Directive,
cacheExchange as createCacheExchange,
} from '@urql/exchange-graphcache';
import { relayPagination } from '@urql/exchange-graphcache/extras';
import { Client, Exchange, fetchExchange, mapExchange } from '@urql/vue';
import { merge } from 'lodash';
@ -30,6 +29,12 @@ const authExchange: Exchange = mapExchange({
},
});
const cacheExchange: Exchange = createCacheExchange({
directives: {
relayPagination: <Directive>relayPagination,
},
});
export const urqlClient = new Client({
url: `${BACKEND_PREFIX}/graphql`,
exchanges: [authExchange, cacheExchange, fetchExchange],

View File

@ -14,12 +14,10 @@
<!-- eslint-disable-next-line vue/no-v-html -->
<span v-html="instanceData?.description" />
</fieldset>
<GenericFooter />
</div>
</template>
<script setup lang="ts">
import GenericFooter from '../components/GenericFooter.vue';
import { useInstanceInfo } from '../graphql/instance-info';
const instanceData = useInstanceInfo();
@ -32,8 +30,8 @@
}
.about-page {
max-width: 80ch;
margin: auto;
max-width: 80ch;
& .title {
text-align: center;

View File

@ -9,39 +9,56 @@
</svg>
</h2>
<p>
<div>
<span class="stat-highlight">
{{ instanceInfo?.name }}
</span>
is home to
<span class="stat-highlight">
{{ instanceInfo?.userCount }}
</span>
users who authored
<span class="stat-highlight">
{{ instanceInfo?.localPostCount }}
</span>
posts!
</p>
{{ $t('stats.title') }}:
<ul>
<li>
<span class="stat-highlight">
{{ instanceInfo?.userCount }}
</span>
{{ $tc('stats.user', instanceInfo?.userCount ?? 0) }}
</li>
<li>
<span class="stat-highlight">
{{ instanceInfo?.localPostCount }}
</span>
{{ $tc('stats.post', instanceInfo?.localPostCount ?? 0) }}
</li>
</ul>
</div>
<strong class="about-link">
<router-link to="/about">About this instance</router-link>
<router-link to="/about">
{{ $t('messages.mainPage.aboutInstance') }}
</router-link>
</strong>
</div>
<AuthForms />
</div>
<GenericFooter />
</div>
</template>
<script setup lang="ts">
import AuthForms from '../components/AuthForms.vue';
import GenericFooter from '../components/GenericFooter.vue';
import { useInstanceInfo } from '../graphql/instance-info';
import { onMounted } from 'vue';
import { useRouter } from 'vue-router';
import AuthForms from '../components/AuthForms.vue';
import { useInstanceInfo } from '../graphql/instance-info';
import { useAuthStore } from '../store/auth';
const authStore = useAuthStore();
const instanceInfo = useInstanceInfo();
onMounted(async () => {
if (authStore.isAuthenticated()) {
const router = useRouter();
router.replace('/timeline/home');
}
});
</script>
<style scoped lang="scss">
@ -51,16 +68,16 @@
&-container {
display: flex;
align-items: center;
height: 80vh;
width: 95vw;
margin: 0 auto;
padding: 0 4vw;
width: 95vw;
height: 80vh;
@media only screen and (max-width: 1023px) {
flex-direction: column;
height: auto;
justify-content: center;
padding: 3vh 4vw;
height: auto;
}
}
@ -68,42 +85,43 @@
display: flex;
flex-direction: column;
justify-content: center;
padding: 1vh 2vw;
width: 60%;
height: auto;
padding: 1vh 2vw;
@media only screen and (max-width: 1367px) {
width: 55%;
}
@media only screen and (max-width: 1023px) {
width: 75%;
margin-bottom: 4vh;
width: 75%;
text-align: center;
}
& .stat-highlight {
display: inline;
color: $shade1dark;
}
&-header {
font-size: 42px;
font-weight: bold;
color: $shade2light;
font-weight: bold;
font-size: 42px;
&-logo {
color: $shade2light;
width: 500px;
max-width: 100%;
color: $shade2light;
}
}
&-description,
&-more {
margin: 10px 0;
width: fit-content;
font-size: 18px;
line-height: 143%;
margin: 10px 0;
}
}
}

View File

@ -0,0 +1,3 @@
<template></template>
<script lang="ts" setup></script>

View File

@ -1,91 +1,33 @@
<template>
<div>
<!-- ADD BACKGROUND IMAGE AS A <img> ELEMENT -->
<div class="main-container">
<div class="main-intro">
<h2 class="main-intro-header">
<svg class="main-intro-header-logo">
<use xlink:href="/header.svg#logo" />
</svg>
</h2>
<div class="container">
<GlitchedElement>
<h1>404</h1>
</GlitchedElement>
<p>Whoops! It seems that you are lost</p>
<p>
{{ $t('messages.notFoundPage.main') }}
</p>
<strong class="about-link">
<router-link to="/">Back to the main page</router-link>
</strong>
</div>
</div>
<GenericFooter />
<strong class="about-link">
<router-link to="/">
{{ $t('messages.notFoundPage.link') }}
</router-link>
</strong>
</div>
</template>
<script setup lang="ts">
import GenericFooter from '../components/GenericFooter.vue';
<script lang="ts" setup>
import GlitchedElement from '../components/GlitchedElement.vue';
</script>
<style scoped lang="scss">
@use '../styles/colours' as *;
.main {
&-container {
display: flex;
align-items: center;
height: 80vh;
width: 95vw;
margin: 0 auto;
padding: 0 4vw;
@media only screen and (max-width: 1023px) {
flex-direction: column;
height: auto;
justify-content: center;
padding: 3vh 4vw;
}
}
&-intro {
display: flex;
flex-direction: column;
justify-content: center;
width: 60%;
height: auto;
padding: 1vh 2vw;
@media only screen and (max-width: 1367px) {
width: 55%;
}
@media only screen and (max-width: 1023px) {
width: 75%;
margin-bottom: 4vh;
text-align: center;
}
& .stat-highlight {
color: $shade1dark;
}
&-header {
font-size: 42px;
font-weight: bold;
color: $shade2light;
&-logo {
color: $shade2light;
width: 500px;
max-width: 100%;
}
}
&-description,
&-more {
width: fit-content;
font-size: 18px;
line-height: 143%;
margin: 10px 0;
}
}
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 75vh;
}
</style>

View File

@ -0,0 +1,3 @@
<template></template>
<script lang="ts" setup></script>

View File

@ -0,0 +1,29 @@
<template>
<div class="post-view">
<Post
v-if="postData"
:id="postData.id"
:subject="postData.subject"
:content="postData.content"
:account="postData.account"
:attachments="postData.attachments"
/>
</div>
</template>
<script lang="ts" setup>
import { useRoute } from 'vue-router';
import Post from '../components/Post.vue';
import { getPostById } from '../graphql/post';
const route = useRoute();
const postData = getPostById(route.params.id as string);
</script>
<style lang="scss" scoped>
.post-view {
margin: auto;
max-width: 100ch;
}
</style>

View File

@ -0,0 +1,24 @@
<template>
<BaseTimeline :posts="posts" :load-more="loadMore" />
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import BaseTimeline from '../../components/BaseTimeline.vue';
import { Post } from '../../components/Post.vue';
import { MAX_UUID } from '../../consts';
import { getPublic } from '../../graphql/timeline';
const posts = ref<Post[]>([]);
const lastPostId = ref<string>(MAX_UUID);
const localTimelineQuery = getPublic(lastPostId, false);
watch(localTimelineQuery, (newTimelineQuery) => {
posts.value = newTimelineQuery?.publicTimeline.nodes ?? [];
});
async function loadMore(): Promise<void> {
lastPostId.value = posts.value[posts.value.length - 1].id;
}
</script>

View File

@ -0,0 +1,24 @@
<template>
<BaseTimeline :posts="posts" :load-more="loadMore" />
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import BaseTimeline from '../../components/BaseTimeline.vue';
import { Post } from '../../components/Post.vue';
import { MAX_UUID } from '../../consts';
import { getHome } from '../../graphql/timeline';
const posts = ref<Post[]>([]);
const lastPostId = ref<string>(MAX_UUID);
const homeTimelineQuery = getHome(lastPostId);
watch(homeTimelineQuery, (newTimelineQuery) => {
posts.value = newTimelineQuery?.homeTimeline.nodes ?? [];
});
async function loadMore(): Promise<void> {
lastPostId.value = posts.value[posts.value.length - 1].id;
}
</script>

View File

@ -0,0 +1,24 @@
<template>
<BaseTimeline :posts="posts" :load-more="loadMore" />
</template>
<script lang="ts" setup>
import { ref, watch } from 'vue';
import BaseTimeline from '../../components/BaseTimeline.vue';
import { Post } from '../../components/Post.vue';
import { MAX_UUID } from '../../consts';
import { getPublic } from '../../graphql/timeline';
const posts = ref<Post[]>([]);
const lastPostId = ref<string>(MAX_UUID);
const localTimelineQuery = getPublic(lastPostId, true);
watch(localTimelineQuery, (newTimelineQuery) => {
posts.value = newTimelineQuery?.publicTimeline.nodes ?? [];
});
async function loadMore(): Promise<void> {
lastPostId.value = posts.value[posts.value.length - 1].id;
}
</script>

1
kitsune-fe/src/virtual-scroller.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module 'vue-virtual-scroller';

View File

@ -7,7 +7,7 @@ import { merge } from 'lodash';
let message: string | undefined;
export const zxcvbnValidationMessage = () => {
return message ? message : '';
return message || '';
};
export const zxcvbnRule: FormKitValidationRule = merge(

File diff suppressed because it is too large Load Diff

View File

@ -14,6 +14,6 @@ kitsune-retry-policies = { path = "../crates/kitsune-retry-policies" }
mimalloc = "0.1.39"
tokio = { version = "1.33.0", features = ["full"] }
toml = "0.8.2"
tracing = "0.1.39"
tracing = "0.1.40"
[features]

View File

@ -62,7 +62,7 @@ simd-json = "0.12.0"
speedy-uuid = { path = "../lib/speedy-uuid" }
strum = { version = "0.25.0", features = ["derive", "phf"] }
tempfile = "3.8.0"
thiserror = "1.0.49"
thiserror = "1.0.50"
time = "0.3.30"
tokio = { version = "1.33.0", features = ["full"] }
tokio-util = { version = "0.7.9", features = ["compat"] }
@ -73,10 +73,10 @@ tower-http = { version = "0.4.4", features = [
"timeout",
"trace",
] }
tracing = "0.1.39"
tracing = "0.1.40"
tracing-error = "0.2.0"
tracing-subscriber = "0.3.17"
typed-builder = "0.17.0"
typed-builder = "0.18.0"
url = "2.4.1"
utoipa = { version = "4.0.0", features = ["axum_extras", "uuid"] }
utoipa-swagger-ui = { version = "4.0.0", features = ["axum"] }
@ -84,7 +84,7 @@ utoipa-swagger-ui = { version = "4.0.0", features = ["axum"] }
# --- Optional dependencies ---
# "graphql" feature
async-graphql = { version = "6.0.7", default-features = false, features = [
async-graphql = { version = "6.0.9", default-features = false, features = [
"playground",
"tempfile",
"time",
@ -92,7 +92,7 @@ async-graphql = { version = "6.0.7", default-features = false, features = [
"unblock",
"uuid",
], optional = true }
async-graphql-axum = { version = "6.0.7", optional = true }
async-graphql-axum = { version = "6.0.9", optional = true }
# "metrics" feature
axum-prometheus = { version = "0.4.0", optional = true }

View File

@ -1,68 +0,0 @@
use axum::handler::Handler;
use futures_util::{future::Either, FutureExt};
use http::{header::ACCEPT, Request};
use mime::APPLICATION_JSON;
const APPLICATION_ACTIVITY_JSON: &str = "application/activity+json";
const APPLICATION_LD_JSON: &str = "application/ld+json";
/// Conditional wrapper around two handlers
///
/// If the conditional wrapper returns `true`, the left future is invoked.
/// Otherwise the right future is invoked.
#[derive(Clone)]
pub struct ConditionalWrapper<C, L, R> {
condition: C,
left: L,
right: R,
}
impl<C, L, R> ConditionalWrapper<C, L, R> {
pub fn new(condition: C, left: L, right: R) -> Self {
Self {
condition,
left,
right,
}
}
}
impl<C, L, R, T, S, B> Handler<T, S, B> for ConditionalWrapper<C, L, R>
where
C: Fn(&Request<B>) -> bool + Clone + Send + 'static,
L: Clone + Handler<T, S, B> + Send + 'static,
R: Clone + Handler<T, S, B> + Send + 'static,
{
type Future = Either<L::Future, R::Future>;
fn call(self, req: Request<B>, state: S) -> Self::Future {
if (self.condition)(&req) {
self.left.call(req, state).left_future()
} else {
self.right.call(req, state).right_future()
}
}
}
pub fn html<B, L, R>(
left: L,
right: R,
) -> ConditionalWrapper<impl Fn(&Request<B>) -> bool + Clone + Send + 'static, L, R> {
let cond = |req: &Request<B>| {
req.headers()
.get(ACCEPT)
.and_then(|header| {
header
.to_str()
.map(|value| {
!(value.contains(APPLICATION_JSON.as_ref())
|| value.contains(APPLICATION_ACTIVITY_JSON)
|| value.contains(APPLICATION_LD_JSON))
})
.ok()
})
.unwrap_or(true)
};
ConditionalWrapper::new(cond, left, right)
}

View File

@ -15,6 +15,7 @@ impl InstanceQuery {
let url_service = &state.service().url;
let captcha = state.service().captcha.backend.clone().map(Into::into);
let character_limit = instance_service.character_limit();
let description = instance_service.description().into();
let domain = url_service.webfinger_domain().into();
let local_post_count = instance_service.local_post_count().await?;
@ -24,6 +25,7 @@ impl InstanceQuery {
Ok(Instance {
captcha,
character_limit,
description,
domain,
local_post_count,

View File

@ -34,7 +34,7 @@ impl TimelineQuery {
let get_home = GetHome::builder()
.fetching_account_id(ctx.user_data()?.account.id)
.max_id(after)
.min_id(before);
.since_id(before);
let get_home = if let Some(first) = first {
get_home.limit(first).build()
} else {
@ -76,7 +76,7 @@ impl TimelineQuery {
|after, before, first, _last| async move {
let get_public = GetPublic::builder()
.max_id(after)
.min_id(before)
.since_id(before)
.only_local(only_local);
let get_public = if let Some(first) = first {
get_public.limit(first).build()

View File

@ -30,6 +30,8 @@ impl From<kitsune_captcha::Captcha> for CaptchaInfo {
#[derive(SimpleObject)]
pub struct Instance {
pub captcha: Option<CaptchaInfo>,
pub character_limit: usize,
pub description: String,
pub domain: String,
pub local_post_count: u64,
@ -37,5 +39,4 @@ pub struct Instance {
pub registrations_open: bool,
pub user_count: u64,
pub version: &'static str,
pub captcha: Option<CaptchaInfo>,
}

View File

@ -1,56 +1,11 @@
use crate::{
error::{Error, Result},
http::{
cond,
page::{PostComponent, PostPage},
responder::ActivityPubJson,
},
state::Zustand,
};
use crate::{error::Result, http::responder::ActivityPubJson, state::Zustand};
use axum::{debug_handler, extract::Path, extract::State, routing, Router};
use futures_util::TryStreamExt;
use kitsune_core::{consts::VERSION, mapping::IntoObject, service::post::PostService};
use kitsune_core::{mapping::IntoObject, service::post::PostService};
use kitsune_type::ap::Object;
use speedy_uuid::Uuid;
use std::collections::VecDeque;
mod activity;
#[debug_handler(state = Zustand)]
async fn get_html(
State(state): State<Zustand>,
State(post_service): State<PostService>,
Path(id): Path<Uuid>,
) -> Result<PostPage> {
let post = post_service.get_by_id(id, None).await?;
let ancestors = post_service
.get_ancestors(post.id, None)
.map_err(Error::from)
.try_fold(VecDeque::new(), |mut acc, item| {
let state = &state;
async move {
let item = PostComponent::prepare(state, item).await?;
acc.push_front(item);
Ok(acc)
}
})
.await?;
let descendants = post_service
.get_descendants(post.id, None)
.map_err(Error::from)
.and_then(|item| PostComponent::prepare(&state, item))
.try_collect()
.await?;
Ok(PostPage {
ancestors,
descendants,
post: PostComponent::prepare(&state, post).await?,
version: VERSION,
})
}
#[debug_handler(state = Zustand)]
async fn get(
State(state): State<Zustand>,
@ -63,6 +18,6 @@ async fn get(
pub fn routes() -> Router<Zustand> {
Router::new()
.route("/:id", routing::get(cond::html(get_html, get)))
.route("/:id", routing::get(get))
.route("/:id/activity", routing::get(activity::get))
}

View File

@ -1,30 +1,11 @@
use crate::{
consts::API_DEFAULT_LIMIT,
error::{Error, Result},
http::{
cond,
page::{PostComponent, UserPage},
responder::ActivityPubJson,
},
state::Zustand,
};
use crate::{error::Result, http::responder::ActivityPubJson, state::Zustand};
use axum::{
extract::{Path, Query, State},
extract::{Path, State},
routing::{self, post},
Router,
};
use futures_util::{future::OptionFuture, TryStreamExt};
use kitsune_core::{
error::ApiError,
mapping::IntoObject,
service::{
account::{AccountService, GetPosts},
attachment::AttachmentService,
url::UrlService,
},
};
use kitsune_core::{error::ApiError, mapping::IntoObject, service::account::AccountService};
use kitsune_type::ap::actor::Actor;
use serde::Deserialize;
use speedy_uuid::Uuid;
mod followers;
@ -32,68 +13,10 @@ mod following;
mod inbox;
mod outbox;
#[derive(Deserialize)]
struct PageQuery {
min_id: Option<Uuid>,
max_id: Option<Uuid>,
}
async fn get_html(
State(state): State<Zustand>,
State(account_service): State<AccountService>,
State(attachment_service): State<AttachmentService>,
State(url_service): State<UrlService>,
Path(account_id): Path<Uuid>,
Query(query): Query<PageQuery>,
) -> Result<UserPage> {
let account = account_service
.get_by_id(account_id)
.await?
.ok_or(ApiError::NotFound)?;
let get_posts = GetPosts::builder()
.account_id(account.id)
.max_id(query.max_id)
.min_id(query.min_id)
.limit(API_DEFAULT_LIMIT)
.build();
let posts = account_service
.get_posts(get_posts)
.await?
.map_err(Error::from)
.and_then(|post| PostComponent::prepare(&state, post))
.try_collect()
.await?;
let mut acct = format!("@{}", account.username);
if !account.local {
acct.push('@');
acct.push_str(&account.domain);
}
let profile_picture_url =
OptionFuture::from(account.avatar_id.map(|id| attachment_service.get_url(id)))
.await
.transpose()?;
Ok(UserPage {
acct,
display_name: account.display_name.unwrap_or(account.username),
profile_picture_url: profile_picture_url
.unwrap_or_else(|| url_service.default_avatar_url()),
bio: account.note.unwrap_or_default(),
posts,
})
}
async fn get(
State(state): State<Zustand>,
State(account_service): State<AccountService>,
_: State<AttachmentService>, // Needed to get the same types for the conditional routing
_: State<UrlService>, // Needed to get the same types for the conditional routing
Path(account_id): Path<Uuid>,
_: Query<PageQuery>, // Needed to get the same types for the conditional routing
) -> Result<ActivityPubJson<Actor>> {
let account = account_service
.get_by_id(account_id)
@ -105,7 +28,7 @@ async fn get(
pub fn routes() -> Router<Zustand> {
Router::new()
.route("/:user_id", routing::get(cond::html(get_html, get)))
.route("/:user_id", routing::get(get))
.route("/:user_id/followers", routing::get(followers::get))
.route("/:user_id/following", routing::get(following::get))
.route("/:user_id/inbox", post(inbox::post))

View File

@ -15,13 +15,11 @@ use tower_http::{
};
use utoipa_swagger_ui::SwaggerUi;
mod cond;
#[cfg(feature = "graphql-api")]
mod graphql;
mod handler;
mod middleware;
mod openapi;
mod page;
#[cfg(feature = "mastodon-api")]
mod pagination;
mod responder;

View File

@ -1,121 +0,0 @@
use crate::{
error::{Error, Result},
state::Zustand,
};
use askama::Template;
use diesel::{BelongingToDsl, QueryDsl, SelectableHelper};
use diesel_async::RunQueryDsl;
use futures_util::{future::OptionFuture, TryStreamExt};
use kitsune_core::try_join;
use kitsune_db::{
model::{
account::Account,
media_attachment::{MediaAttachment as DbMediaAttachment, PostMediaAttachment},
post::Post,
},
schema::{accounts, media_attachments},
};
use scoped_futures::ScopedFutureExt;
use std::collections::VecDeque;
pub struct MediaAttachment {
pub content_type: String,
pub description: Option<String>,
pub url: String,
}
#[derive(Template)]
#[template(path = "components/post.html", escape = "none")] // Make sure everything is escaped either on submission or in the template
pub struct PostComponent {
pub display_name: String,
pub acct: String,
pub profile_url: String,
pub profile_picture_url: String,
pub content: String,
pub url: String,
pub attachments: Vec<MediaAttachment>,
}
impl PostComponent {
pub async fn prepare(state: &Zustand, post: Post) -> Result<Self> {
let (author, attachments_stream) = state
.db_pool()
.with_connection(|db_conn| {
async {
let author_fut = accounts::table
.find(post.account_id)
.select(Account::as_select())
.get_result::<Account>(db_conn);
let attachments_stream_fut = PostMediaAttachment::belonging_to(&post)
.inner_join(media_attachments::table)
.select(DbMediaAttachment::as_select())
.load_stream::<DbMediaAttachment>(db_conn);
try_join!(author_fut, attachments_stream_fut)
}
.scoped()
})
.await?;
let attachments = attachments_stream
.map_err(Error::from)
.and_then(|attachment| async move {
let url = state.service().attachment.get_url(attachment.id).await?;
Ok(MediaAttachment {
content_type: attachment.content_type,
description: attachment.description,
url,
})
})
.try_collect()
.await?;
let profile_picture_url = OptionFuture::from(
author
.avatar_id
.map(|id| state.service().attachment.get_url(id)),
)
.await
.transpose()?;
let mut acct = format!("@{}", author.username);
if !author.local {
acct.push('@');
acct.push_str(&author.domain);
}
Ok(Self {
attachments,
display_name: author
.display_name
.unwrap_or_else(|| author.username.clone()),
acct,
profile_url: author.url,
profile_picture_url: profile_picture_url
.unwrap_or_else(|| state.service().url.default_avatar_url()),
content: post.content,
url: post.url,
})
}
}
#[derive(Template)]
#[template(path = "pages/posts.html", escape = "none")]
pub struct PostPage {
pub ancestors: VecDeque<PostComponent>,
pub post: PostComponent,
pub descendants: Vec<PostComponent>,
pub version: &'static str,
}
#[derive(Template)]
#[template(path = "pages/users.html", escape = "none")]
pub struct UserPage {
pub acct: String,
pub display_name: String,
pub profile_picture_url: String,
pub bio: String,
pub posts: Vec<PostComponent>,
}

View File

@ -0,0 +1,24 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/public/template.css">
{% block head %}
{% endblock %}
<title>
{% block title %}
{% endblock %}
</title>
</head>
<body>
{% block body %}
{% endblock %}
</body>
</html>

View File

@ -1,32 +0,0 @@
<div class="post">
<a href='{{ profile_url|escape("html") }}'>
<div class="account-info">
<img class="profile-picture" src='{{ profile_picture_url|escape("html") }}'
alt="{{ display_name }}'s profile picture" />
<div class="account-info__names">
<strong>{{ display_name }}</strong>
{{ acct|escape("html") }}
</div>
</div>
</a>
<a class="post__url" href='{{ url|escape("html") }}'>
<div class="post__content">{{ content }}</div>
</a>
<div class="post__attachments">
{% for attachment in attachments %}
<div title='{{ attachment.description.as_deref().unwrap_or_default()|escape("html") }}'
href='{{ attachment.url.as_str()|escape("html") }}'>
{% if attachment.content_type.starts_with("audio") %}
<audio src='{{ attachment.url|escape("html") }}' />
{% else if attachment.content_type.starts_with("video") %}
<video src='{{ attachment.url|escape("html") }}' controls />
{% else %}
<img src='{{ attachment.url|escape("html") }}'
alt='{{ attachment.description.as_deref().unwrap_or_default()|escape("html") }}' />
{% endif %}
</div>
{% endfor %}
</div>
</div>

View File

@ -1,51 +1,52 @@
<!DOCTYPE html>
<html lang="en">
{% extends "../base.html" %}
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/public/template.css">
<title>OAuth Login</title>
</head>
{% block title %}
OAuth Login
{% endblock %}
{% macro add_query_params() %}
<input type="hidden" name="client_id" value="{{ query.client_id }}" />
<input type="hidden" name="redirect_uri" value="{{ query.redirect_uri }}" />
<input type="hidden" name="response_type" value="{{ query.response_type }}" />
<input type="hidden" name="scope" value="{{ query.scope }}" />
<input type="hidden" name="state" value="{{ query.state.as_deref().unwrap_or("") }}" />
<input type="hidden" name="client_id" value="{{ query.client_id }}" />
<input type="hidden" name="redirect_uri" value="{{ query.redirect_uri }}" />
<input type="hidden" name="response_type" value="{{ query.response_type }}" />
<input type="hidden" name="scope" value="{{ query.scope }}" />
<input type="hidden" name="state" value="{{ query.state.as_deref().unwrap_or("") }}" />
{% endmacro %}
<body>
<p class="header">Kitsune</p>
{% block body %}
<p class="header">
Kitsune
</p>
<div class="consent-text">
<span class="appName">{{app_name}}</span> wants the following access to your Kitsune account:
<p>
<ul>
{% for scope in scopes %}
<li>{{ scope.get_message().unwrap() }}</li>
{% endfor %}
</ul>
<ul>
{% for scope in scopes %}
<li>
{{ scope.get_message().unwrap() }}
</li>
{% endfor %}
</ul>
</p>
</div>
<div class="consent-forms">
<form method="get">
{% call add_query_params() %}
<input type="hidden" name="login_consent" value="accept" />
<input class="formButton" type="submit" value="Accept" />
</form>
<form method="get">
{% call add_query_params() %}
<input type="hidden" name="login_consent" value="deny" />
<input class="formButton" type="submit" value="Deny" />
</form>
</div>
<div class="disclaimer">
Authenticated as <span class="authUsername">{{ authenticated_username }}</span> ~
<a href="#"
onclick="document.cookie='user_id=; expires=Thu, 01 Jan 1970 00:00:00 UTC;';window.navigation.reload();">Logout</a>
onclick="document.cookie='user_id=; expires=Thu, 01 Jan 1970 00:00:00 UTC;';window.location.reload();">Logout</a>
</div>
</body>
</html>
{% endblock %}

View File

@ -1,21 +1,18 @@
<!DOCTYPE html>
<html lang="en">
{% extends "../base.html" %}
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/public/template.css">
<title>OAuth Login</title>
</head>
{% block title %}
OAuth Login
{% endblock %}
<body>
{% block body %}
<p class="header">Kitsune</p>
{% for (_level, msg) in flash_messages %}
<div class="message">
{{ msg }}
</div>
<div class="message">
{{ msg }}
</div>
{% endfor %}
<form method="post">
<div>
<label class="label" for="username">Username</label><br />
@ -25,6 +22,4 @@
</div>
<input class="formButton" type="submit" value="Submit" />
</form>
</body>
</html>
{% endblock %}

View File

@ -1,20 +1,16 @@
<!DOCTYPE html>
<html lang="en">
{% extends "../base.html" %}
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="/public/template.css">
<title>OAuth Token</title>
</head>
{% block title %}
OAuth Token
{% endblock %}
{% block body %}
<p class="header">
{{domain}}
</p>
<body>
<p class="header">{{domain}}</p>
<div class="tokenContainer">
<p>Copy & Paste this token into <span class="appName">{{app_name}}</span></p>
<code class="token">{{token}}</code>
</div>
</body>
</html>
{% endblock %}

View File

@ -1,3 +0,0 @@
<footer class="footer">
Powered by <a target="_blank" href="https://github.com/kitsune-soc/kitsune">Kitsune</a> v{{ version }}
</footer>

View File

@ -1,33 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Post - Kitsune</title>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/public/colours.css" />
<link rel="stylesheet" href="/public/post-style.css" />
</head>
<body>
<div class="posts__ancestors">
{% for ancestor in ancestors %}
{{ ancestor }}
{% endfor %}
</div>
<div class="posts__main">
{{ post }}
</div>
<div class="posts__descendants">
{% for descendant in descendants %}
{{ descendant }}
{% endfor %}
</div>
{% include "footer.html" %}
</body>
</html>

View File

@ -1,28 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>{{ acct|escape("html") }} - Kitsune</title>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/public/colours.css" />
<link rel="stylesheet" href="/public/post-style.css" />
<link rel="stylesheet" href="/public/user-style.css" />
</head>
<body>
<div class="user__header">
<img class="user__profile-picture" src='{{ profile_picture_url|escape("html") }}' />
<div class="user__names">
<strong>{{ display_name }}</strong>
{{ acct|escape("html") }}
</div>
<p class="user__bio">{{ bio }}</p>
</div>
{% for post in posts %}
{{ post }}
{% endfor %}
</body>
</html>

View File

@ -26,10 +26,10 @@ serde = { version = "1.0.189", features = ["derive"] }
simd-json = "0.12.0"
smol_str = "0.2.0"
speedy-uuid = { path = "../speedy-uuid", features = ["redis", "serde"] }
thiserror = "1.0.49"
thiserror = "1.0.50"
tokio = { version = "1.33.0", features = ["macros", "rt", "sync"] }
tracing = "0.1.39"
typed-builder = "0.17.0"
tracing = "0.1.40"
typed-builder = "0.18.0"
[dev-dependencies]
tracing-subscriber = "0.3.17"

View File

@ -4,13 +4,13 @@ edition.workspace = true
version.workspace = true
[dependencies]
async-graphql = { version = "6.0.7", default-features = false, optional = true }
async-graphql = { version = "6.0.9", default-features = false, optional = true }
diesel = { version = "2.1.3", features = [
"postgres_backend",
"uuid",
], optional = true }
redis = { version = "0.23.3", default-features = false, optional = true }
serde = { version = "1.0.189", optional = true }
thiserror = "1.0.49"
thiserror = "1.0.50"
uuid = { version = "1.5.0", features = ["fast-rng", "v7"] }
uuid-simd = { version = "0.8.0", features = ["uuid"] }

View File

@ -1,9 +0,0 @@
:root {
--dark1: #0f1026;
--dark2: #051c30;
--dark3: #042f40;
--shade1dark: #53a0c4;
--shade1light: #afd7fa;
--shade2dark: #935d7e;
--shade2light: #d68fbc;
}

View File

@ -1,69 +0,0 @@
:root {
background-color: var(--dark1);
color: white;
font-family: 'Courier New', Courier, monospace;
}
a {
text-decoration: none;
color: var(--shade2light);
}
body {
width: 100%;
min-height: 100vh;
display: flex;
margin: 0;
padding: 0;
align-items: center;
justify-content: center;
flex-direction: column;
}
.post {
border: 1px solid var(--shade2dark);
border-radius: 5px;
padding: 0.5em;
margin: 0.5em 0;
width: 600px;
max-width: 90%;
min-height: 100px;
}
.posts__main * {
background-color: var(--dark2);
}
.post__content {
word-wrap: break-word;
white-space: pre-wrap;
}
.post__attachments {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.25em;
}
.post__attachments * {
width: 100%;
}
.post__url {
color: white;
}
.account-info {
display: flex;
gap: 0.5em;
align-items: center;
}
.account-info__names {
display: flex;
flex-direction: column;
}
.profile-picture {
width: 60px;
}

View File

@ -1,12 +1,4 @@
@import url("https://fonts.googleapis.com/css2?family=Play:wght@400;700&display=swap");
:root {
--crimson: #ff124f;
--magenta: #ff00a0;
--pink: #fe75fe;
--indigo: #7a04eb;
--darkblue: #120458;
--dark1: #0f1026;
--dark2: #051c30;
--dark3: #042f40;
@ -17,7 +9,7 @@
}
* {
font-family: Play;
font-family: 'Courier New', Courier, monospace;
}
a {

View File

@ -1,27 +0,0 @@
.user__header {
border: 1px solid var(--shade2dark);
border-radius: 5px;
padding: 0.5em;
margin: 0.5em 0;
width: 600px;
max-width: 90%;
background-color: var(--dark2);
}
.user__bio {
word-wrap: break-word;
white-space: pre-wrap;
}
.user__names {
display: flex;
flex-direction: column;
margin-top: 0.5em;
}
.user__profile-picture {
float: left;
width: 160px;
margin-right: 1em;
margin-bottom: 0.1em;
}