Compare commits
5 Commits
39488ec094
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| b6fbe3a04f | |||
| 493a689d75 | |||
| e1a5a733c1 | |||
| 799aebb49c | |||
| 0f4391d388 |
1
assets/album.svg
Normal file
1
assets/album.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="1.375em" height="1.375em" viewBox="0 0 24 24" class="shrink-0 -scale-x-100 svelte-ztbs85" stroke="transparent" stroke-width="2" role="img" aria-hidden="true"><!----><!----><path d="M6,19L9,15.14L11.14,17.72L14.14,13.86L18,19H6M6,4H11V12L8.5,10.5L6,12M18,2H6A2,2 0 0,0 4,4V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V4A2,2 0 0,0 18,2Z" fill="currentColor"></path></svg>
|
||||||
|
After Width: | Height: | Size: 374 B |
44
assets/checked.svg
Normal file
44
assets/checked.svg
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="text-primary svelte-ztbs85"
|
||||||
|
stroke="transparent"
|
||||||
|
stroke-width="2"
|
||||||
|
role="img"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="checked.svg"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#eeeeee"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#505050"
|
||||||
|
inkscape:zoom="13.470055"
|
||||||
|
inkscape:cx="12.583468"
|
||||||
|
inkscape:cy="5.8277418"
|
||||||
|
inkscape:window-width="1555"
|
||||||
|
inkscape:window-height="1000"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1" />
|
||||||
|
<!---->
|
||||||
|
<!---->
|
||||||
|
<path
|
||||||
|
d="M12 2C6.5 2 2 6.5 2 12S6.5 22 12 22 22 17.5 22 12 17.5 2 12 2M10 17L5 12L6.41 10.59L10 14.17L17.59 6.58L19 8L10 17Z"
|
||||||
|
fill="currentColor"
|
||||||
|
id="path1" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
1
assets/photos.svg
Normal file
1
assets/photos.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="1.375em" height="1.375em" viewBox="0 0 24 24" class="shrink-0 svelte-ztbs85" stroke="transparent" stroke-width="2" role="img" aria-hidden="true"><!----><!----><path d="M22,16V4A2,2 0 0,0 20,2H8A2,2 0 0,0 6,4V16A2,2 0 0,0 8,18H20A2,2 0 0,0 22,16M11,12L13.03,14.71L16,11L20,16H8M2,6V20A2,2 0 0,0 4,22H18V20H4V6" fill="currentColor"></path></svg>
|
||||||
|
After Width: | Height: | Size: 356 B |
1
assets/search.svg
Normal file
1
assets/search.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg width="1.375em" height="1.375em" viewBox="0 0 24 24" class="shrink-0 svelte-ztbs85" stroke="transparent" stroke-width="2" role="img" aria-hidden="true"><!----><!----><path d="M9.5,3A6.5,6.5 0 0,1 16,9.5C16,11.11 15.41,12.59 14.44,13.73L14.71,14H15.5L20.5,19L19,20.5L14,15.5V14.71L13.73,14.44C12.59,15.41 11.11,16 9.5,16A6.5,6.5 0 0,1 3,9.5A6.5,6.5 0 0,1 9.5,3M9.5,5C7,5 5,7 5,9.5C5,12 7,14 9.5,14C12,14 14,12 14,9.5C14,7 12,5 9.5,5Z" fill="currentColor"></path></svg>
|
||||||
|
After Width: | Height: | Size: 473 B |
47
assets/unchecked.svg
Normal file
47
assets/unchecked.svg
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
class="text-primary svelte-ztbs85"
|
||||||
|
stroke="transparent"
|
||||||
|
stroke-width="2"
|
||||||
|
role="img"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
sodipodi:docname="unchecked.svg"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlns:svg="http://www.w3.org/2000/svg">
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#505050"
|
||||||
|
bordercolor="#eeeeee"
|
||||||
|
borderopacity="1"
|
||||||
|
inkscape:showpageshadow="0"
|
||||||
|
inkscape:pageopacity="0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#505050"
|
||||||
|
inkscape:zoom="11.92186"
|
||||||
|
inkscape:cx="32.922716"
|
||||||
|
inkscape:cy="21.682859"
|
||||||
|
inkscape:window-width="1555"
|
||||||
|
inkscape:window-height="1000"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="svg1" />
|
||||||
|
<!---->
|
||||||
|
<!---->
|
||||||
|
<ellipse
|
||||||
|
style="fill:none;stroke:#000000;stroke-width:1.91177;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:none;stroke-opacity:1"
|
||||||
|
id="path2"
|
||||||
|
cx="12"
|
||||||
|
cy="11.999998"
|
||||||
|
rx="9.0441151"
|
||||||
|
ry="9.0441122" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
19
src/api.rs
19
src/api.rs
@@ -10,7 +10,7 @@ use image::{
|
|||||||
DynamicImage, EncodableLayout,
|
DynamicImage, EncodableLayout,
|
||||||
codecs::webp::{WebPDecoder, WebPEncoder},
|
codecs::webp::{WebPDecoder, WebPEncoder},
|
||||||
};
|
};
|
||||||
use immich_sdk::{AssetId, AssetVisibility};
|
use immich_sdk::{AlbumResponse, AssetId, AssetVisibility};
|
||||||
use slint::{Rgba8Pixel, SharedPixelBuffer};
|
use slint::{Rgba8Pixel, SharedPixelBuffer};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -180,7 +180,7 @@ impl Api {
|
|||||||
.buckets()
|
.buckets()
|
||||||
.execute()
|
.execute()
|
||||||
.await
|
.await
|
||||||
.context(anyhow!("Failed to fetch list of time buckets",))
|
.context(anyhow!("Failed to fetch list of time buckets"))
|
||||||
.inspect_err(|e| {
|
.inspect_err(|e| {
|
||||||
tracing::error!("{e:?}");
|
tracing::error!("{e:?}");
|
||||||
})?;
|
})?;
|
||||||
@@ -267,4 +267,19 @@ impl Api {
|
|||||||
.await
|
.await
|
||||||
.context(anyhow!("Failed to fetch asset {asset_id:?}"))
|
.context(anyhow!("Failed to fetch asset {asset_id:?}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_album_list(&self) -> anyhow::Result<Vec<AlbumResponse>> {
|
||||||
|
let albums = self
|
||||||
|
.client
|
||||||
|
.albums()
|
||||||
|
.list()
|
||||||
|
.execute()
|
||||||
|
.await
|
||||||
|
.context(anyhow!("Failed to fetch album list"))
|
||||||
|
.inspect_err(|e| {
|
||||||
|
tracing::error!("{e:?}");
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(albums)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
107
src/main.rs
107
src/main.rs
@@ -6,7 +6,7 @@ use std::{mem, ops::Deref, sync::Arc};
|
|||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use immich_sdk::AssetId;
|
use immich_sdk::AssetId;
|
||||||
use slint::{
|
use slint::{
|
||||||
ComponentHandle as _, Model, ModelRc, SharedPixelBuffer, SharedString, ToSharedString,
|
ComponentHandle as _, Image, Model, ModelRc, SharedPixelBuffer, SharedString, ToSharedString,
|
||||||
VecModel, Weak,
|
VecModel, Weak,
|
||||||
};
|
};
|
||||||
use tracing::Level;
|
use tracing::Level;
|
||||||
@@ -14,7 +14,7 @@ use tracing::Level;
|
|||||||
use crate::{
|
use crate::{
|
||||||
api::{Api, TimeBucketKey},
|
api::{Api, TimeBucketKey},
|
||||||
config::Config,
|
config::Config,
|
||||||
ui::{AppWindow, ImageBucket},
|
ui::AppWindow,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Use jemalloc to reduce memory fragmentation.
|
/// Use jemalloc to reduce memory fragmentation.
|
||||||
@@ -36,27 +36,6 @@ mod ui {
|
|||||||
#[derive(clap::Parser)]
|
#[derive(clap::Parser)]
|
||||||
struct Opt {}
|
struct Opt {}
|
||||||
|
|
||||||
// enum ApiReq<M: Send + 'static>
|
|
||||||
// where
|
|
||||||
// Api: Message<M>,
|
|
||||||
// {
|
|
||||||
// AskRequest(
|
|
||||||
// AskRequest<
|
|
||||||
// 'static,
|
|
||||||
// Api,
|
|
||||||
// M,
|
|
||||||
// kameo::request::WithoutRequestTimeout,
|
|
||||||
// kameo::request::WithoutRequestTimeout,
|
|
||||||
// >,
|
|
||||||
// ),
|
|
||||||
// PendingReply(PendingReply<M, <Api as Message<M>>::Reply>),
|
|
||||||
// }
|
|
||||||
|
|
||||||
// enum ApiReqs {
|
|
||||||
// GetBuckets(ApiReq<GetTimeBuckets>),
|
|
||||||
// GetBucket(ApiReq<GetTimeBucket>),
|
|
||||||
// }
|
|
||||||
|
|
||||||
fn main() -> anyhow::Result<()> {
|
fn main() -> anyhow::Result<()> {
|
||||||
let _opt = Opt::parse();
|
let _opt = Opt::parse();
|
||||||
|
|
||||||
@@ -200,6 +179,82 @@ fn start_api(app: &AppWindow, immich: &config::ImmichLogin) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let app_weak = app.as_weak();
|
||||||
|
let api = api_.clone();
|
||||||
|
global.on_load_albums(move || {
|
||||||
|
tracing::info!("on_load_albums()");
|
||||||
|
let api = api.clone();
|
||||||
|
let app_weak = app_weak.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let Ok(albums) = api
|
||||||
|
.get_album_list()
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::error!("Failed to load albums: {e}"))
|
||||||
|
else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
tracing::info!("loaded {} album covers", albums.len());
|
||||||
|
|
||||||
|
let mut thumbnail_tasks = vec![];
|
||||||
|
for album in &albums {
|
||||||
|
let Some(asset_id) = album.album_thumbnail_asset_id else {
|
||||||
|
thumbnail_tasks.push(None);
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
let api = api.clone();
|
||||||
|
let task = tokio::spawn(async move {
|
||||||
|
api.get_asset_thumbnail(asset_id)
|
||||||
|
.await
|
||||||
|
.inspect_err(|e| tracing::error!("Failed to get album thumbnail: {e}"))
|
||||||
|
});
|
||||||
|
thumbnail_tasks.push(Some(task));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut thumbnails = vec![];
|
||||||
|
for task in thumbnail_tasks {
|
||||||
|
if let Some(task) = task
|
||||||
|
&& let Ok(Ok(thumbnail)) = task.await
|
||||||
|
{
|
||||||
|
thumbnails.push(Some(thumbnail.thumbnail.clone()));
|
||||||
|
} else {
|
||||||
|
thumbnails.push(None);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let _ = app_weak.upgrade_in_event_loop(move |app| {
|
||||||
|
let albums = albums
|
||||||
|
.into_iter()
|
||||||
|
.zip(thumbnails)
|
||||||
|
.map(|(album, thumbnail)| {
|
||||||
|
ui::AlbumCover {
|
||||||
|
asset_count: album.asset_count as i32,
|
||||||
|
description: album.description.into(),
|
||||||
|
id: album.id.to_shared_string(),
|
||||||
|
name: album.album_name.into(),
|
||||||
|
thumbnail: thumbnail
|
||||||
|
.map(|t| ui::ImagePreview {
|
||||||
|
asset_id: album
|
||||||
|
.album_thumbnail_asset_id
|
||||||
|
.unwrap()
|
||||||
|
.to_shared_string(),
|
||||||
|
kind: ui::ImageKind::Thumbnail,
|
||||||
|
ratio: t.width() as f32 / t.height() as f32,
|
||||||
|
image: Image::from_rgba8(t),
|
||||||
|
})
|
||||||
|
.unwrap_or_default(), // TODO
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<VecModel<_>>();
|
||||||
|
let albums = ModelRc::new(albums);
|
||||||
|
|
||||||
|
let global = app.global::<ui::Global>();
|
||||||
|
global.set_albums(albums);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fn calculate_timeline_visibility(app: &AppWindow, api: Arc<Api>, scroll: f32) {
|
fn calculate_timeline_visibility(app: &AppWindow, api: Arc<Api>, scroll: f32) {
|
||||||
@@ -287,11 +342,11 @@ fn calculate_timeline_layout(app: &AppWindow, api: Arc<Api>, timeline_width: f32
|
|||||||
}
|
}
|
||||||
|
|
||||||
struct AppImageBuckets {
|
struct AppImageBuckets {
|
||||||
buckets: ModelRc<ImageBucket>,
|
buckets: ModelRc<ui::ImageBucket>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Deref for AppImageBuckets {
|
impl Deref for AppImageBuckets {
|
||||||
type Target = VecModel<ImageBucket>;
|
type Target = VecModel<ui::ImageBucket>;
|
||||||
|
|
||||||
#[track_caller]
|
#[track_caller]
|
||||||
fn deref(&self) -> &Self::Target {
|
fn deref(&self) -> &Self::Target {
|
||||||
@@ -302,7 +357,7 @@ impl Deref for AppImageBuckets {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_image_buckets(app: &AppWindow) -> impl Deref<Target = VecModel<ImageBucket>> {
|
fn get_image_buckets(app: &AppWindow) -> impl Deref<Target = VecModel<ui::ImageBucket>> {
|
||||||
let global = app.global::<ui::Global>();
|
let global = app.global::<ui::Global>();
|
||||||
AppImageBuckets {
|
AppImageBuckets {
|
||||||
buckets: global.get_image_buckets(),
|
buckets: global.get_image_buckets(),
|
||||||
|
|||||||
71
ui/albums.slint
Normal file
71
ui/albums.slint
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { AlbumCover } from "types.slint";
|
||||||
|
import { Global } from "global.slint";
|
||||||
|
import { ScrollView, HorizontalBox, Palette } from "std-widgets.slint";
|
||||||
|
import { ImagePreview } from "timeline.slint";
|
||||||
|
|
||||||
|
component AlbumCover {
|
||||||
|
in property <AlbumCover> album;
|
||||||
|
|
||||||
|
states [
|
||||||
|
pressed when touch.pressed: {
|
||||||
|
click-effect.opacity: 0.2;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
click-effect.opacity: 0;
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
background: Palette.alternate-background;
|
||||||
|
border-radius: 24px;
|
||||||
|
clip: true;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
spacing: 20px;
|
||||||
|
|
||||||
|
ImagePreview {
|
||||||
|
preview: album.thumbnail;
|
||||||
|
size: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: album.name;
|
||||||
|
horizontal-alignment: left;
|
||||||
|
vertical-alignment: center;
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
click_effect := Rectangle {
|
||||||
|
background: Palette.accent-foreground;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
touch := TouchArea {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component Albums {
|
||||||
|
private property <[AlbumCover]> albums: Global.albums;
|
||||||
|
|
||||||
|
property <length> min-image-size: Global.min-image-size;
|
||||||
|
property <length> min-size-with-margin: min-image-size + Global.image-margin;
|
||||||
|
|
||||||
|
ScrollView {
|
||||||
|
mouse-drag-pan-enabled: true;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: center;
|
||||||
|
VerticalLayout {
|
||||||
|
min-width: min-image-size;
|
||||||
|
alignment: start;
|
||||||
|
spacing: 10px;
|
||||||
|
|
||||||
|
for album[i] in albums : AlbumCover {
|
||||||
|
album: album;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -2,25 +2,21 @@ import { AboutSlint, Button, Palette, HorizontalBox, ScrollView } from "std-widg
|
|||||||
import { Timeline } from "timeline.slint";
|
import { Timeline } from "timeline.slint";
|
||||||
import { ImageViewer } from "image-viewer.slint";
|
import { ImageViewer } from "image-viewer.slint";
|
||||||
import { LoginView } from "login.slint";
|
import { LoginView } from "login.slint";
|
||||||
|
import { Header } from "header.slint";
|
||||||
|
import { Footer, FooterButton } from "footer.slint";
|
||||||
|
|
||||||
import { Global } from "global.slint";
|
import { Global } from "global.slint";
|
||||||
|
import { Albums } from "albums.slint";
|
||||||
export { Global }
|
export { Global }
|
||||||
|
|
||||||
component Header inherits Rectangle {
|
enum View {
|
||||||
width: 100%;
|
Timeline,
|
||||||
height: 48px;
|
Albums
|
||||||
background: Palette.alternate-background;
|
|
||||||
|
|
||||||
HorizontalBox {
|
|
||||||
height: parent.height;
|
|
||||||
Text {
|
|
||||||
text: "immich";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export component AppWindow inherits Window {
|
export component AppWindow inherits Window {
|
||||||
out property <length> window-height: self.height;
|
out property <length> window-height: self.height;
|
||||||
|
out property <View> view: View.Timeline;
|
||||||
|
|
||||||
// Do not base preferred-width on children
|
// Do not base preferred-width on children
|
||||||
preferred-width: 480px;
|
preferred-width: 480px;
|
||||||
@@ -36,7 +32,32 @@ export component AppWindow inherits Window {
|
|||||||
Header {}
|
Header {}
|
||||||
|
|
||||||
if !Global.logged-in: LoginView {}
|
if !Global.logged-in: LoginView {}
|
||||||
if Global.logged-in: Timeline {}
|
if Global.logged-in && view == View.Timeline: Timeline {}
|
||||||
|
if Global.logged-in && view == View.Albums: Albums {}
|
||||||
|
|
||||||
|
Footer {
|
||||||
|
FooterButton {
|
||||||
|
title: "Photos";
|
||||||
|
icon: @image-url("../assets/photos.svg");
|
||||||
|
clicked => { view = View.Timeline }
|
||||||
|
}
|
||||||
|
FooterButton {
|
||||||
|
title: "Search";
|
||||||
|
icon: @image-url("../assets/search.svg");
|
||||||
|
}
|
||||||
|
FooterButton {
|
||||||
|
title: "Album";
|
||||||
|
icon: @image-url("../assets/album.svg");
|
||||||
|
clicked => {
|
||||||
|
view = View.Albums;
|
||||||
|
Global.load-albums();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FooterButton {
|
||||||
|
title: "Library";
|
||||||
|
icon: @image-url("../assets/album.svg"); // TODO
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if Global.viewed-image.asset-id != "" : ImageViewer {
|
if Global.viewed-image.asset-id != "" : ImageViewer {
|
||||||
|
|||||||
59
ui/footer.slint
Normal file
59
ui/footer.slint
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { Palette } from "std-widgets.slint";
|
||||||
|
|
||||||
|
export component FooterButton inherits Rectangle {
|
||||||
|
in property <string> title: "Button";
|
||||||
|
in property <image> icon;
|
||||||
|
callback clicked <=> touch.clicked;
|
||||||
|
|
||||||
|
states [
|
||||||
|
pressed when touch.pressed: {
|
||||||
|
background: #0000ff30; // TODO: palette
|
||||||
|
}
|
||||||
|
hovered when touch.has-hover: {
|
||||||
|
background: #0000ff15; // TODO: palette
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
background: #0000;
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
animate background {
|
||||||
|
duration: 0.1s;
|
||||||
|
easing: ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
border-radius: 20px;
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: 10px;
|
||||||
|
padding-left: 20px;
|
||||||
|
padding-right: 20px;
|
||||||
|
Image {
|
||||||
|
source: icon;
|
||||||
|
colorize: Palette.accent-background;
|
||||||
|
}
|
||||||
|
Text {
|
||||||
|
text: title;
|
||||||
|
horizontal-alignment: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
touch := TouchArea {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component Footer inherits Rectangle {
|
||||||
|
width: 100%;
|
||||||
|
height: 88px;
|
||||||
|
background: Palette.alternate-background;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
height: parent.height;
|
||||||
|
spacing: 16px;
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 16px;
|
||||||
|
padding-left: 16px;
|
||||||
|
padding-right: 16px;
|
||||||
|
|
||||||
|
@children
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { ImageBucket, ImagePreview } from "types.slint";
|
import { ImageBucket, ImagePreview, AlbumCover } from "types.slint";
|
||||||
|
|
||||||
export global Global {
|
export global Global {
|
||||||
in-out property <bool> logged-in: false;
|
in-out property <bool> logged-in: false;
|
||||||
in-out property <length> min-image-size: 160px;
|
in-out property <length> min-image-size: 88px;
|
||||||
in-out property <length> image-margin: 2px;
|
in-out property <length> image-margin: 2px;
|
||||||
in-out property <ImagePreview> viewed-image;
|
in-out property <ImagePreview> viewed-image;
|
||||||
|
in-out property <[AlbumCover]> albums;
|
||||||
in-out property <[ImageBucket]> image-buckets: [
|
in-out property <[ImageBucket]> image-buckets: [
|
||||||
{ key: "2026-02-01", title: "Feb 1, 2026", count: 12 },
|
{ key: "2026-02-01", title: "Feb 1, 2026", count: 12 },
|
||||||
{ key: "2026-02-02", title: "Feb 2, 2026", count: 12 },
|
{ key: "2026-02-02", title: "Feb 2, 2026", count: 12 },
|
||||||
@@ -17,4 +18,5 @@ export global Global {
|
|||||||
callback set-timeline-width(length);
|
callback set-timeline-width(length);
|
||||||
callback timeline-scrolled(length);
|
callback timeline-scrolled(length);
|
||||||
callback view-image(string);
|
callback view-image(string);
|
||||||
|
callback load-albums();
|
||||||
}
|
}
|
||||||
|
|||||||
14
ui/header.slint
Normal file
14
ui/header.slint
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { HorizontalBox, Palette } from "std-widgets.slint";
|
||||||
|
|
||||||
|
export component Header inherits Rectangle {
|
||||||
|
width: 100%;
|
||||||
|
height: 48px;
|
||||||
|
background: Palette.alternate-background;
|
||||||
|
|
||||||
|
HorizontalBox {
|
||||||
|
height: parent.height;
|
||||||
|
Text {
|
||||||
|
text: "immich";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { ScrollView } from "std-widgets.slint";
|
import { ScrollView, Palette } from "std-widgets.slint";
|
||||||
import { Global } from "global.slint";
|
import { Global } from "global.slint";
|
||||||
import { ImageBucket, Visibility, ImagePreview } from "types.slint";
|
import { ImageBucket, Visibility, ImagePreview } from "types.slint";
|
||||||
|
|
||||||
component ImagePreview inherits Rectangle {
|
export component ImagePreview inherits Rectangle {
|
||||||
in property <ImagePreview> preview;
|
in property <ImagePreview> preview;
|
||||||
in property <length> size: 32px;
|
in property <length> size: 32px;
|
||||||
|
callback clicked <=> touch.clicked;
|
||||||
|
|
||||||
width: size;
|
width: size;
|
||||||
height: size;
|
height: size;
|
||||||
clip: true;
|
clip: true;
|
||||||
@@ -15,12 +17,7 @@ component ImagePreview inherits Rectangle {
|
|||||||
source: preview.image;
|
source: preview.image;
|
||||||
}
|
}
|
||||||
|
|
||||||
touch := TouchArea {
|
touch := TouchArea {}
|
||||||
clicked => {
|
|
||||||
Global.viewed-image = root.preview;
|
|
||||||
Global.view-image(root.preview.asset-id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
component TimelineBlock inherits VerticalLayout {
|
component TimelineBlock inherits VerticalLayout {
|
||||||
@@ -40,25 +37,46 @@ component TimelineBlock inherits VerticalLayout {
|
|||||||
property <length> image-size: calc-image-size();
|
property <length> image-size: calc-image-size();
|
||||||
property <length> image-size-with-margin: image-size + Global.image-margin;
|
property <length> image-size-with-margin: image-size + Global.image-margin;
|
||||||
|
|
||||||
property <length> title-box-height: 36px;
|
property <length> title-box-height: 44px;
|
||||||
height: title-box.height + count-y * image-size-with-margin;
|
height: title-box-height + count-y * image-size-with-margin;
|
||||||
|
|
||||||
y: bucket.y;
|
y: bucket.y;
|
||||||
min-width: min-image-size;
|
min-width: min-image-size;
|
||||||
alignment: start;
|
alignment: start;
|
||||||
|
|
||||||
title-box := HorizontalLayout {
|
title-box := Rectangle {
|
||||||
|
property <bool> checked: false;
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
alignment: space-between;
|
alignment: space-between;
|
||||||
height: title-box-height;
|
height: title-box-height;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
|
|
||||||
Text {
|
title := Text {
|
||||||
text: bucket.title;
|
text: bucket.title;
|
||||||
|
font-size: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: checkbox thingy
|
if !checked : Image {
|
||||||
Text {
|
source: @image-url("../assets/unchecked.svg");
|
||||||
text: "O";
|
colorize: Palette.foreground;
|
||||||
|
opacity: 0.8;
|
||||||
|
height: title.height;
|
||||||
|
width: self.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
if checked : Image {
|
||||||
|
source: @image-url("../assets/checked.svg");
|
||||||
|
colorize: Palette.accent-background;
|
||||||
|
height: title.height;
|
||||||
|
width: self.height;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
title-touch := TouchArea {
|
||||||
|
clicked => {
|
||||||
|
parent.checked = !parent.checked;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,13 +89,58 @@ component TimelineBlock inherits VerticalLayout {
|
|||||||
size: image-size;
|
size: image-size;
|
||||||
x: Global.image-margin / 2 + Math.mod(i, count-x) * (Global.image-margin + image-size);
|
x: Global.image-margin / 2 + Math.mod(i, count-x) * (Global.image-margin + image-size);
|
||||||
y: Math.floor(i / count-x) * (image-size + Global.image-margin);
|
y: Math.floor(i / count-x) * (image-size + Global.image-margin);
|
||||||
|
clicked => {
|
||||||
|
Global.viewed-image = preview;
|
||||||
|
Global.view-image(preview.asset-id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export component Timeline inherits ScrollView {
|
export component ScrollHandle {
|
||||||
|
out property<float> maximum: 1;
|
||||||
|
out property<float> minimum: 0;
|
||||||
|
in-out property<float> value;
|
||||||
|
callback dragged(float);
|
||||||
|
|
||||||
|
width: handle.width * 0.66;
|
||||||
|
horizontal-stretch: 0;
|
||||||
|
vertical-stretch: 1;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
handle := Rectangle {
|
||||||
|
x: 0;
|
||||||
|
width: 64px;
|
||||||
|
height: self.width;
|
||||||
|
border-width: 3px;
|
||||||
|
border-radius: self.height / 2;
|
||||||
|
background: touch.pressed ? Palette.accent-background : Palette.alternate-background;
|
||||||
|
border-color: Palette.accent-foreground;
|
||||||
|
y: (root.height - handle.height) * (root.value - root.minimum)/(root.maximum - root.minimum);
|
||||||
|
|
||||||
|
touch := TouchArea {
|
||||||
|
moved => {
|
||||||
|
if (self.enabled && self.pressed) {
|
||||||
|
root.value = max(root.minimum, min(root.maximum,
|
||||||
|
root.value + (self.mouse-y - self.pressed-y) * (root.maximum - root.minimum) / root.height));
|
||||||
|
dragged(root.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component Timeline {
|
||||||
|
scroll-view := ScrollView {
|
||||||
mouse-drag-pan-enabled: true;
|
mouse-drag-pan-enabled: true;
|
||||||
viewport-height: rect.height;
|
viewport-height: rect.height;
|
||||||
|
vertical-scrollbar-policy: always-off;
|
||||||
|
horizontal-scrollbar-policy: always-off;
|
||||||
|
|
||||||
|
scrolled => {
|
||||||
|
// sync ScrollHandle with ScrollView
|
||||||
|
scroll-handle.value = (-scroll-view.viewport-y) / scroll-view.viewport-height;
|
||||||
|
}
|
||||||
|
|
||||||
changed viewport-y => {
|
changed viewport-y => {
|
||||||
Global.timeline-scrolled(-self.viewport-y);
|
Global.timeline-scrolled(-self.viewport-y);
|
||||||
@@ -99,3 +162,13 @@ export component Timeline inherits ScrollView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
scroll-handle := ScrollHandle {
|
||||||
|
x: parent.x + parent.width - self.width;
|
||||||
|
height: root.height;
|
||||||
|
dragged(value) => {
|
||||||
|
// sync ScrollView with ScrollHandle
|
||||||
|
scroll-view.viewport-y = -(value * scroll-view.viewport-height);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -33,3 +33,11 @@ export struct ImageBucket {
|
|||||||
visibility: Visibility,
|
visibility: Visibility,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export struct AlbumCover {
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
description: string,
|
||||||
|
thumbnail: ImagePreview,
|
||||||
|
asset_count: int,
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user