Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 511e62b58c | |||
| 41aa78b672 | |||
| 21163b277d | |||
| 7a0d3b7f40 | |||
| 6d2b8c77b2 | |||
| 8bdd9edbb0 | |||
| 5e6b0b21a6 | |||
| 19274d444d | |||
| 63e2f9f791 | |||
| 8964d1c069 | |||
| 8808126905 | |||
| d36aaac836 | |||
| bb2c6c4683 | |||
| f8d457a096 | |||
| 165adb2775 | |||
| e832ccf07e | |||
| 6df6de098b | |||
| 364b23e62a | |||
| 7b2ed32043 | |||
| d094f7bbef | |||
| f98429159f | |||
| 3dca408356 | |||
| 1655e342b7 | |||
| 7223c35658 |
56
.github/workflows/release.yaml
vendored
56
.github/workflows/release.yaml
vendored
@@ -1,37 +1,39 @@
|
|||||||
name: "Build and Push Image"
|
name: "Build and Push Image"
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*"
|
- "v*"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkou code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Build
|
- name: Set up Docker Build
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Login
|
- name: Login
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
registry: git.kaisei.app
|
registry: git.kaisei.app
|
||||||
username: ${{ secrets.DOCKER_USER }}
|
username: ${{ secrets.DOCKER_USER }}
|
||||||
password: ${{ secrets.DOCKER_PASS }}
|
password: ${{ secrets.DOCKER_PASS }}
|
||||||
|
|
||||||
- name: Extract tag version
|
- name: Extract tag version
|
||||||
id: extract_tag
|
id: extract_tag
|
||||||
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
run: echo "VERSION=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||||
|
|
||||||
- name: Build and Push
|
- name: Build and Push
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
file: AobaServer/Dockerfile
|
file: AobaServer/Dockerfile
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
tags: git.kaisei.app/amatsugu/aoba:${{ env.VERSION }}
|
tags: |
|
||||||
build-args: VERSION=${{ env.VERSION }}
|
git.kaisei.app/amatsugu/aoba:${{ env.VERSION }}
|
||||||
|
git.kaisei.app/amatsugu/aoba:latest
|
||||||
|
build-args: VERSION=${{ env.VERSION }}
|
||||||
|
|||||||
1961
AobaClient/Cargo.lock
generated
1961
AobaClient/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ edition = "2024"
|
|||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
dioxus = { version = "0.6.0", features = ["router"] }
|
dioxus = { version = "0.7.2", features = ["router"] }
|
||||||
serde = "1.0.219"
|
serde = "1.0.219"
|
||||||
serde_repr = "0.1.20"
|
serde_repr = "0.1.20"
|
||||||
tonic = { version = "*", default-features = false, features = [
|
tonic = { version = "*", default-features = false, features = [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
$mainBGColor: #584577;
|
$mainBGColor: #584577;
|
||||||
$featureColor: #ce2d4f;
|
$featureColor: #ce2d4f;
|
||||||
|
$focusColor: #ff3862;
|
||||||
$accentColor: #f0eaf8;
|
$accentColor: #f0eaf8;
|
||||||
|
|
||||||
$mainTextColor: #eee;
|
$mainTextColor: #eee;
|
||||||
|
|||||||
@@ -21,6 +21,8 @@
|
|||||||
top: 0;
|
top: 0;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
z-index: 100;
|
z-index: 100;
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
box-shadow: 0 3px 10px $mainBGColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -157,3 +159,62 @@ form {
|
|||||||
padding: 5px;
|
padding: 5px;
|
||||||
user-select: all;
|
user-select: all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.contextMenu {
|
||||||
|
backdrop-filter: blur(10px) brightness(0.5) grayscale(1);
|
||||||
|
position: absolute;
|
||||||
|
z-index: 100;
|
||||||
|
|
||||||
|
.itemList {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
max-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 5px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $accentColor;
|
||||||
|
color: $invertTextColor;
|
||||||
|
border-left: 10px solid $focusColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5;
|
||||||
|
padding: 10px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
transition: all 0.25s ease-out;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
padding: 5px 10px;
|
||||||
|
background-color: $featureColor;
|
||||||
|
border-radius: 4px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: $focusColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ pub struct InputProps {
|
|||||||
pub label: Option<String>,
|
pub label: Option<String>,
|
||||||
pub placeholder: Option<String>,
|
pub placeholder: Option<String>,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub oninput: Option<EventHandler<FormEvent>>,
|
pub oninput: Option<EventHandler<Event<FormData>>>,
|
||||||
pub required: Option<bool>,
|
pub required: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -22,9 +22,12 @@ pub fn Input(props: InputProps) -> Element {
|
|||||||
r#type: props.r#type.unwrap_or("text".into()),
|
r#type: props.r#type.unwrap_or("text".into()),
|
||||||
value: props.value,
|
value: props.value,
|
||||||
oninput: move |e| {
|
oninput: move |e| {
|
||||||
if let Some(mut s) = props.value {
|
if let Some(mut s) = props.value {
|
||||||
s.set(e.value());
|
s.set(e.value());
|
||||||
}
|
}
|
||||||
|
if let Some(handler) = props.oninput{
|
||||||
|
handler.call(e);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
name: props.name,
|
name: props.name,
|
||||||
placeholder: ph,
|
placeholder: ph,
|
||||||
|
|||||||
97
AobaClient/src/components/context_menu.rs
Normal file
97
AobaClient/src/components/context_menu.rs
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
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}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ use crate::{
|
|||||||
#[derive(PartialEq, Clone, Props)]
|
#[derive(PartialEq, Clone, Props)]
|
||||||
pub struct MediaGridProps {
|
pub struct MediaGridProps {
|
||||||
pub query: Option<String>,
|
pub query: Option<String>,
|
||||||
|
pub max_page: Signal<i32>,
|
||||||
|
pub total_items: Signal<i32>,
|
||||||
#[props(default = Some(1))]
|
#[props(default = Some(1))]
|
||||||
pub page: Option<i32>,
|
pub page: Option<i32>,
|
||||||
#[props(default = Some(100))]
|
#[props(default = Some(100))]
|
||||||
@@ -33,12 +35,13 @@ impl Into<PageFilter> for MediaGridProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn MediaGrid(props: MediaGridProps) -> Element {
|
pub fn MediaGrid(mut props: MediaGridProps) -> Element {
|
||||||
let media_result = use_resource(use_reactive!(|(props)| async move {
|
let media_result = use_resource(use_reactive!(|(props)| async move {
|
||||||
let mut client = get_rpc_client();
|
let mut client = get_rpc_client();
|
||||||
let result = client.list_media(props.into_request()).await;
|
let result = client.list_media(props.into_request()).await;
|
||||||
if let Ok(items) = result {
|
if let Ok(items) = result {
|
||||||
return Ok(items.into_inner());
|
let res = items.into_inner();
|
||||||
|
return Ok(res);
|
||||||
} else {
|
} else {
|
||||||
let err = result.err().unwrap();
|
let err = result.err().unwrap();
|
||||||
let message = err.message();
|
let message = err.message();
|
||||||
@@ -48,14 +51,24 @@ pub fn MediaGrid(props: MediaGridProps) -> Element {
|
|||||||
|
|
||||||
match media_result.cloned() {
|
match media_result.cloned() {
|
||||||
Some(value) => match value {
|
Some(value) => match value {
|
||||||
Ok(result) => rsx! {
|
Ok(result) => {
|
||||||
div {
|
let pagination = result.pagination.unwrap();
|
||||||
class: "mediaGrid",
|
let total_pages = pagination.total_pages;
|
||||||
{result.items.iter().map(|itm| rsx!{
|
let total_items = pagination.total_items;
|
||||||
MediaItem { item: Some(itm.clone()) }
|
props.max_page.set(total_pages.max(1));
|
||||||
})},
|
props.total_items.set(total_items.max(1));
|
||||||
}
|
return rsx! {
|
||||||
},
|
div {
|
||||||
|
class: "mediaGrid",
|
||||||
|
// oncontextmenu: oncontext,
|
||||||
|
{result.items.iter().map(|itm| rsx!{
|
||||||
|
MediaItem {
|
||||||
|
item: itm.clone()
|
||||||
|
}
|
||||||
|
})},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
Err(msg) => rsx! {
|
Err(msg) => rsx! {
|
||||||
div {
|
div {
|
||||||
class: "mediaGrid",
|
class: "mediaGrid",
|
||||||
@@ -69,7 +82,7 @@ pub fn MediaGrid(props: MediaGridProps) -> Element {
|
|||||||
div{
|
div{
|
||||||
class: "mediaGrid",
|
class: "mediaGrid",
|
||||||
{(0..50).map(|_| rsx!{
|
{(0..50).map(|_| rsx!{
|
||||||
MediaItem {}
|
MediaItem { }
|
||||||
})}
|
})}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,25 +1,72 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
use web_sys::window;
|
||||||
|
|
||||||
use crate::{HOST, rpc::aoba::MediaModel};
|
use crate::{
|
||||||
|
HOST,
|
||||||
|
components::{ContextMenu, ContextMenuItem, ContextMenuRenderer},
|
||||||
|
rpc::aoba::MediaModel,
|
||||||
|
};
|
||||||
|
|
||||||
#[derive(PartialEq, Clone, Props)]
|
#[derive(PartialEq, Clone, Props)]
|
||||||
pub struct MediaItemProps {
|
pub struct MediaItemProps {
|
||||||
pub item: Option<MediaModel>,
|
pub item: Option<MediaModel>,
|
||||||
|
// pub oncontextmenu: Option<EventHandler<Event<MouseData>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn MediaItem(props: MediaItemProps) -> Element {
|
pub fn MediaItem(props: MediaItemProps) -> Element {
|
||||||
|
let mut ct_renderer = use_context::<ContextMenuRenderer>();
|
||||||
|
|
||||||
if let Some(item) = props.item {
|
if let Some(item) = props.item {
|
||||||
let mtype = item.media_type().as_str_name();
|
let mtype = item.media_type().as_str_name();
|
||||||
let filename = item.file_name;
|
let filename = item.file_name;
|
||||||
let id = item.id.unwrap().value;
|
let id = item.id.unwrap().value;
|
||||||
let thumb = item.thumb_url;
|
let thumb = item.thumb_url;
|
||||||
let url = item.media_url;
|
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));
|
||||||
|
};
|
||||||
|
|
||||||
return rsx! {
|
return rsx! {
|
||||||
a {
|
a {
|
||||||
class: "mediaItem",
|
class: "mediaItem",
|
||||||
href: "{HOST}/{url}",
|
href: "{HOST}{url}",
|
||||||
target: "_blank",
|
target: "_blank",
|
||||||
|
oncontextmenu: oncontext,
|
||||||
"data-id" : id,
|
"data-id" : id,
|
||||||
img { src: "{HOST}{thumb}" }
|
img { src: "{HOST}{thumb}" }
|
||||||
span { class: "info",
|
span { class: "info",
|
||||||
@@ -29,7 +76,7 @@ pub fn MediaItem(props: MediaItemProps) -> Element {
|
|||||||
span { "{item.view_count}" }
|
span { "{item.view_count}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
return rsx! {
|
return rsx! {
|
||||||
|
|||||||
@@ -1,14 +1,18 @@
|
|||||||
pub mod basic;
|
pub mod basic;
|
||||||
|
mod context_menu;
|
||||||
mod media_grid;
|
mod media_grid;
|
||||||
mod media_item;
|
mod media_item;
|
||||||
mod metrics_token;
|
mod metrics_token;
|
||||||
mod navbar;
|
mod navbar;
|
||||||
mod notif;
|
mod notif;
|
||||||
|
mod pagination;
|
||||||
mod search;
|
mod search;
|
||||||
|
pub use context_menu::*;
|
||||||
pub use media_grid::*;
|
pub use media_grid::*;
|
||||||
pub use media_item::*;
|
pub use media_item::*;
|
||||||
pub use metrics_token::*;
|
pub use metrics_token::*;
|
||||||
pub use navbar::*;
|
pub use navbar::*;
|
||||||
pub use notif::*;
|
pub use notif::*;
|
||||||
|
pub use pagination::*;
|
||||||
pub use search::*;
|
pub use search::*;
|
||||||
mod icons;
|
mod icons;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ pub fn Navbar() -> Element {
|
|||||||
pub fn MainNaviagation() -> Element {
|
pub fn MainNaviagation() -> Element {
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "mainNav",
|
div { class: "mainNav",
|
||||||
Link { class: "navItem", to: Route::Home {}, "Home" }
|
Link { class: "navItem", to: Route::Home { }, "Home" }
|
||||||
Link { class: "navItem", to: Route::Settings {}, "Settings" }
|
Link { class: "navItem", to: Route::Settings {}, "Settings" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
30
AobaClient/src/components/pagination.rs
Normal file
30
AobaClient/src/components/pagination.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
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();
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
class: "pagination",
|
||||||
|
a {
|
||||||
|
onclick: move|_| page.set(1),
|
||||||
|
"First"
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
onclick: move|_| page.set((cur_page_val - 1).max(1)),
|
||||||
|
"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)),
|
||||||
|
"Next"
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
onclick: move|_| page.set(max_page_val),
|
||||||
|
"Last"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Search(query: Signal<String>) -> Element {
|
pub fn Search(query: Signal<String>, page: Signal<i32>) -> Element {
|
||||||
rsx! {
|
rsx! {
|
||||||
div { class: "searchBar stickyTop",
|
div { class: "searchBar",
|
||||||
input {
|
input {
|
||||||
r#type: "search",
|
r#type: "search",
|
||||||
placeholder: "Search Files",
|
placeholder: "Search Files",
|
||||||
value: query,
|
value: query,
|
||||||
oninput: move |event| query.set(event.value()),
|
oninput: move |event| {query.set(event.value()); page.set(1);},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use dioxus::signals::{Signal, Writable};
|
use dioxus::signals::{Signal, WritableExt};
|
||||||
use web_sys::window;
|
use web_sys::window;
|
||||||
|
|
||||||
use crate::rpc::{login, logout};
|
use crate::rpc::{login, logout};
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
use crate::{Route, components::Navbar, contexts::AuthContext, views::Login};
|
use crate::{
|
||||||
|
Route,
|
||||||
|
components::{ContextMenuRenderer, ContextMenuRoot, Navbar},
|
||||||
|
contexts::AuthContext,
|
||||||
|
views::Login,
|
||||||
|
};
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn MainLayout() -> Element {
|
pub fn MainLayout() -> Element {
|
||||||
@@ -8,12 +13,24 @@ pub fn MainLayout() -> Element {
|
|||||||
|
|
||||||
if auth_context.jwt.cloned().is_none() {
|
if auth_context.jwt.cloned().is_none() {
|
||||||
return rsx! {
|
return rsx! {
|
||||||
Login {}
|
Login { }
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut ct_renderer = use_context::<ContextMenuRenderer>();
|
||||||
|
|
||||||
return rsx! {
|
return rsx! {
|
||||||
Navbar {}
|
ContextMenuRoot { }
|
||||||
div { id: "content", Outlet::<Route> {} }
|
Navbar { }
|
||||||
|
div {
|
||||||
|
id: "content",
|
||||||
|
onclick: move |_| {
|
||||||
|
ct_renderer.close();
|
||||||
|
},
|
||||||
|
oncontextmenu: move |_| {
|
||||||
|
ct_renderer.close();
|
||||||
|
},
|
||||||
|
Outlet::<Route> { }
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use contexts::AuthContext;
|
|||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
use route::Route;
|
use route::Route;
|
||||||
|
|
||||||
|
use crate::components::ContextMenuRenderer;
|
||||||
|
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
pub const HOST: &'static str = "http://localhost:8081";
|
pub const HOST: &'static str = "http://localhost:8081";
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
@@ -30,7 +32,8 @@ fn main() {
|
|||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
fn App() -> Element {
|
fn App() -> Element {
|
||||||
let _auth_state = use_context_provider(|| AuthContext::new());
|
use_context_provider(|| AuthContext::new());
|
||||||
|
use_context_provider(|| ContextMenuRenderer::default());
|
||||||
rsx! {
|
rsx! {
|
||||||
document::Link { rel: "icon", href: FAVICON }
|
document::Link { rel: "icon", href: FAVICON }
|
||||||
document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" }
|
document::Link { rel: "preconnect", href: "https://fonts.googleapis.com" }
|
||||||
@@ -41,6 +44,7 @@ fn App() -> Element {
|
|||||||
rel: "stylesheet",
|
rel: "stylesheet",
|
||||||
href: "https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap",
|
href: "https://fonts.googleapis.com/css2?family=Noto+Sans:ital,wght@0,100..900;1,100..900&display=swap",
|
||||||
}
|
}
|
||||||
Router::<Route> {}
|
|
||||||
|
Router::<Route> { }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
layouts::MainLayout,
|
layouts::MainLayout,
|
||||||
views::{Home, Settings},
|
views::{Home, Media, Settings},
|
||||||
};
|
};
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
@@ -8,8 +8,11 @@ use dioxus::prelude::*;
|
|||||||
#[rustfmt::skip]
|
#[rustfmt::skip]
|
||||||
pub enum Route {
|
pub enum Route {
|
||||||
#[layout(MainLayout)]
|
#[layout(MainLayout)]
|
||||||
|
|
||||||
#[route("/")]
|
#[route("/")]
|
||||||
Home {},
|
Home { },
|
||||||
|
#[route("/media/:id")]
|
||||||
|
Media { id: String },
|
||||||
#[route("/settings")]
|
#[route("/settings")]
|
||||||
Settings {},
|
Settings {},
|
||||||
// #[end_layout]
|
// #[end_layout]
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
use crate::components::{MediaGrid, Search};
|
use crate::components::{MediaGrid, Pagination, Search};
|
||||||
use dioxus::prelude::*;
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn Home() -> Element {
|
pub fn Home() -> Element {
|
||||||
let query = use_signal(|| "".to_string());
|
let query = use_signal(|| "".to_string());
|
||||||
|
let page = use_signal(|| 1 as i32);
|
||||||
|
let max_page = use_signal(|| 1 as i32);
|
||||||
|
let item_count = use_signal(|| 0 as i32);
|
||||||
rsx! {
|
rsx! {
|
||||||
Search { query }
|
div {
|
||||||
MediaGrid { query: query.cloned() }
|
class: "stickyTop",
|
||||||
|
Search { query, page },
|
||||||
|
Pagination { page, max_page, item_count },
|
||||||
|
}
|
||||||
|
MediaGrid { query: query.cloned(), page: page.cloned(), max_page, total_items: item_count }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
8
AobaClient/src/views/media.rs
Normal file
8
AobaClient/src/views/media.rs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Media(id: String) -> Element {
|
||||||
|
rsx! {
|
||||||
|
{id}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
mod home;
|
mod home;
|
||||||
mod login;
|
mod login;
|
||||||
|
mod media;
|
||||||
pub use home::*;
|
pub use home::*;
|
||||||
pub use login::*;
|
pub use login::*;
|
||||||
|
pub use media::*;
|
||||||
|
|
||||||
mod settings;
|
mod settings;
|
||||||
pub use settings::Settings;
|
pub use settings::Settings;
|
||||||
|
|||||||
@@ -9,14 +9,14 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="FFMpegCore" Version="5.2.0" />
|
<PackageReference Include="FFMpegCore" Version="5.2.0" />
|
||||||
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
|
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.6" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.7" />
|
||||||
<PackageReference Include="MaybeError" Version="1.1.0" />
|
<PackageReference Include="MaybeError" Version="1.1.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.6" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.7" />
|
||||||
<PackageReference Include="MongoDB.Driver" Version="3.4.0" />
|
<PackageReference Include="MongoDB.Driver" Version="3.4.1" />
|
||||||
<PackageReference Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="2.1.0" />
|
<PackageReference Include="MongoDB.Driver.Core.Extensions.DiagnosticSources" Version="2.1.0" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.6" />
|
<PackageReference Include="SixLabors.ImageSharp.Drawing" Version="2.1.6" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.12.1" />
|
||||||
<PackageReference Include="ZLinq" Version="1.4.12" />
|
<PackageReference Include="ZLinq" Version="1.5.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ using System.Text;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace AobaCore.Models;
|
namespace AobaCore.Models;
|
||||||
public class PagedResult<T>(List<T> items, int page, int pageSize, long totalItems)
|
public class PagedResult<T>(List<T> items, int page, int pageSize, int totalItems)
|
||||||
{
|
{
|
||||||
public List<T> Items { get; set; } = items;
|
public List<T> Items { get; set; } = items;
|
||||||
public int Page { get; set; } = page;
|
public int Page { get; set; } = page;
|
||||||
public int PageSize { get; set; } = pageSize;
|
public int PageSize { get; set; } = pageSize;
|
||||||
public long TotalItems { get; set; } = totalItems;
|
public int TotalItems { get; set; } = totalItems;
|
||||||
public long TotalPages { get; set; } = totalItems / pageSize;
|
public int TotalPages { get; set; } = (totalItems / pageSize) + 1;
|
||||||
public string? Query { get; set; }
|
public string? Query { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,9 +37,14 @@ public class AobaService(IMongoDatabase db)
|
|||||||
var total = await find.CountDocumentsAsync();
|
var total = await find.CountDocumentsAsync();
|
||||||
page -= 1;
|
page -= 1;
|
||||||
var items = await find.Sort(sort).Skip(page * pageSize).Limit(pageSize).ToListAsync();
|
var items = await find.Sort(sort).Skip(page * pageSize).Limit(pageSize).ToListAsync();
|
||||||
return new PagedResult<Media>(items, page, pageSize, total);
|
return new PagedResult<Media>(items, page, pageSize, (int)total);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<Media>> FindMediaWithExtAsync(string ext, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var filter = Builders<Media>.Filter.Eq(m => m.Ext, ext);
|
||||||
|
return await _media.Find(filter).ToListAsync();
|
||||||
|
}
|
||||||
|
|
||||||
public Task AddMediaAsync(Media media, CancellationToken cancellationToken = default)
|
public Task AddMediaAsync(Media media, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
@@ -53,6 +58,13 @@ public class AobaService(IMongoDatabase db)
|
|||||||
await _media.UpdateOneAsync(m => m.MediaId == mediaId, upate, cancellationToken: cancellationToken);
|
await _media.UpdateOneAsync(m => m.MediaId == mediaId, upate, cancellationToken: cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task RemoveThumbnailAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var upate = Builders<Media>.Update.Unset(m => m.Thumbnails[size]);
|
||||||
|
|
||||||
|
await _media.UpdateOneAsync(m => m.MediaId == mediaId, upate, cancellationToken: cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ObjectId> GetThumbnailIdAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
|
public async Task<ObjectId> GetThumbnailIdAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var thumb = await _media.Find(m => m.MediaId == mediaId).Project(m => m.Thumbnails[size]).FirstOrDefaultAsync(cancellationToken);
|
var thumb = await _media.Find(m => m.MediaId == mediaId).Project(m => m.Thumbnails[size]).FirstOrDefaultAsync(cancellationToken);
|
||||||
@@ -79,6 +91,10 @@ public class AobaService(IMongoDatabase db)
|
|||||||
{
|
{
|
||||||
return ex;
|
return ex;
|
||||||
}
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
data.Dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MaybeEx<GridFSDownloadStream, GridFSException>> GetFileStreamAsync(ObjectId mediaId, bool seekable = false, CancellationToken cancellationToken = default)
|
public async Task<MaybeEx<GridFSDownloadStream, GridFSException>> GetFileStreamAsync(ObjectId mediaId, bool seekable = false, CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
using FFMpegCore;
|
using FFMpegCore;
|
||||||
using FFMpegCore.Pipes;
|
using FFMpegCore.Pipes;
|
||||||
|
|
||||||
|
|
||||||
using MaybeError.Errors;
|
using MaybeError.Errors;
|
||||||
|
|
||||||
using MongoDB.Bson;
|
using MongoDB.Bson;
|
||||||
@@ -10,10 +11,12 @@ using MongoDB.Driver;
|
|||||||
using MongoDB.Driver.GridFS;
|
using MongoDB.Driver.GridFS;
|
||||||
|
|
||||||
using SixLabors.ImageSharp;
|
using SixLabors.ImageSharp;
|
||||||
|
using SixLabors.ImageSharp.Formats;
|
||||||
using SixLabors.ImageSharp.Processing;
|
using SixLabors.ImageSharp.Processing;
|
||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
@@ -25,6 +28,28 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
|
|||||||
{
|
{
|
||||||
private readonly GridFSBucket _gridfs = new GridFSBucket(db);
|
private readonly GridFSBucket _gridfs = new GridFSBucket(db);
|
||||||
|
|
||||||
|
public async Task<Error?> DeleteThumbnailAsync(ObjectId mediaId, ThumbnailSize size)
|
||||||
|
{
|
||||||
|
var thumbId = await aobaService.GetThumbnailIdAsync(mediaId, size);
|
||||||
|
if (thumbId == default)
|
||||||
|
return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await _gridfs.DeleteAsync(thumbId);
|
||||||
|
await aobaService.RemoveThumbnailAsync(mediaId, size);
|
||||||
|
}
|
||||||
|
catch (GridFSFileNotFoundException)
|
||||||
|
{
|
||||||
|
//Ignore if the file was not found (somehow already deleted)
|
||||||
|
await aobaService.RemoveThumbnailAsync(mediaId, size);
|
||||||
|
}
|
||||||
|
catch (Exception e)
|
||||||
|
{
|
||||||
|
return new ExceptionError(e);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
///
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -34,6 +59,7 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
|
|||||||
/// <returns></returns>
|
/// <returns></returns>
|
||||||
public async Task<Maybe<Stream>> GetOrCreateThumbnailAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
|
public async Task<Maybe<Stream>> GetOrCreateThumbnailAsync(ObjectId mediaId, ThumbnailSize size, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
|
||||||
var existingThumb = await GetThumbnailAsync(mediaId, size, cancellationToken);
|
var existingThumb = await GetThumbnailAsync(mediaId, size, cancellationToken);
|
||||||
if (existingThumb != null)
|
if (existingThumb != null)
|
||||||
return existingThumb;
|
return existingThumb;
|
||||||
@@ -91,17 +117,29 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
|
|||||||
{
|
{
|
||||||
return type switch
|
return type switch
|
||||||
{
|
{
|
||||||
MediaType.Image => await GenerateImageThumbnailAsync(stream, size, cancellationToken),
|
MediaType.Image => await GenerateImageThumbnailAsync(stream, size, ext, cancellationToken),
|
||||||
MediaType.Video => GenerateVideoThumbnail(stream, size, cancellationToken),
|
MediaType.Video => GenerateVideoThumbnail(stream, size, cancellationToken),
|
||||||
MediaType.Text or MediaType.Code => await GenerateDocumentThumbnailAsync(stream, size, cancellationToken),
|
MediaType.Text or MediaType.Code => await GenerateDocumentThumbnailAsync(stream, size, cancellationToken),
|
||||||
_ => new Error($"No Thumbnail for {type}"),
|
_ => new Error($"No Thumbnail for {type}"),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async Task<Stream> GenerateImageThumbnailAsync(Stream stream, ThumbnailSize size, CancellationToken cancellationToken = default)
|
private static Maybe<Image> LoadImage(Stream stream, string ext)
|
||||||
{
|
{
|
||||||
var img = Image.Load(stream);
|
if (ext is ".heif" or ".avif")
|
||||||
img.Mutate(o =>
|
{
|
||||||
|
return new Error("Unsupported image type");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
return Image.Load(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<Maybe<Stream>> GenerateImageThumbnailAsync(Stream stream, ThumbnailSize size, string ext, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var img = LoadImage(stream, ext);
|
||||||
|
if (img.HasError)
|
||||||
|
return img.Error;
|
||||||
|
img.Value.Mutate(o =>
|
||||||
{
|
{
|
||||||
var size =
|
var size =
|
||||||
o.Resize(new ResizeOptions
|
o.Resize(new ResizeOptions
|
||||||
@@ -112,12 +150,13 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
var result = new MemoryStream();
|
var result = new MemoryStream();
|
||||||
await img.SaveAsWebpAsync(result, cancellationToken);
|
await img.Value.SaveAsWebpAsync(result, cancellationToken);
|
||||||
|
img.Value.Dispose();
|
||||||
result.Position = 0;
|
result.Position = 0;
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Maybe<Stream> GenerateVideoThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
|
public static Maybe<Stream> GenerateVideoThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var w = (int)size;
|
var w = (int)size;
|
||||||
var fn = ObjectId.GenerateNewId().ToString();
|
var fn = ObjectId.GenerateNewId().ToString();
|
||||||
@@ -141,7 +180,7 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
|
|||||||
output.Position = 0;
|
output.Position = 0;
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
catch(Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
return ex;
|
return ex;
|
||||||
}
|
}
|
||||||
@@ -151,6 +190,47 @@ public class ThumbnailService(IMongoDatabase db, AobaService aobaService)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static Maybe<Stream> GenerateAvifThumbnail(Stream data, ThumbnailSize size, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var w = (int)size;
|
||||||
|
var fn = ObjectId.GenerateNewId().ToString();
|
||||||
|
var inFilePath = $"/tmp/{fn}.avif";
|
||||||
|
var outFilePath = $"/tmp/{fn}.webp";
|
||||||
|
using var source = new FileStream(inFilePath, FileMode.CreateNew);
|
||||||
|
data.CopyTo(source);
|
||||||
|
source.Flush();
|
||||||
|
source.Dispose();
|
||||||
|
data.Dispose();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var process = Process.Start(new ProcessStartInfo
|
||||||
|
{
|
||||||
|
FileName = "vips",
|
||||||
|
Arguments = $"smartcrop \"{inFilePath}\" \"{outFilePath}\"[Q=75] {w} {w}",
|
||||||
|
WorkingDirectory = "/tmp"
|
||||||
|
});
|
||||||
|
if (process == null)
|
||||||
|
return new Error("Failed to run vips command");
|
||||||
|
process.WaitForExit();
|
||||||
|
if (process.ExitCode != 0)
|
||||||
|
return new Error("Failed to convert");
|
||||||
|
var output = new MemoryStream();
|
||||||
|
using var oFile = File.OpenRead(outFilePath);
|
||||||
|
oFile.CopyTo(output);
|
||||||
|
output.Position = 0;
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
catch(Exception ex)
|
||||||
|
{
|
||||||
|
return ex;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
File.Delete(inFilePath);
|
||||||
|
File.Delete(outFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Maybe<Stream>> GenerateDocumentThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
|
public async Task<Maybe<Stream>> GenerateDocumentThumbnailAsync(Stream data, ThumbnailSize size, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return new NotImplementedException();
|
return new NotImplementedException();
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
|
<PackageReference Include="Isopoh.Cryptography.Argon2" Version="2.0.0" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.6" />
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.7" />
|
||||||
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.12.1" />
|
<PackageReference Include="Microsoft.IdentityModel.JsonWebTokens" Version="8.12.1" />
|
||||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
||||||
<PackageReference Include="MimeTypesMap" Version="1.0.9" />
|
<PackageReference Include="MimeTypesMap" Version="1.0.9" />
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.2" />
|
<PackageReference Include="OpenTelemetry.Exporter.Prometheus.AspNetCore" Version="1.9.0-beta.2" />
|
||||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public class MediaApi(AobaService aoba) : ControllerBase
|
|||||||
if (media.HasError)
|
if (media.HasError)
|
||||||
return Problem(detail: media.Error.Message, statusCode: StatusCodes.Status400BadRequest);
|
return Problem(detail: media.Error.Message, statusCode: StatusCodes.Status400BadRequest);
|
||||||
|
|
||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
media = media.Value,
|
media = media.Value,
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ namespace AobaServer.Controllers;
|
|||||||
public class MediaController(AobaService aobaService, ILogger<MediaController> logger) : Controller
|
public class MediaController(AobaService aobaService, ILogger<MediaController> logger) : Controller
|
||||||
{
|
{
|
||||||
[HttpGet("{id}")]
|
[HttpGet("{id}")]
|
||||||
[HttpGet("{id}/*")]
|
[HttpGet("{id}/{*fn}")]
|
||||||
[ResponseCache(Duration = int.MaxValue)]
|
[ResponseCache(Duration = int.MaxValue)]
|
||||||
public async Task<IActionResult> MediaAsync(ObjectId id, [FromServices] MongoClient client, CancellationToken cancellationToken)
|
public async Task<IActionResult> MediaAsync(ObjectId id, [FromServices] MongoClient client, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -29,6 +29,22 @@ public class MediaController(AobaService aobaService, ILogger<MediaController> l
|
|||||||
return File(file, mime, true);
|
return File(file, mime, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("{id}/dl")]
|
||||||
|
[HttpGet("{id}/dl/{*fn}")]
|
||||||
|
[ResponseCache(Duration = int.MaxValue)]
|
||||||
|
public async Task<IActionResult> DownloadMediaAsync(ObjectId id, [FromServices] MongoClient client, [FromQuery] string? fn = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var file = await aobaService.GetFileStreamAsync(id, seekable: true, cancellationToken: cancellationToken);
|
||||||
|
if (file.HasError)
|
||||||
|
{
|
||||||
|
logger.LogError(file.Error.Exception, "Failed to load media stream");
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
var mime = MimeTypesMap.GetMimeType(file.Value.FileInfo.Filename);
|
||||||
|
_ = aobaService.IncrementViewCountAsync(id, cancellationToken);
|
||||||
|
return File(file, mime, fn ?? file.Value.FileInfo.Filename, true);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Redirect legacy media urls to the new url
|
/// Redirect legacy media urls to the new url
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -22,17 +22,17 @@ RUN apt install -y protobuf-compiler libprotobuf-dev ffmpeg
|
|||||||
|
|
||||||
# Install `dx`
|
# Install `dx`
|
||||||
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
|
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash
|
||||||
RUN cargo binstall dioxus-cli --root /.cargo -y --force
|
RUN cargo binstall dioxus-cli@0.7.2 --root /.cargo -y --force
|
||||||
ENV PATH="/.cargo/bin:$PATH"
|
ENV PATH="/.cargo/bin:$PATH"
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
ENV APP_VERSION=$VERSION
|
ENV APP_VERSION=$VERSION
|
||||||
# Create the final bundle folder. Bundle always executes in release mode with optimizations enabled
|
# Create the final bundle folder. Bundle always executes in release mode with optimizations enabled
|
||||||
RUN dx bundle --platform web
|
RUN dx bundle --release --platform web
|
||||||
|
|
||||||
# Server Build
|
# Server Build
|
||||||
# This stage is used when running from VS in fast mode (Default for Debug configuration)
|
# This stage is used when running from VS in fast mode (Default for Debug configuration)
|
||||||
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base
|
||||||
RUN apt-get update && apt-get install -y ffmpeg
|
RUN apt-get update && apt-get install -y ffmpeg #libvips libvips-tools
|
||||||
USER $APP_UID
|
USER $APP_UID
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
|
|||||||
@@ -121,10 +121,13 @@ builder.Services.AddAuthentication(options =>
|
|||||||
|
|
||||||
|
|
||||||
builder.Services.AddAoba();
|
builder.Services.AddAoba();
|
||||||
|
#if DEBUG
|
||||||
|
builder.Services.AddHostedService<DebugService>();
|
||||||
|
#endif
|
||||||
builder.Services.Configure<FormOptions>(opt =>
|
builder.Services.Configure<FormOptions>(opt =>
|
||||||
{
|
{
|
||||||
opt.ValueLengthLimit = int.MaxValue;
|
opt.ValueLengthLimit = int.MaxValue;
|
||||||
opt.MultipartBodyLengthLimit = int.MaxValue;
|
opt.MultipartBodyLengthLimit = long.MaxValue;
|
||||||
});
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|||||||
@@ -54,8 +54,8 @@ message ListResponse {
|
|||||||
message Pagination {
|
message Pagination {
|
||||||
int32 page = 1;
|
int32 page = 1;
|
||||||
int32 pageSize = 2;
|
int32 pageSize = 2;
|
||||||
int64 totalPages = 3;
|
int32 totalPages = 3;
|
||||||
int64 totalItems = 4;
|
int32 totalItems = 4;
|
||||||
optional string query = 5;
|
optional string query = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
19
AobaServer/Services/DebugService.cs
Normal file
19
AobaServer/Services/DebugService.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
|
||||||
|
using AobaCore.Services;
|
||||||
|
|
||||||
|
namespace AobaServer.Services;
|
||||||
|
|
||||||
|
public class DebugService(AobaService aobaService, ThumbnailService thumbnailService) : BackgroundService
|
||||||
|
{
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
var mediaItems = await aobaService.FindMediaWithExtAsync(".avif", stoppingToken);
|
||||||
|
foreach (var item in mediaItems)
|
||||||
|
{
|
||||||
|
foreach (var size in item.Thumbnails.Keys)
|
||||||
|
{
|
||||||
|
await thumbnailService.DeleteThumbnailAsync(item.MediaId, size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user