added ability to set media class of items
Build and Push Image / build-and-push (push) Successful in 5m4s

This commit is contained in:
2026-03-29 03:19:33 -04:00
parent b8d01b567c
commit b1ab165665
26 changed files with 739 additions and 234 deletions
+69 -4
View File
@@ -22,6 +22,7 @@ name = "aoba-client"
version = "0.1.0"
dependencies = [
"dioxus",
"dioxus-primitives",
"dotenv",
"prost",
"serde",
@@ -555,6 +556,17 @@ dependencies = [
"web-sys",
]
[[package]]
name = "dioxus-attributes"
version = "0.1.0"
source = "git+https://github.com/DioxusLabs/components#ccdb07f69383de008a0afadda0e5ab7ec14c1a9c"
dependencies = [
"dioxus-rsx",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "dioxus-cli-config"
version = "0.7.3"
@@ -663,7 +675,7 @@ dependencies = [
"futures-channel",
"futures-util",
"generational-box",
"lazy-js-bundle",
"lazy-js-bundle 0.7.3",
"serde",
"serde_json",
"tracing",
@@ -813,7 +825,7 @@ dependencies = [
"futures-util",
"generational-box",
"keyboard-types",
"lazy-js-bundle",
"lazy-js-bundle 0.7.3",
"rustversion",
"tracing",
]
@@ -837,7 +849,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a42a7f73ad32a5054bd8c1014f4ac78cca3b7f6889210ee2b57ea31b33b6d32f"
dependencies = [
"js-sys",
"lazy-js-bundle",
"lazy-js-bundle 0.7.3",
"rustc-hash 2.1.1",
"sledgehammer_bindgen",
"sledgehammer_utils",
@@ -858,6 +870,21 @@ dependencies = [
"tracing-wasm",
]
[[package]]
name = "dioxus-primitives"
version = "0.0.1"
source = "git+https://github.com/DioxusLabs/components#ccdb07f69383de008a0afadda0e5ab7ec14c1a9c"
dependencies = [
"dioxus",
"dioxus-attributes",
"dioxus-sdk-time",
"lazy-js-bundle 0.6.2",
"num-integer",
"serde",
"time",
"tracing",
]
[[package]]
name = "dioxus-router"
version = "0.7.3"
@@ -906,6 +933,18 @@ dependencies = [
"syn",
]
[[package]]
name = "dioxus-sdk-time"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "80c25ae93a3f72e734873b97fbd09d9b1b6adff97205fb0ffd8543e3564fb78e"
dependencies = [
"dioxus",
"futures",
"gloo-timers",
"tokio",
]
[[package]]
name = "dioxus-signals"
version = "0.7.3"
@@ -966,7 +1005,7 @@ dependencies = [
"generational-box",
"gloo-timers",
"js-sys",
"lazy-js-bundle",
"lazy-js-bundle 0.7.3",
"rustc-hash 2.1.1",
"send_wrapper",
"serde",
@@ -1647,6 +1686,12 @@ dependencies = [
"bitflags",
]
[[package]]
name = "lazy-js-bundle"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e49596223b9d9d4947a14a25c142a6e7d8ab3f27eb3ade269d238bb8b5c267e2"
[[package]]
name = "lazy-js-bundle"
version = "0.7.3"
@@ -1896,6 +1941,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1927,6 +1981,15 @@ dependencies = [
"syn",
]
[[package]]
name = "num_threads"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9"
dependencies = [
"libc",
]
[[package]]
name = "once_cell"
version = "1.21.3"
@@ -2792,7 +2855,9 @@ checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5"
dependencies = [
"deranged",
"itoa",
"libc",
"num-conv",
"num_threads",
"powerfmt",
"serde_core",
"time-core",
+1
View File
@@ -16,6 +16,7 @@ tonic = { version = "*", default-features = false, features = [
prost = "0.13"
tonic-web-wasm-client = "0.7"
web-sys = { version = "0.3.91", features = ["Storage", "Window"] }
dioxus-primitives = { git = "https://github.com/DioxusLabs/components", version = "0.0.1", default-features = false }
[build-dependencies]
tonic-build = { version = "*", default-features = false, features = ["prost"] }
+83
View File
@@ -0,0 +1,83 @@
/* This file contains the global styles for the styled dioxus components. You only
* need to import this file once in your project root.
*/
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
body {
padding: 0;
margin: 0;
background-color: var(--primary-color);
color: var(--secondary-color-4);
font-family: Inter, sans-serif;
font-optical-sizing: auto;
font-style: normal;
font-weight: 400;
}
@media (prefers-color-scheme: dark) {
:root {
--dark: initial;
--light: ;
}
}
@media (prefers-color-scheme: light) {
:root {
--dark: ;
--light: initial;
}
}
:root {
/* Primary colors */
--primary-color: var(--dark, #000) var(--light, #fff);
--primary-color-1: var(--dark, #0e0e0e) var(--light, #fbfbfb);
--primary-color-2: var(--dark, #0a0a0a) var(--light, #fff);
--primary-color-3: var(--dark, #141313) var(--light, #f8f8f8);
--primary-color-4: var(--dark, #1a1a1a) var(--light, #f8f8f8);
--primary-color-5: var(--dark, #262626) var(--light, #f5f5f5);
--primary-color-6: var(--dark, #232323) var(--light, #e5e5e5);
--primary-color-7: var(--dark, #3e3e3e) var(--light, #b0b0b0);
/* Secondary colors */
--secondary-color: var(--dark, #fff) var(--light, #000);
--secondary-color-1: var(--dark, #fafafa) var(--light, #000);
--secondary-color-2: var(--dark, #e6e6e6) var(--light, #0d0d0d);
--secondary-color-3: var(--dark, #dcdcdc) var(--light, #2b2b2b);
--secondary-color-4: var(--dark, #d4d4d4) var(--light, #111);
--secondary-color-5: var(--dark, #a1a1a1) var(--light, #848484);
--secondary-color-6: var(--dark, #5d5d5d) var(--light, #d0d0d0);
/* Highlight colors */
--focused-border-color: var(--dark, #2b7fff) var(--light, #2b7fff);
--primary-success-color: var(--dark, #02271c) var(--light, #ecfdf5);
--secondary-success-color: var(--dark, #b6fae3) var(--light, #10b981);
--primary-warning-color: var(--dark, #342203) var(--light, #fffbeb);
--secondary-warning-color: var(--dark, #feeac7) var(--light, #f59e0b);
--primary-error-color: var(--dark, #a22e2e) var(--light, #dc2626);
--secondary-error-color: var(--dark, #9b1c1c) var(--light, #ef4444);
--contrast-error-color: var(--dark, var(--secondary-color-3))
var(--light, var(--primary-color));
--primary-info-color: var(--dark, var(--primary-color-5))
var(--light, var(--primary-color));
--secondary-info-color: var(--dark, var(--primary-color-7))
var(--light, var(--secondary-color-3));
}
/* Modern browsers with `scrollbar-*` support */
@supports (scrollbar-width: auto) {
:not(:hover) {
scrollbar-color: rgb(0 0 0 / 0%) rgb(0 0 0 / 0%);
}
:hover {
scrollbar-color: var(--secondary-color-2) rgb(0 0 0 / 0%);
}
}
/* Legacy browsers with `::-webkit-scrollbar-*` support */
@supports selector(::-webkit-scrollbar) {
:root::-webkit-scrollbar-track {
background: transparent;
}
}
@@ -0,0 +1,10 @@
@import "colors";
div[role="menu"] {
backdrop-filter: blur(10px) brightness(0.5) grayscale(1);
display: flex;
flex-direction: column;
max-width: 300px;
width: auto;
outline: none;
width: max-content;
}
+38 -22
View File
@@ -112,6 +112,14 @@ $mediaItemSize: 300px;
&.placeholder {
}
&.nsfw img {
filter: blur(20px);
transition: filter 0.25s ease-out;
}
&.nsfw:hover img {
filter: blur(0px);
}
}
}
@@ -170,34 +178,42 @@ form {
flex-direction: column;
max-width: 300px;
}
}
.contextItem {
border-left: 4px solid $featureColor;
$size: 30px;
display: grid;
grid-template-columns: $size 1fr;
grid-template-areas: "Icon Label";
height: $size;
transition: border 0.1s ease-out;
cursor: default;
width: 100%;
.contextItem {
border-left: 4px solid $featureColor;
$size: 30px;
display: grid;
grid-template-columns: $size 1fr auto;
height: $size;
transition: border 0.1s ease-out;
cursor: default;
&.clickable {
cursor: pointer;
}
&.clickable {
cursor: pointer;
}
.label {
grid-area: Label;
display: flex;
align-items: center;
padding: 5px 10px;
}
.label {
display: flex;
align-items: center;
padding: 5px 10px;
}
.icon {
grid-area: Icon;
display: flex;
justify-content: center;
align-items: center;
}
&:hover {
background-color: $accentColor;
color: $invertTextColor;
border-left: 10px solid $focusColor;
}
&:hover {
background-color: $accentColor;
color: $invertTextColor;
border-left: 10px solid $focusColor;
}
}
.pagination {
display: flex;
justify-content: center;
-97
View File
@@ -1,97 +0,0 @@
use core::str;
use dioxus::prelude::*;
mod props {
use dioxus::prelude::*;
#[derive(PartialEq, Clone, Props)]
pub struct ContextMenu {
pub top: f64,
pub left: f64,
pub items: Element,
}
#[derive(PartialEq, Clone, Props, Default)]
pub struct ContextMenuItem {
pub name: String,
pub sub_items: Option<Element>,
pub onclick: Option<EventHandler<MouseEvent>>,
}
}
#[derive(Clone, Copy, Default)]
pub struct ContextMenuRenderer {
pub menu: Signal<Option<Element>>,
}
impl ContextMenuRenderer {
pub fn close(&mut self) {
self.menu.set(None);
}
pub fn render(&self) -> Element {
if let Some(menu) = self.menu.cloned() {
rsx! {
{menu}
}
} else {
rsx! {}
}
}
}
#[component]
pub fn ContextMenuRoot() -> Element {
let renderer = use_context::<ContextMenuRenderer>();
rsx! {
{renderer.render()}
}
}
#[component]
pub fn ContextMenu(props: props::ContextMenu) -> Element {
rsx! {
div {
class: "contextMenu",
style: "left: {props.left}px; top: {props.top}px;",
ItemList { items: props.items }
}
}
}
#[component]
fn ItemList(items: Element) -> Element {
rsx! {
div{
class: "itemList",
{items}
}
}
}
#[component]
pub fn ContextMenuItem(props: props::ContextMenuItem) -> Element {
let mut renderer = use_context::<ContextMenuRenderer>();
if let Some(_sub) = props.sub_items {
todo!("Sub Menu");
}
rsx! {
div{
onclick: move |e|{
if let Some(handler) = props.onclick{
handler.call(e);
}
renderer.close();
},
class: "contextItem",
div {
class: "icon"
},
div {
class: "label",
{props.name}
}
}
}
}
@@ -0,0 +1,64 @@
use dioxus::prelude::*;
use dioxus_primitives::context_menu::{
self, ContextMenuContentProps, ContextMenuItemProps, ContextMenuProps, ContextMenuTriggerProps,
};
#[component]
pub fn ContextMenu(props: ContextMenuProps) -> Element {
rsx! {
document::Link { rel: "stylesheet", href: asset!("./style.css") }
context_menu::ContextMenu {
disabled: props.disabled,
open: props.open,
default_open: props.default_open,
on_open_change: props.on_open_change,
roving_loop: props.roving_loop,
attributes: props.attributes,
{props.children}
}
}
}
#[component]
pub fn ContextMenuTrigger(props: ContextMenuTriggerProps) -> Element {
rsx! {
context_menu::ContextMenuTrigger {
padding: "20px",
background: "var(--primary-color)",
border: "1px dashed var(--primary-color-6)",
border_radius: ".5rem",
cursor: "context-menu",
user_select: "none",
text_align: "center",
attributes: props.attributes,
{props.children}
}
}
}
#[component]
pub fn ContextMenuContent(props: ContextMenuContentProps) -> Element {
rsx! {
context_menu::ContextMenuContent {
class: "context-menu-content",
id: props.id,
attributes: props.attributes,
{props.children}
}
}
}
#[component]
pub fn ContextMenuItem(props: ContextMenuItemProps) -> Element {
rsx! {
context_menu::ContextMenuItem {
class: "context-menu-item",
disabled: props.disabled,
value: props.value,
index: props.index,
on_select: props.on_select,
attributes: props.attributes,
{props.children}
}
}
}
@@ -0,0 +1,2 @@
mod component;
pub use component::*;
@@ -0,0 +1,71 @@
.context-menu-content {
z-index: 1000;
min-width: 220px;
padding: 0.25rem;
border-radius: 0.5rem;
background: var(--dark, var(--primary-color-5))
var(--light, var(--primary-color));
box-shadow: inset 0 0 0 1px var(--dark, var(--primary-color-7))
var(--light, var(--primary-color-6));
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.context-menu-content[data-state="closed"] {
animation: context-menu-animate-out 150ms ease-in forwards;
}
@keyframes context-menu-animate-out {
0% {
opacity: 1;
transform: scale(1) translateY(0);
}
100% {
opacity: 0;
transform: scale(0.95) translateY(-2px);
}
}
.context-menu-content[data-state="open"] {
animation: context-menu-animate-in 150ms ease-out forwards;
}
@keyframes context-menu-animate-in {
0% {
opacity: 0;
transform: scale(0.95) translateY(-2px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.context-menu-item {
display: flex;
align-items: center;
padding: 8px 12px;
border-radius: calc(0.5rem - 0.25rem);
color: var(--secondary-color-4);
cursor: pointer;
font-size: 14px;
outline: none;
transition: background-color 100ms ease-out;
user-select: none;
}
.context-menu-item[data-disabled="true"] {
color: var(--secondary-color-5);
cursor: not-allowed;
}
.context-menu-item:hover:not([data-disabled="true"]),
.context-menu-item:focus-visible {
background: var(--light, var(--primary-color-4))
var(--dark, var(--primary-color-7));
color: var(--light, var(--secondary-color-1))
var(--dark, var(--secondary-color-4));
}
+153 -55
View File
@@ -1,84 +1,171 @@
use dioxus::prelude::*;
use dioxus_primitives::context_menu::{ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger};
use tonic::{Response, Status};
use web_sys::window;
use crate::{
HOST,
components::{ContextMenu, ContextMenuItem, ContextMenuRenderer},
rpc::aoba::MediaModel,
route::Route,
rpc::{
aoba::{Id, MediaClass, MediaModel, SetMediaClassRequest},
get_rpc_client,
},
};
#[derive(PartialEq, Clone, Props)]
pub struct MediaItemProps {
pub struct MediaItemProps
{
pub item: Option<MediaModel>,
// pub oncontextmenu: Option<EventHandler<Event<MouseData>>>,
}
#[component]
pub fn MediaItem(props: MediaItemProps) -> Element {
let mut ct_renderer = use_context::<ContextMenuRenderer>();
if let Some(item) = props.item {
pub fn MediaItem(props: MediaItemProps) -> Element
{
let mut class_signal = use_signal(|| "");
if let Some(item) = props.item
{
let mtype = item.media_type().as_str_name();
let filename = item.file_name;
let id = item.id.unwrap().value;
let thumb = item.thumb_url;
let class = item.class;
let url = item.media_url;
let download = format!("{HOST}{url}");
let oncontext = move |event: Event<MouseData>| {
println!("ContextMenu");
event.prevent_default();
event.stop_propagation();
let data = event.data();
if data.modifiers().ctrl() {
return;
}
let pos = data.coordinates().client();
let left = pos.x;
let top = pos.y;
let download = download.clone();
let menu: Element = rsx! {
ContextMenu {
left: left,
top: top,
items: rsx! {
ContextMenuItem {
name: "Details",
},
ContextMenuItem {
name: "Download",
onclick: move |_|{
_ = window().unwrap().open_with_url_and_target(&download, "_blank");
}
},
ContextMenuItem {
name: "Delete",
},
},
}
};
ct_renderer.menu.set(Some(menu));
match class
{
1 => class_signal.set("nsfw"),
2 => class_signal.set("secret"),
_ => class_signal.set(""),
};
return rsx! {
a {
class: "mediaItem",
href: "{HOST}{url}",
target: "_blank",
oncontextmenu: oncontext,
"data-id" : id,
img { src: "{HOST}{thumb}" }
span { class: "info",
span { class: "name", "{filename}" }
span { class: "details",
span { "{mtype}" }
span { "{item.view_count}" }
ContextMenu{
ContextMenuTrigger{
a {
class: "mediaItem {class_signal()}",
href: "{HOST}{url}",
target: "_blank",
"data-id" : id.clone(),
img { src: "{HOST}{thumb}" }
span { class: "info",
span { class: "name", "{filename}" }
span { class: "details",
span { "{mtype}" }
span { "{item.view_count}" }
}
}
},
},
ContextMenuContent{
ContextMenuItem {
index: 0 as usize,
value: id.clone(),
on_select: move |id: String|{
window().expect("Failed to get window")
.location().set_href(&format!("/media/{}", id))
.expect("Failed to open Url");
},
div{
class: "contextItem",
div{
class: "label",
"Details"
}
}
},
ContextMenuItem {
index: 1 as usize,
value: "{download}",
on_select: move |url: String|{
window().expect("Failed to get window").open_with_url_and_target(&url, "_blank").expect("Failed to open url");
},
div{
class: "contextItem",
div{
class: "label",
"Download"
}
}
},
{
if class != 0 {
rsx!{ContextMenuItem {
index: 2 as usize,
value: "{id}",
on_select: async |id: String|{
_ = set_class(id, 0).await;
},
div{
class: "contextItem",
div{
class: "label",
"Mark Standard"
}
}
}}
}else{
rsx!{}
}
}
{
if class != 1 {
rsx!{ContextMenuItem {
index: 2 as usize,
value: "{id}",
on_select: async |id: String|{
_ = set_class(id, 1).await;
},
div{
class: "contextItem",
div{
class: "label",
"Mark NSFW"
}
}
}}
}else{
rsx!{}
}
}
{
if class != 1 {
rsx!{ContextMenuItem {
index: 2 as usize,
value: "{id}",
on_select: async |id: String|{
_ = set_class(id, 2).await;
},
div{
class: "contextItem",
div{
class: "label",
"Mark Secret"
}
}
}}
}else{
rsx!{}
}
}
ContextMenuItem {
index: 2 as usize,
value: "",
div{
class: "contextItem",
div{
class: "label",
"Delete"
}
}
},
}
},
}
};
} else {
}
else
{
return rsx! {
div { class: "mediaItem placeholder",
img { },
@@ -93,3 +180,14 @@ pub fn MediaItem(props: MediaItemProps) -> Element {
};
}
}
async fn set_class(id: String, class: i32) -> Result<Response<()>, Status>
{
let mut client = get_rpc_client();
return client
.set_media_class(SetMediaClassRequest {
class: class,
id: Some(Id { value: id }),
})
.await;
}
+3 -2
View File
@@ -1,5 +1,5 @@
pub mod basic;
mod context_menu;
// mod context_menu;
mod icons;
mod media_grid;
mod media_item;
@@ -9,7 +9,7 @@ mod notif;
mod pagination;
mod passkey;
mod search;
pub use context_menu::*;
// pub use context_menu::*;
pub use media_grid::*;
pub use media_item::*;
pub use metrics_token::*;
@@ -18,3 +18,4 @@ pub use notif::*;
pub use pagination::*;
pub use passkey::*;
pub use search::*;
pub mod radio_group;
+32 -5
View File
@@ -1,7 +1,9 @@
use dioxus::prelude::*;
use web_sys::window;
#[component]
pub fn Pagination(page: Signal<i32>, max_page: Signal<i32>, item_count: Signal<i32>) -> Element {
pub fn Pagination(page: Signal<i32>, max_page: Signal<i32>, item_count: Signal<i32>) -> Element
{
let cur_page_val = page.cloned();
let max_page_val = max_page.cloned();
let item_count_val = item_count.cloned();
@@ -9,22 +11,47 @@ pub fn Pagination(page: Signal<i32>, max_page: Signal<i32>, item_count: Signal<i
div {
class: "pagination",
a {
onclick: move|_| page.set(1),
onclick: move|_| {
page.set(1);
on_page_change();
},
"First"
}
a {
onclick: move|_| page.set((cur_page_val - 1).max(1)),
onclick: move|_| {
let p = (cur_page_val - 1).max(1);
page.set(p);
on_page_change();
},
"Prev"
}
div { "Page {cur_page_val} of {max_page_val} ({item_count_val} Media Items)" }
a {
onclick: move|_| page.set((cur_page_val + 1).min(max_page_val)),
onclick: move|_| {
let p = (cur_page_val + 1).min(max_page_val);
page.set(p);
on_page_change();
},
"Next"
}
a {
onclick: move|_| page.set(max_page_val),
onclick: move|_| {
page.set(max_page_val);
on_page_change();
},
"Last"
}
}
}
}
fn on_page_change()
{
let window = window().expect("Failed to get window");
let document = window.document().expect("Failed to get document");
document
.query_selector("#content")
.expect("Failed to find content")
.expect("Failed to find content")
.scroll_to_with_x_and_y(0.0, 0.0);
}
@@ -0,0 +1,36 @@
use dioxus::prelude::*;
use dioxus_primitives::radio_group::{self, RadioGroupProps, RadioItemProps};
#[component]
pub fn RadioGroup(props: RadioGroupProps) -> Element {
rsx! {
document::Link { rel: "stylesheet", href: asset!("./style.css") }
radio_group::RadioGroup {
class: "radio-group",
value: props.value,
default_value: props.default_value,
on_value_change: props.on_value_change,
disabled: props.disabled,
required: props.required,
name: props.name,
horizontal: props.horizontal,
roving_loop: props.roving_loop,
attributes: props.attributes,
{props.children}
}
}
}
#[component]
pub fn RadioItem(props: RadioItemProps) -> Element {
rsx! {
radio_group::RadioItem {
class: "radio-item",
value: props.value,
index: props.index,
disabled: props.disabled,
attributes: props.attributes,
{props.children}
}
}
}
@@ -0,0 +1,2 @@
mod component;
pub use component::*;
@@ -0,0 +1,48 @@
.radio-group {
display: flex;
flex-direction: column;
gap: .75rem;
}
.radio-item {
display: flex;
flex-direction: row;
align-items: center;
padding: 0;
border: none;
background-color: transparent;
color: var(--secondary-color-4);
font-size: 14px;
gap: .75rem;
&::before {
display: block;
width: 1rem;
height: 1rem;
box-sizing: border-box;
border-radius: 1.5rem;
background: var(--light, var(--primary-color)) var(--dark, var(--primary-color-3));
box-shadow: 0 0 0 1px var(--light, var(--primary-color-6))
var(--dark, var(--primary-color-7));
content: "";
cursor: pointer;
}
&:focus-visible {
outline: none;
}
&:focus-visible::before {
box-shadow: 0 0 0 2px var(--focused-border-color);
}
&[data-state="checked"]::before {
border: 0.25rem solid var(--light, var(--primary-color)) var(--dark, var(--primary-color-3));
background: var(--secondary-color-4);
}
&[data-disabled="true"]::before {
cursor: not-allowed;
opacity: 0.5;
}
}
+13 -16
View File
@@ -1,35 +1,32 @@
use dioxus::prelude::*;
use crate::{
Route,
components::{ContextMenuRenderer, ContextMenuRoot, Navbar},
contexts::AuthContext,
views::Login,
};
use crate::{Route, components::Navbar, contexts::AuthContext, views::Login};
#[component]
pub fn MainLayout() -> Element {
pub fn MainLayout() -> Element
{
let auth_context = use_context::<AuthContext>();
if auth_context.jwt.cloned().is_none() {
if auth_context.jwt.cloned().is_none()
{
return rsx! {
Login { }
};
}
let mut ct_renderer = use_context::<ContextMenuRenderer>();
// let mut ct_renderer = use_context::<ContextMenuRenderer>();
return rsx! {
ContextMenuRoot { }
// ContextMenuRoot { }
Navbar { }
div {
id: "content",
onclick: move |_| {
ct_renderer.close();
},
oncontextmenu: move |_| {
ct_renderer.close();
},
// onclick: move |_| {
// ct_renderer.close();
// },
// oncontextmenu: move |_| {
// ct_renderer.close();
// },
Outlet::<Route> { }
}
};
+7 -5
View File
@@ -11,8 +11,6 @@ use contexts::AuthContext;
use dioxus::prelude::*;
use route::Route;
use crate::components::ContextMenuRenderer;
#[cfg(debug_assertions)]
pub const HOST: &'static str = "http://localhost:8081";
#[cfg(debug_assertions)]
@@ -25,21 +23,25 @@ pub const HOST: &'static str = "https://aoba.app";
const FAVICON: Asset = asset!("/assets/favicon.ico");
const MAIN_CSS: Asset = asset!("/assets/style/main.scss");
const INPUT_CSS: Asset = asset!("/assets/style/inputs.scss");
const DX_COMPONENTS: Asset = asset!("/assets/style/dx-components.scss");
fn main() {
fn main()
{
dioxus::launch(App);
}
#[component]
fn App() -> Element {
fn App() -> Element
{
use_context_provider(|| AuthContext::new());
use_context_provider(|| ContextMenuRenderer::default());
// use_context_provider(|| ContextMenuRenderer::default());
rsx! {
document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" }
document::Link { rel: "preconnect", href: "https://fonts.gstatic.com" }
document::Link { rel: "stylesheet", href: MAIN_CSS }
document::Link { rel: "stylesheet", href: INPUT_CSS }
document::Link { rel: "stylesheet", href: DX_COMPONENTS }
document::Link {
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap",
+3 -1
View File
@@ -1,6 +1,6 @@
use crate::{
layouts::MainLayout,
views::{Home, Media, Settings},
views::{Home, HomePaged, Media, Settings},
};
use dioxus::prelude::*;
@@ -11,6 +11,8 @@ pub enum Route {
#[route("/")]
Home { },
#[route("/?:page&:q")]
HomePaged { page: i32, q: String },
#[route("/media/:id")]
Media { id: String },
#[route("/settings")]
+32 -16
View File
@@ -9,7 +9,8 @@ use crate::{
rpc::aoba::{auth_rpc_client::AuthRpcClient, metrics_rpc_client::MetricsRpcClient},
};
pub mod aoba {
pub mod aoba
{
tonic::include_proto!("aoba");
}
@@ -21,31 +22,38 @@ static RPC_CLIENT: RpcConnection = RpcConnection {
};
#[derive(Default)]
pub struct RpcConnection {
pub struct RpcConnection
{
aoba: RwLock<Option<AobaRpcClient<InterceptedService<Client, AuthInterceptor>>>>,
auth: RwLock<Option<AuthRpcClient<Client>>>,
metrics: RwLock<Option<MetricsRpcClient<InterceptedService<Client, AuthInterceptor>>>>,
jwt: RwLock<Option<String>>,
}
impl RpcConnection {
pub fn get_client(&self) -> AobaRpcClient<InterceptedService<Client, AuthInterceptor>> {
impl RpcConnection
{
pub fn get_client(&self) -> AobaRpcClient<InterceptedService<Client, AuthInterceptor>>
{
self.ensure_client();
return self.aoba.read().unwrap().clone().unwrap();
}
pub fn get_auth_client(&self) -> AuthRpcClient<Client> {
pub fn get_auth_client(&self) -> AuthRpcClient<Client>
{
self.ensure_client();
return self.auth.read().unwrap().clone().unwrap();
}
pub fn get_metrics_client(&self) -> MetricsRpcClient<InterceptedService<Client, AuthInterceptor>> {
pub fn get_metrics_client(&self) -> MetricsRpcClient<InterceptedService<Client, AuthInterceptor>>
{
self.ensure_client();
return self.metrics.read().unwrap().clone().unwrap();
}
fn ensure_client(&self) {
if self.aoba.read().unwrap().is_none() {
fn ensure_client(&self)
{
if self.aoba.read().unwrap().is_none()
{
let wasm_client = Client::new(RPC_HOST.into());
let aoba_client = AobaRpcClient::with_interceptor(wasm_client.clone(), AuthInterceptor);
*self.aoba.write().unwrap() = Some(aoba_client);
@@ -58,9 +66,12 @@ impl RpcConnection {
#[derive(Clone)]
pub struct AuthInterceptor;
impl Interceptor for AuthInterceptor {
fn call(&mut self, mut request: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status> {
if let Some(jwt) = RPC_CLIENT.jwt.read().unwrap().clone() {
impl Interceptor for AuthInterceptor
{
fn call(&mut self, mut request: tonic::Request<()>) -> Result<tonic::Request<()>, tonic::Status>
{
if let Some(jwt) = RPC_CLIENT.jwt.read().unwrap().clone()
{
request
.metadata_mut()
.insert("authorization", format!("Bearer {jwt}").parse().unwrap());
@@ -69,21 +80,26 @@ impl Interceptor for AuthInterceptor {
}
}
pub fn get_rpc_client() -> AobaRpcClient<InterceptedService<Client, AuthInterceptor>> {
pub fn get_rpc_client() -> AobaRpcClient<InterceptedService<Client, AuthInterceptor>>
{
return RPC_CLIENT.get_client();
}
pub fn get_auth_rpc_client() -> AuthRpcClient<Client> {
pub fn get_auth_rpc_client() -> AuthRpcClient<Client>
{
return RPC_CLIENT.get_auth_client();
}
pub fn get_metrics_rpc_client() -> MetricsRpcClient<InterceptedService<Client, AuthInterceptor>> {
pub fn get_metrics_rpc_client() -> MetricsRpcClient<InterceptedService<Client, AuthInterceptor>>
{
return RPC_CLIENT.get_metrics_client();
}
pub fn login(jwt: String) {
pub fn login(jwt: String)
{
*RPC_CLIENT.jwt.write().unwrap() = Some(jwt);
}
pub fn logout() {
pub fn logout()
{
*RPC_CLIENT.jwt.write().unwrap() = None;
}
+19 -1
View File
@@ -2,7 +2,8 @@ use crate::components::{MediaGrid, Pagination, Search};
use dioxus::prelude::*;
#[component]
pub fn Home() -> Element {
pub fn Home() -> Element
{
let query = use_signal(|| "".to_string());
let page = use_signal(|| 1 as i32);
let max_page = use_signal(|| 1 as i32);
@@ -16,3 +17,20 @@ pub fn Home() -> Element {
MediaGrid { query: query.cloned(), page: page.cloned(), max_page, total_items: item_count }
}
}
#[component]
pub fn HomePaged(page: i32, q: String) -> Element
{
let query = use_signal(|| q);
let page = use_signal(|| page);
let max_page = use_signal(|| 1 as i32);
let item_count = use_signal(|| 0 as i32);
rsx! {
div {
class: "stickyTop",
Search { query, page },
Pagination { page, max_page, item_count },
}
MediaGrid { query: query.cloned(), page: page.cloned(), max_page, total_items: item_count }
}
}
+48 -2
View File
@@ -1,8 +1,54 @@
use crate::HOST;
use crate::components::radio_group::{RadioGroup, RadioItem};
use crate::rpc::aoba::SetMediaClassRequest;
use crate::rpc::{
aoba::{Id, MediaModel},
get_rpc_client,
};
use dioxus::prelude::*;
#[component]
pub fn Media(id: String) -> Element {
pub fn Media(id: String) -> Element
{
let media_result = use_resource(use_reactive!(|(id)| async move {
let mut client = get_rpc_client();
let result = client.get_media(Id { value: id.clone() }).await;
if let Ok(item) = result
{
let res = item.into_inner();
return res.value;
}
else
{
return None;
}
}));
return match media_result.cloned().unwrap_or(None)
{
Some(media) =>
{
return rsx! {MediaPage{media: media}};
}
None => rsx! {"Not Found"},
};
}
#[component]
fn MediaPage(media: MediaModel) -> Element
{
let url = media.thumb_url;
let id = media.id.expect("Media has no id").value.clone();
let cur_class = match media.class
{
0 => "Standard",
1 => "NSFW",
2 => "Secret",
_ => "Unkown",
};
rsx! {
{id}
img { src: "{HOST}{url}", }
label { "Media Class: {cur_class}" }
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ public class AobaService(IMongoDatabase db)
return await _media.Find(m => m.LegacyId == id).FirstOrDefaultAsync(cancellationToken);
}
public async Task<Media?> GetMediaFromFileAsync(ObjectId id, CancellationToken cancellationToken = default)
public async Task<Media?> GetMediaAsync(ObjectId id, CancellationToken cancellationToken = default)
{
return await _media.Find(m => m.MediaId == id).FirstOrDefaultAsync(cancellationToken);
}
+1 -1
View File
@@ -64,7 +64,7 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
if (existingThumb != null)
return existingThumb;
var media = await aobaService.GetMediaFromFileAsync(mediaId, cancellationToken);
var media = await aobaService.GetMediaAsync(mediaId, cancellationToken);
if (media == null)
return new Error("Media does not exist");
+1 -4
View File
@@ -41,10 +41,7 @@ message Id {
}
message MediaResponse {
oneof result {
MediaModel value = 1;
google.protobuf.Empty empty = 2;
}
optional MediaModel value = 1;
}
message ListResponse {
+1 -1
View File
@@ -17,7 +17,7 @@ public class AobaRpcService(AobaService aobaService, AccountsService accountsSer
{
public override async Task<MediaResponse> GetMedia(Id request, ServerCallContext context)
{
var media = await aobaService.GetMediaFromLegacyIdAsync(request.ToObjectId(), context.CancellationToken);
var media = await aobaService.GetMediaAsync(request.ToObjectId(), context.CancellationToken);
return media.ToResponse();
}
+1 -1
View File
@@ -35,7 +35,7 @@ public static class ProtoExtensions
public static MediaResponse ToResponse(this Media? media)
{
if(media == null)
return new MediaResponse() { Empty = new Empty() };
return new MediaResponse() {};
return new MediaResponse()
{
Value = media.ToMediaModel()