From 63c8e676af152e6256427fa9fb966f15e15aa8e3 Mon Sep 17 00:00:00 2001 From: Lennart Kloock Date: Thu, 18 Jan 2024 21:26:51 +0100 Subject: [PATCH 01/21] refactor(platform): image upload --- .../{profile_picture.rs => image_upload.rs} | 138 +++++++----------- .../v1/upload/image_upload/profile_picture.rs | 83 +++++++++++ platform/api/src/api/v1/upload/mod.rs | 4 +- 3 files changed, 138 insertions(+), 87 deletions(-) rename platform/api/src/api/v1/upload/{profile_picture.rs => image_upload.rs} (60%) create mode 100644 platform/api/src/api/v1/upload/image_upload/profile_picture.rs diff --git a/platform/api/src/api/v1/upload/profile_picture.rs b/platform/api/src/api/v1/upload/image_upload.rs similarity index 60% rename from platform/api/src/api/v1/upload/profile_picture.rs rename to platform/api/src/api/v1/upload/image_upload.rs index e0e3dd7d..10ca9edb 100644 --- a/platform/api/src/api/v1/upload/profile_picture.rs +++ b/platform/api/src/api/v1/upload/image_upload.rs @@ -2,15 +2,13 @@ use std::sync::Arc; use aws_sdk_s3::types::ObjectCannedAcl; use bytes::Bytes; -use common::http::ext::ResultExt; -use common::http::RouteError; -use common::make_response; -use common::s3::PutObjectOptions; -use hyper::{Response, StatusCode}; -use pb::scuffle::platform::internal::image_processor; -use pb::scuffle::platform::internal::types::{uploaded_file_metadata, ImageFormat, UploadedFileMetadata}; +use common::{database::deadpool_postgres::Transaction, http::{RouteError, ext::ResultExt}, s3::PutObjectOptions, make_response}; +use pb::scuffle::platform::internal::{image_processor, types::{UploadedFileMetadata, uploaded_file_metadata}}; use serde_json::json; use ulid::Ulid; +use hyper::{Response, StatusCode}; + +use crate::{global::ApiGlobal, api::{auth::AuthData, Body, error::ApiError}, database::FileType}; use super::UploadType; use crate::api::auth::AuthData; @@ -20,37 +18,11 @@ use crate::config::{ApiConfig, ImageUploaderConfig}; use crate::database::{FileType, RolePermission, UploadedFileStatus}; use crate::global::ApiGlobal; -fn create_task(file_id: Ulid, input_path: &str, config: &ImageUploaderConfig, owner_id: Ulid) -> image_processor::Task { - image_processor::Task { - input_path: input_path.to_string(), - base_height: 128, // 128, 256, 384, 512 - base_width: 128, // 128, 256, 384, 512 - formats: vec![ - ImageFormat::PngStatic as i32, - ImageFormat::AvifStatic as i32, - ImageFormat::WebpStatic as i32, - ImageFormat::Gif as i32, - ImageFormat::Webp as i32, - ImageFormat::Avif as i32, - ], - callback_subject: config.callback_subject.clone(), - limits: Some(image_processor::task::Limits { - max_input_duration_ms: 10 * 1000, // 10 seconds - max_input_frame_count: 300, - max_input_height: 1000, - max_input_width: 1000, - max_processing_time_ms: 60 * 1000, // 60 seconds - }), - resize_algorithm: image_processor::task::ResizeAlgorithm::Lanczos3 as i32, - upscale: true, // For profile pictures we want to have a consistent size - scales: vec![1, 2, 3, 4], - resize_method: image_processor::task::ResizeMethod::PadCenter as i32, - output_prefix: format!("{owner_id}/{file_id}"), - } -} +pub(crate) mod profile_picture; +// pub(crate) mod offline_banner; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] -enum AcceptedFormats { +pub(super) enum AcceptedFormats { Webp, Avif, Avifs, @@ -132,46 +104,55 @@ impl AcceptedFormats { } } -#[derive(Default, serde::Deserialize)] -#[serde(default)] -pub(super) struct ProfilePicture { - set_active: bool, -} +pub(super) trait ImageUploadRequest { + fn create_task(global: &Arc, auth: &AuthData, format: AcceptedFormats, file_id: Ulid, owner_id: Ulid) -> image_processor::Task; -impl UploadType for ProfilePicture { - fn validate_format(_: &Arc, _: &AuthData, content_type: &str) -> bool { - AcceptedFormats::from_content_type(content_type).is_some() - } + fn task_priority(global: &Arc) -> i64; - fn validate_permissions(&self, auth: &AuthData) -> bool { - auth.user_permissions.has_permission(RolePermission::UploadProfilePicture) - } + fn get_max_size(global: &Arc) -> usize; - fn get_max_size(global: &Arc) -> usize { - global.config::().max_profile_picture_size - } + fn validate_permissions(auth: &AuthData) -> bool; + + fn file_type(global: &Arc) -> FileType; + + async fn process(&self, auth: &AuthData, tx: &Transaction, file_id: Ulid) -> Result<(), RouteError>; +} - async fn handle( - self, - global: &Arc, - auth: AuthData, - name: Option, - file: Bytes, - content_type: &str, - ) -> Result, RouteError> { - let image_format = AcceptedFormats::from_content_type(content_type) +impl UploadType for T { + fn validate_format(_global: &Arc, _auth: &AuthData, content_type: &str) -> bool { + AcceptedFormats::from_content_type(content_type).is_some() + } + + fn validate_permissions(&self, auth: &AuthData) -> bool { + T::validate_permissions(auth) + } + + fn get_max_size(global: &Arc) -> usize { + T::get_max_size(global) + } + + async fn handle( + self, + global: &Arc, + auth: AuthData, + name: Option, + file: Bytes, + content_type: &str, + ) -> Result, RouteError> { + let image_format = AcceptedFormats::from_content_type(content_type) .ok_or((StatusCode::BAD_REQUEST, "invalid content-type header"))?; let file_id = Ulid::new(); - let config = global.config::(); + let task = T::create_task( + global, + &auth, + image_format, + file_id, + auth.session.user_id, + ); - let input_path = format!( - "{}/profile_pictures/{}/source.{}", - auth.session.user_id, - file_id, - image_format.ext() - ); + let input_path = task.input_path.clone(); let mut client = global .db() @@ -185,13 +166,8 @@ impl UploadType for ProfilePicture { common::database::query("INSERT INTO image_jobs (id, priority, task) VALUES ($1, $2, $3)") .bind(file_id) - .bind(config.profile_picture_task_priority) - .bind(common::database::Protobuf(create_task( - file_id, - &input_path, - config, - auth.session.user_id, - ))) + .bind(T::task_priority(global)) + .bind(common::database::Protobuf(task)) .build() .execute(&tx) .await @@ -202,7 +178,7 @@ impl UploadType for ProfilePicture { .bind(auth.session.user_id) // owner_id .bind(auth.session.user_id) // uploader_id .bind(name.unwrap_or_else(|| format!("untitled.{}", image_format.ext()))) // name - .bind(FileType::ProfilePicture) // type + .bind(T::file_type(global)) // type .bind(common::database::Protobuf(UploadedFileMetadata { metadata: Some(uploaded_file_metadata::Metadata::Image(uploaded_file_metadata::Image { versions: Vec::new(), @@ -216,15 +192,7 @@ impl UploadType for ProfilePicture { .await .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to insert uploaded file"))?; - if self.set_active { - common::database::query("UPDATE users SET pending_profile_picture_id = $1 WHERE id = $2") - .bind(file_id) - .bind(auth.session.user_id) - .build() - .execute(&tx) - .await - .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to update user"))?; - } + T::process(&self, &auth, &tx, file_id).await?; global .image_uploader_s3() @@ -257,5 +225,5 @@ impl UploadType for ProfilePicture { "file_id": file_id.to_string(), }) )) - } + } } diff --git a/platform/api/src/api/v1/upload/image_upload/profile_picture.rs b/platform/api/src/api/v1/upload/image_upload/profile_picture.rs new file mode 100644 index 00000000..cc95c5ba --- /dev/null +++ b/platform/api/src/api/v1/upload/image_upload/profile_picture.rs @@ -0,0 +1,83 @@ +use std::sync::Arc; + +use common::{database::deadpool_postgres::Transaction, http::{ext::ResultExt, RouteError}}; +use pb::scuffle::platform::internal::{image_processor, types::ImageFormat}; +use ulid::Ulid; +use hyper::StatusCode; + +use crate::{global::ApiGlobal, config::{ImageUploaderConfig, ApiConfig}, database::{FileType, RolePermission}, api::{auth::AuthData, error::ApiError}}; + +use super::{ImageUploadRequest, AcceptedFormats}; + +#[derive(Default, serde::Deserialize)] +#[serde(default)] +pub struct ProfilePicture { + set_active: bool, +} + +impl ImageUploadRequest for ProfilePicture { + fn create_task(global: &Arc, auth: &AuthData, format: AcceptedFormats, file_id: Ulid, owner_id: Ulid) -> image_processor::Task { + let config = global.config::(); + + image_processor::Task { + input_path: format!( + "{}/profile_pictures/{}/source.{}", + auth.session.user_id, + file_id, + format.ext() + ), + base_height: 128, // 128, 256, 384, 512 + base_width: 128, // 128, 256, 384, 512 + formats: vec![ + ImageFormat::PngStatic as i32, + ImageFormat::AvifStatic as i32, + ImageFormat::WebpStatic as i32, + ImageFormat::Gif as i32, + ImageFormat::Webp as i32, + ImageFormat::Avif as i32, + ], + callback_subject: config.profile_picture_callback_subject.clone(), + limits: Some(image_processor::task::Limits { + max_input_duration_ms: 10 * 1000, // 10 seconds + max_input_frame_count: 300, + max_input_height: 1000, + max_input_width: 1000, + max_processing_time_ms: 60 * 1000, // 60 seconds + }), + resize_algorithm: image_processor::task::ResizeAlgorithm::Lanczos3 as i32, + upscale: true, // For profile pictures we want to have a consistent size + scales: vec![1, 2, 3, 4], + resize_method: image_processor::task::ResizeMethod::PadCenter as i32, + output_prefix: format!("{owner_id}/{file_id}"), + } + } + + fn task_priority(global: &std::sync::Arc) -> i64 { + global.config::().profile_picture_task_priority + } + + fn get_max_size(global: &Arc) -> usize { + global.config::().max_profile_picture_size + } + + fn validate_permissions(auth: &AuthData) -> bool { + auth.user_permissions.has_permission(RolePermission::UploadProfilePicture) + } + + fn file_type(_global: &std::sync::Arc) -> FileType { + FileType::ProfilePicture + } + + async fn process(&self, auth: &AuthData, tx: &Transaction<'_>, file_id: Ulid) -> Result<(), RouteError> { + if self.set_active { + common::database::query("UPDATE users SET pending_profile_picture_id = $1 WHERE id = $2") + .bind(file_id) + .bind(auth.session.user_id) + .build() + .execute(&tx) + .await + .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to update user"))?; + } + Ok(()) + } +} diff --git a/platform/api/src/api/v1/upload/mod.rs b/platform/api/src/api/v1/upload/mod.rs index 2d9410f0..eeb4563c 100644 --- a/platform/api/src/api/v1/upload/mod.rs +++ b/platform/api/src/api/v1/upload/mod.rs @@ -11,7 +11,7 @@ use hyper::body::Incoming; use hyper::{Request, Response, StatusCode}; use multer::{Constraints, SizeLimit}; -use self::profile_picture::ProfilePicture; +use self::image_upload::profile_picture::ProfilePicture; use crate::api::auth::AuthData; use crate::api::error::ApiError; use crate::api::request_context::RequestContext; @@ -19,7 +19,7 @@ use crate::api::Body; use crate::global::ApiGlobal; use crate::turnstile::validate_turnstile_token; -pub(crate) mod profile_picture; +pub(crate) mod image_upload; trait UploadType: serde::de::DeserializeOwned + Default { fn validate_format(global: &Arc, auth: &AuthData, content_type: &str) -> bool; From d33b7d8006754af094b6d05e936a381e6b3e1ffa Mon Sep 17 00:00:00 2001 From: Lennart Kloock Date: Fri, 19 Jan 2024 00:27:08 +0100 Subject: [PATCH 02/21] feat(platform): add offline banner upload endpoint --- .../api/src/api/v1/upload/image_upload.rs | 2 +- .../v1/upload/image_upload/offline_banner.rs | 83 +++++++++++++++++++ platform/api/src/api/v1/upload/mod.rs | 5 +- platform/api/src/config.rs | 9 ++ platform/api/src/database/role.rs | 2 + .../migrations/20230825170300_init.up.sql | 1 + 6 files changed, 100 insertions(+), 2 deletions(-) create mode 100644 platform/api/src/api/v1/upload/image_upload/offline_banner.rs diff --git a/platform/api/src/api/v1/upload/image_upload.rs b/platform/api/src/api/v1/upload/image_upload.rs index 10ca9edb..2bf7d55e 100644 --- a/platform/api/src/api/v1/upload/image_upload.rs +++ b/platform/api/src/api/v1/upload/image_upload.rs @@ -19,7 +19,7 @@ use crate::database::{FileType, RolePermission, UploadedFileStatus}; use crate::global::ApiGlobal; pub(crate) mod profile_picture; -// pub(crate) mod offline_banner; +pub(crate) mod offline_banner; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(super) enum AcceptedFormats { diff --git a/platform/api/src/api/v1/upload/image_upload/offline_banner.rs b/platform/api/src/api/v1/upload/image_upload/offline_banner.rs new file mode 100644 index 00000000..b3278e9d --- /dev/null +++ b/platform/api/src/api/v1/upload/image_upload/offline_banner.rs @@ -0,0 +1,83 @@ +use std::sync::Arc; + +use common::{http::{RouteError, ext::ResultExt}, database::deadpool_postgres::Transaction}; +use pb::scuffle::platform::internal::{image_processor, types::ImageFormat}; +use hyper::StatusCode; + +use crate::{api::{auth::AuthData, error::ApiError}, database::{FileType, RolePermission}, global::ApiGlobal, config::{ImageUploaderConfig, ApiConfig}}; + +use super::{ImageUploadRequest, AcceptedFormats}; + + +#[derive(Default, serde::Deserialize)] +#[serde(default)] +pub(crate) struct OfflineBanner { + set_active: bool, +} + +impl ImageUploadRequest for OfflineBanner { + fn create_task(global: &Arc, auth: &AuthData, format: AcceptedFormats, file_id: ulid::Ulid, owner_id: ulid::Ulid) -> image_processor::Task { + let config = global.config::(); + + image_processor::Task { + input_path: format!( + "{}/offliner_banners/{}/source.{}", + auth.session.user_id, + file_id, + format.ext() + ), + base_height: 128, // 128, 256, 384, 512 + base_width: 128, // 128, 256, 384, 512 + formats: vec![ + ImageFormat::PngStatic as i32, + ImageFormat::AvifStatic as i32, + ImageFormat::WebpStatic as i32, + ImageFormat::Gif as i32, + ImageFormat::Webp as i32, + ImageFormat::Avif as i32, + ], + callback_subject: config.offline_banner_callback_subject.clone(), + limits: Some(image_processor::task::Limits { + max_input_duration_ms: 10 * 1000, // 10 seconds + max_input_frame_count: 300, + max_input_height: 1000, + max_input_width: 2000, + max_processing_time_ms: 60 * 1000, // 60 seconds + }), + resize_algorithm: image_processor::task::ResizeAlgorithm::Lanczos3 as i32, + upscale: true, // For profile pictures we want to have a consistent size + scales: vec![1, 2, 3, 4], + resize_method: image_processor::task::ResizeMethod::PadCenter as i32, + output_prefix: format!("{owner_id}/{file_id}"), + } + } + + fn task_priority(global: &Arc) -> i64 { + global.config::().offline_banner_task_priority + } + + fn get_max_size(global: &Arc) -> usize { + global.config::().max_offline_banner_size + } + + fn validate_permissions(auth: &AuthData) -> bool { + auth.user_permissions.has_permission(RolePermission::UploadOfflineBanner) + } + + fn file_type(_global: &Arc) -> FileType { + FileType::OfflineBanner + } + + async fn process(&self, auth: &AuthData, tx: &Transaction<'_>, file_id: ulid::Ulid) -> Result<(), RouteError> { + if self.set_active { + common::database::query("UPDATE users SET channel_offline_banner_id = $1 WHERE id = $2") + .bind(file_id) + .bind(auth.session.user_id) + .build() + .execute(&tx) + .await + .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to update user"))?; + } + Ok(()) + } +} diff --git a/platform/api/src/api/v1/upload/mod.rs b/platform/api/src/api/v1/upload/mod.rs index eeb4563c..6476c956 100644 --- a/platform/api/src/api/v1/upload/mod.rs +++ b/platform/api/src/api/v1/upload/mod.rs @@ -11,6 +11,7 @@ use hyper::body::Incoming; use hyper::{Request, Response, StatusCode}; use multer::{Constraints, SizeLimit}; +use self::image_upload::offline_banner::OfflineBanner; use self::image_upload::profile_picture::ProfilePicture; use crate::api::auth::AuthData; use crate::api::error::ApiError; @@ -39,7 +40,9 @@ trait UploadType: serde::de::DeserializeOwned + Default { } pub fn routes(_: &Arc) -> RouterBuilder> { - Router::builder().post("/profile-picture", handler::) + Router::builder() + .post("/profile-picture", handler::) + .post("/offline-banner", handler::) } async fn handler(req: Request) -> Result, RouteError> { diff --git a/platform/api/src/config.rs b/platform/api/src/config.rs index bdeb65a7..39806842 100644 --- a/platform/api/src/config.rs +++ b/platform/api/src/config.rs @@ -15,6 +15,9 @@ pub struct ApiConfig { /// Max profile picture upload size pub max_profile_picture_size: usize, + + /// Max offline banner upload size + pub max_offline_banner_size: usize, } impl Default for ApiConfig { @@ -23,6 +26,7 @@ impl Default for ApiConfig { bind_address: "[::]:4000".parse().expect("failed to parse bind address"), tls: None, max_profile_picture_size: 5 * 1024 * 1024, // 5 MB + max_offline_banner_size: 10 * 1024 * 1024, // 10 MB } } } @@ -82,6 +86,9 @@ pub struct ImageUploaderConfig { /// Igdb image task priority, higher number means higher priority pub igdb_image_task_priority: i32, + + /// Offline banner task priority, higher number means higher priority + pub offline_banner_task_priority: i64, } impl Default for ImageUploaderConfig { @@ -89,6 +96,8 @@ impl Default for ImageUploaderConfig { Self { bucket: S3BucketConfig::default(), callback_subject: "scuffle-platform-image_processor-callback".to_string(), + profile_picture_task_priority: 2, + offline_banner_task_priority: 2, public_endpoint: "https://images.scuffle.tv/scuffle-image-processor-public".to_string(), profile_picture_task_priority: 2, igdb_image_task_priority: 1, diff --git a/platform/api/src/database/role.rs b/platform/api/src/database/role.rs index e93fc253..1c0e2bc9 100644 --- a/platform/api/src/database/role.rs +++ b/platform/api/src/database/role.rs @@ -31,6 +31,8 @@ pub enum RolePermission { StreamRecording, /// Upload Profile Pictures UploadProfilePicture, + /// Upload Offline Banners + UploadOfflineBanner, } impl<'a> postgres_types::FromSql<'a> for RolePermission { diff --git a/platform/migrations/20230825170300_init.up.sql b/platform/migrations/20230825170300_init.up.sql index 7e30564b..bf367969 100644 --- a/platform/migrations/20230825170300_init.up.sql +++ b/platform/migrations/20230825170300_init.up.sql @@ -41,6 +41,7 @@ CREATE TABLE users ( channel_links JSONB NOT NULL DEFAULT '[]'::JSONB, channel_custom_thumbnail_id UUID, channel_offline_banner_id UUID, + channel_pending_offline_banner_id UUID, channel_category_id UUID, channel_stream_key VARCHAR(256), channel_role_order UUID[] NOT NULL DEFAULT '{}'::UUID[], From b13e7634b7883e78b3702fe612cac3a5eb715d39 Mon Sep 17 00:00:00 2001 From: Lennart Kloock Date: Fri, 19 Jan 2024 01:28:34 +0100 Subject: [PATCH 03/21] feat(platform): handle offline banner callback --- .../v1/upload/image_upload/offline_banner.rs | 2 +- .../v1/upload/image_upload/profile_picture.rs | 2 +- platform/api/src/image_upload_callback.rs | 218 ++++++++++++++++++ platform/api/src/subscription.rs | 2 + .../platform/internal/events/api.proto | 6 + 5 files changed, 228 insertions(+), 2 deletions(-) create mode 100644 platform/api/src/image_upload_callback.rs diff --git a/platform/api/src/api/v1/upload/image_upload/offline_banner.rs b/platform/api/src/api/v1/upload/image_upload/offline_banner.rs index b3278e9d..6c81cde1 100644 --- a/platform/api/src/api/v1/upload/image_upload/offline_banner.rs +++ b/platform/api/src/api/v1/upload/image_upload/offline_banner.rs @@ -36,7 +36,7 @@ impl ImageUploadRequest for OfflineBanner { ImageFormat::Webp as i32, ImageFormat::Avif as i32, ], - callback_subject: config.offline_banner_callback_subject.clone(), + callback_subject: format!("{}.{}", config.callback_subject, config.offline_banner_suffix), limits: Some(image_processor::task::Limits { max_input_duration_ms: 10 * 1000, // 10 seconds max_input_frame_count: 300, diff --git a/platform/api/src/api/v1/upload/image_upload/profile_picture.rs b/platform/api/src/api/v1/upload/image_upload/profile_picture.rs index cc95c5ba..84b384ba 100644 --- a/platform/api/src/api/v1/upload/image_upload/profile_picture.rs +++ b/platform/api/src/api/v1/upload/image_upload/profile_picture.rs @@ -36,7 +36,7 @@ impl ImageUploadRequest for ProfilePicture { ImageFormat::Webp as i32, ImageFormat::Avif as i32, ], - callback_subject: config.profile_picture_callback_subject.clone(), + callback_subject: format!("{}.{}", config.callback_subject, config.profile_picture_suffix), limits: Some(image_processor::task::Limits { max_input_duration_ms: 10 * 1000, // 10 seconds max_input_frame_count: 300, diff --git a/platform/api/src/image_upload_callback.rs b/platform/api/src/image_upload_callback.rs new file mode 100644 index 00000000..90c1cb69 --- /dev/null +++ b/platform/api/src/image_upload_callback.rs @@ -0,0 +1,218 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Context; +use async_nats::jetstream::AckKind; +use futures_util::StreamExt; +use pb::ext::UlidExt; +use pb::scuffle::platform::internal::events::{processed_image, ProcessedImage}; +use pb::scuffle::platform::internal::types::{uploaded_file_metadata, UploadedFileMetadata}; +use prost::Message; +use tokio::select; + +use crate::config::ImageUploaderConfig; +use crate::database::UploadedFile; +use crate::global::ApiGlobal; +use crate::subscription::SubscriptionTopic; + +const IMAGE_UPLOAD_CONSUMER_NAME: &str = "image-upload-consumer"; + +enum Subject { + ProfilePicture, + OfflineBanner, +} + +pub async fn run(global: Arc) -> anyhow::Result<()> { + let config = global.config::(); + + let wildcard = format!("{}.>", config.callback_subject); + + // It can't contain dots for some reason + let stream_name = config.callback_subject.replace(".", "-"); + + let image_upload_stream = global + .jetstream() + .get_or_create_stream(async_nats::jetstream::stream::Config { + name: stream_name, + subjects: vec![wildcard.clone()], + max_consumers: 1, + ..Default::default() + }) + .await + .context("failed to create image upload stream")?; + + let image_upload_consumer = image_upload_stream + .get_or_create_consumer( + IMAGE_UPLOAD_CONSUMER_NAME, + async_nats::jetstream::consumer::pull::Config { + name: Some(IMAGE_UPLOAD_CONSUMER_NAME.into()), + durable_name: Some(IMAGE_UPLOAD_CONSUMER_NAME.into()), + filter_subject: wildcard.clone(), + ..Default::default() + }, + ) + .await + .context("failed to create image upload consumer")?; + + let mut image_upload_consumer = image_upload_consumer + .messages() + .await + .context("failed to get image upload consumer messages")?; + + loop { + select! { + _ = global.ctx().done() => break, + message = image_upload_consumer.next() => { + let message = message.ok_or_else(|| anyhow::anyhow!("image upload consumer closed"))?.context("failed to get image upload consumer message")?; + + let subject = if message.subject.ends_with(&config.profile_picture_suffix) { + Subject::ProfilePicture + } else if message.subject.ends_with(&config.offline_banner_suffix) { + Subject::OfflineBanner + } else { + tracing::warn!("unknown image upload subject: {}", message.subject); + message.ack().await.map_err(|err| anyhow::anyhow!(err)).context("failed to ack")?; + continue; + }; + + let (job_id, job_result) = match ProcessedImage::decode(message.payload.as_ref()) { + Ok(ProcessedImage { job_id, result: Some(result) }) => (job_id, result), + err => { + if let Err(err) = err { + tracing::warn!(error = %err, "failed to decode image upload job result"); + } else { + tracing::warn!("malformed image upload job result"); + } + message.ack().await.map_err(|err| anyhow::anyhow!(err)).context("failed to ack")?; + continue; + }, + }; + tracing::debug!("received image upload job result: {:?}", job_result); + + let mut client = global.db().get().await.context("failed to get db connection")?; + let tx = client.transaction().await.context("failed to start transaction")?; + + match job_result { + processed_image::Result::Success(processed_image::Success { variants }) => { + let uploaded_file: UploadedFile = match common::database::query("UPDATE uploaded_files SET pending = FALSE, metadata = $1, updated_at = NOW() WHERE id = $2 AND pending = TRUE RETURNING *") + .bind(common::database::Protobuf(UploadedFileMetadata { + metadata: Some(uploaded_file_metadata::Metadata::Image(uploaded_file_metadata::Image { + versions: variants, + })), + })) + .bind(job_id.into_ulid()) + .build_query_as() + .fetch_optional(&tx) + .await + .context("failed to get uploaded file")? { + Some(uploaded_file) => uploaded_file, + None => { + tracing::warn!("uploaded file not found"); + message.ack().await.map_err(|err| anyhow::anyhow!(err)).context("failed to ack")?; + continue; + } + }; + + global + .nats() + .publish( + SubscriptionTopic::UploadedFileStatus(uploaded_file.id), + pb::scuffle::platform::internal::events::UploadedFileStatus { + file_id: Some(uploaded_file.id.into()), + status: Some(pb::scuffle::platform::internal::events::uploaded_file_status::Status::Success(pb::scuffle::platform::internal::events::uploaded_file_status::Success {})), + }.encode_to_vec().into(), + ) + .await + .context("failed to publish file update event")?; + + let mut qb = common::database::query("UPDATE users SET "); + let (pending_column, column) = match subject { + Subject::ProfilePicture => ("pending_profile_picture_id", "profile_picture_id"), + Subject::OfflineBanner => ("pending_offline_banner_id", "offline_banner_id"), + }; + qb.push(column).push(" = ").push_bind(uploaded_file.id).push(", ").push(pending_column).push(" = NULL, updated_at = NOW() WHERE id = ").push_bind(uploaded_file.owner_id).push(" AND ").push(pending_column).push(" = ").push_bind(uploaded_file.id); + let user_updated = qb.build().execute(&tx).await.context("failed to update user")? == 1; + + if let Err(err) = tx.commit().await.context("failed to commit transaction") { + tracing::warn!(error = %err, "failed to commit transaction"); + message.ack_with(AckKind::Nak(Some(Duration::from_secs(5)))).await.map_err(|err| anyhow::anyhow!(err)).context("failed to ack")?; + continue; + } + + if user_updated { + let event_subject = match subject { + Subject::ProfilePicture => SubscriptionTopic::UserProfilePicture(uploaded_file.owner_id), + Subject::OfflineBanner => SubscriptionTopic::ChannelOfflineBanner(uploaded_file.owner_id), + }; + let payload = match subject { + Subject::ProfilePicture => pb::scuffle::platform::internal::events::UserProfilePicture { + user_id: Some(uploaded_file.owner_id.into()), + profile_picture_id: Some(uploaded_file.id.into()), + }.encode_to_vec().into(), + Subject::OfflineBanner => pb::scuffle::platform::internal::events::ChannelOfflineBanner { + channel_id: Some(uploaded_file.owner_id.into()), + offline_banner_id: Some(uploaded_file.id.into()), + }.encode_to_vec().into(), + }; + + global + .nats() + .publish(event_subject, payload) + .await + .context("failed to publish image upload update event")?; + } + }, + processed_image::Result::Failure(processed_image::Failure { reason, friendly_message }) => { + let uploaded_file: UploadedFile = match common::database::query("UPDATE uploaded_files SET pending = FALSE, failed = $1, updated_at = NOW() WHERE id = $2 AND pending = TRUE RETURNING *") + .bind(reason.clone()) + .bind(job_id.into_ulid()) + .build_query_as() + .fetch_optional(&tx) + .await + .context("failed to get uploaded file")? { + Some(uploaded_file) => uploaded_file, + None => { + tracing::warn!("uploaded file not found"); + message.ack().await.map_err(|err| anyhow::anyhow!(err)).context("failed to ack")?; + continue; + } + }; + + global + .nats() + .publish( + SubscriptionTopic::UploadedFileStatus(uploaded_file.id), + pb::scuffle::platform::internal::events::UploadedFileStatus { + file_id: Some(uploaded_file.id.into()), + status: Some(pb::scuffle::platform::internal::events::uploaded_file_status::Status::Failure(pb::scuffle::platform::internal::events::uploaded_file_status::Failure { + reason, + friendly_message, + })), + }.encode_to_vec().into(), + ) + .await + .context("failed to publish file update event")?; + + common::database::query("UPDATE users SET pending_profile_picture_id = NULL, updated_at = NOW() WHERE id = $1 AND pending_profile_picture_id = $2") + .bind(uploaded_file.owner_id) + .bind(uploaded_file.id) + .build() + .execute(&tx) + .await + .context("failed to update user")?; + + if let Err(err) = tx.commit().await.context("failed to commit transaction") { + tracing::warn!(error = %err, "failed to commit transaction"); + message.ack_with(AckKind::Nak(Some(Duration::from_secs(5)))).await.map_err(|err| anyhow::anyhow!(err)).context("failed to ack")?; + continue; + } + }, + } + + message.ack().await.map_err(|err| anyhow::anyhow!(err)).context("failed to ack")?; + }, + } + } + + Ok(()) +} diff --git a/platform/api/src/subscription.rs b/platform/api/src/subscription.rs index 6172d36b..ca2e1324 100644 --- a/platform/api/src/subscription.rs +++ b/platform/api/src/subscription.rs @@ -76,6 +76,7 @@ pub enum SubscriptionTopic { ChannelChatMessages(Ulid), ChannelTitle(Ulid), ChannelLive(Ulid), + ChannelOfflineBanner(Ulid), UserDisplayName(Ulid), UserDisplayColor(Ulid), UserFollows(Ulid), @@ -90,6 +91,7 @@ impl std::fmt::Display for SubscriptionTopic { Self::ChannelChatMessages(channel_id) => write!(f, "channel.{channel_id}.chat_messages"), Self::ChannelTitle(channel_id) => write!(f, "channel.{channel_id}.title"), Self::ChannelLive(channel_id) => write!(f, "channel.{channel_id}.live"), + Self::ChannelOfflineBanner(channel_id) => write!(f, "channel.{channel_id}.offline_banner"), Self::UserDisplayName(user_id) => write!(f, "user.{user_id}.display_name"), Self::UserDisplayColor(user_id) => write!(f, "user.{user_id}.display_color"), Self::UserFollows(user_id) => write!(f, "user.{user_id}.follows"), diff --git a/proto/scuffle/platform/internal/events/api.proto b/proto/scuffle/platform/internal/events/api.proto index 8ca58be0..e49b7d2e 100644 --- a/proto/scuffle/platform/internal/events/api.proto +++ b/proto/scuffle/platform/internal/events/api.proto @@ -49,6 +49,12 @@ message UserProfilePicture { scuffle.types.Ulid profile_picture_id = 2; } +// For channel.{id}.offline_banner event +message ChannelOfflineBanner { + scuffle.types.Ulid channel_id = 1; + scuffle.types.Ulid offline_banner_id = 2; +} + // For file.{id}.status event message UploadedFileStatus { message Success {} From e3079af7bf185422d4f7366c10bdca0014204e79 Mon Sep 17 00:00:00 2001 From: Lennart Kloock Date: Fri, 19 Jan 2024 16:43:44 +0100 Subject: [PATCH 04/21] feat(platform): offline banner gql api --- platform/api/src/api/v1/gql/models/channel.rs | 28 +++++++++++++++++-- platform/api/src/database/channel.rs | 3 ++ schema.graphql | 3 +- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/platform/api/src/api/v1/gql/models/channel.rs b/platform/api/src/api/v1/gql/models/channel.rs index 1e53f1b5..e8c14f22 100644 --- a/platform/api/src/api/v1/gql/models/channel.rs +++ b/platform/api/src/api/v1/gql/models/channel.rs @@ -1,9 +1,11 @@ use async_graphql::{ComplexObject, Context, SimpleObject}; use chrono::Utc; use jwt_next::SignWithKey; +use ulid::Ulid; use super::category::Category; use super::date::DateRFC3339; +use super::image_upload::ImageUpload; use super::ulid::GqlUlid; use crate::api::v1::gql::error::ext::*; use crate::api::v1::gql::error::Result; @@ -22,11 +24,15 @@ pub struct Channel { pub description: Option, pub links: Vec, pub custom_thumbnail_id: Option, - pub offline_banner_id: Option, + pub pending_offline_banner_id: Option, pub category_id: Option, pub live: Option>, pub last_live_at: Option, + // Custom resolver + #[graphql(skip)] + pub offline_banner_id_: Option, + // Private fields #[graphql(skip)] stream_key_: Option, @@ -50,6 +56,23 @@ impl Channel { Ok(category.map(Into::into)) } + async fn offline_banner_id(&self, ctx: &Context<'_>) -> Result>> { + let Some(offline_banner_id) = self.offline_banner_id_ else { + return Ok(None); + }; + + let global = ctx.get_global::(); + + Ok(global + .uploaded_file_by_id_loader() + .load(offline_banner_id) + .await + .map_err_ignored_gql("failed to fetch offline banner")? + .map(ImageUpload::from_uploaded_file) + .transpose()? + .flatten()) + } + async fn stream_key(&self, ctx: &Context<'_>) -> Result> { auth_guard::<_, G>(ctx, "streamKey", self.stream_key_.as_deref(), self.id.into()).await } @@ -203,7 +226,7 @@ impl From for Channel { description: value.description, links: value.links, custom_thumbnail_id: value.custom_thumbnail_id.map(Into::into), - offline_banner_id: value.offline_banner_id.map(Into::into), + pending_offline_banner_id: value.pending_offline_banner_id.map(Into::into), category_id: value.category_id.map(Into::into), live: value.active_connection_id.map(|_| ChannelLive { room_id: value.room_id.into(), @@ -212,6 +235,7 @@ impl From for Channel { channel_id: value.id, _phantom: std::marker::PhantomData, }), + offline_banner_id_: value.offline_banner_id, last_live_at: value.last_live_at.map(DateRFC3339), stream_key_, } diff --git a/platform/api/src/database/channel.rs b/platform/api/src/database/channel.rs index 597a46fe..d92c6ccf 100644 --- a/platform/api/src/database/channel.rs +++ b/platform/api/src/database/channel.rs @@ -34,6 +34,9 @@ pub struct Channel { /// The offline banner of the channel #[from_row(rename = "channel_offline_banner_id")] pub offline_banner_id: Option, + /// The offline banner of the channel + #[from_row(rename = "channel_pending_offline_banner_id")] + pub pending_offline_banner_id: Option, /// The current stream's category #[from_row(rename = "channel_category_id")] pub category_id: Option, diff --git a/schema.graphql b/schema.graphql index c0253123..d5fccf93 100644 --- a/schema.graphql +++ b/schema.graphql @@ -149,7 +149,8 @@ type Channel { lastLiveAt: DateRFC3339 links: [ChannelLink!]! live: ChannelLive - offlineBannerId: ULID + offlineBannerId: ImageUpload + pendingOfflineBannerId: ULID streamKey: String title: String } From 310b78b9a3451f1f1ae6e1c280e8011966e9d9c4 Mon Sep 17 00:00:00 2001 From: Lennart Kloock Date: Fri, 19 Jan 2024 17:10:45 +0100 Subject: [PATCH 05/21] feat(platform): offline banner gql subscription --- .../src/api/v1/gql/subscription/channel.rs | 77 +++++++++++++++++++ .../api/src/api/v1/gql/subscription/user.rs | 12 +-- 2 files changed, 83 insertions(+), 6 deletions(-) diff --git a/platform/api/src/api/v1/gql/subscription/channel.rs b/platform/api/src/api/v1/gql/subscription/channel.rs index 61516e8f..2826e172 100644 --- a/platform/api/src/api/v1/gql/subscription/channel.rs +++ b/platform/api/src/api/v1/gql/subscription/channel.rs @@ -9,6 +9,7 @@ use crate::api::auth::AuthError; use crate::api::v1::gql::error::ext::*; use crate::api::v1::gql::error::{GqlError, Result}; use crate::api::v1::gql::ext::ContextExt; +use crate::api::v1::gql::models::image_upload::ImageUpload; use crate::api::v1::gql::models::ulid::GqlUlid; use crate::global::ApiGlobal; use crate::subscription::SubscriptionTopic; @@ -33,8 +34,84 @@ struct ChannelLiveStream { pub live: bool, } +#[derive(SimpleObject)] +struct ChannelOfflineBannerStream { + pub channel_id: GqlUlid, + pub offline_banner: Option>, +} + #[Subscription] impl ChannelSubscription { + async fn channel_offline_banner<'ctx>(&self, ctx: &'ctx Context<'ctx>, channel_id: GqlUlid) -> Result>> + 'ctx> { + let global = ctx.get_global::(); + + let Some(offline_banner_id) = global + .user_by_id_loader() + .load(channel_id.to_ulid()) + .await + .map_err_ignored_gql("failed to fetch channel")? + .map(|u| u.channel.offline_banner_id) + else { + return Err(GqlError::InvalidInput { + fields: vec!["channelId"], + message: "channel not found", + } + .into()); + }; + + let mut subscription = global + .subscription_manager() + .subscribe(SubscriptionTopic::ChannelOfflineBanner(channel_id.to_ulid())) + .await + .map_err_gql("failed to subscribe to channel offline banner")?; + + let offline_banner = if let Some(offline_banner_id) = offline_banner_id { + global + .uploaded_file_by_id_loader() + .load(offline_banner_id) + .await + .map_err_ignored_gql("failed to fetch offline banner")? + .map(ImageUpload::from_uploaded_file) + .transpose()? + .flatten() + } else { + None + }; + + Ok(async_stream::stream!({ + yield Ok(ChannelOfflineBannerStream { + channel_id, + offline_banner, + }); + + while let Ok(message) = subscription.recv().await { + let event = pb::scuffle::platform::internal::events::ChannelOfflineBanner::decode(message.payload) + .map_err_ignored_gql("failed to decode channel offline banner event")?; + + let channel_id = event.channel_id.into_ulid(); + let offline_banner_id = event.offline_banner_id.map(|u| u.into_ulid()); + + let offline_banner = if let Some(offline_banner_id) = offline_banner_id { + global + .uploaded_file_by_id_loader() + .load(offline_banner_id) + .await + .map_err_ignored_gql("failed to fetch offline banner")? + .map(ImageUpload::from_uploaded_file) + .transpose()? + .flatten() + } else { + None + }; + + yield Ok(ChannelOfflineBannerStream { + channel_id: channel_id.into(), + offline_banner, + }); + } + })) + } + async fn channel_follows<'ctx>( &self, ctx: &'ctx Context<'ctx>, diff --git a/platform/api/src/api/v1/gql/subscription/user.rs b/platform/api/src/api/v1/gql/subscription/user.rs index c987fd27..8384d9d1 100644 --- a/platform/api/src/api/v1/gql/subscription/user.rs +++ b/platform/api/src/api/v1/gql/subscription/user.rs @@ -75,7 +75,7 @@ impl UserSubscription { while let Ok(message) = subscription.recv().await { let event = pb::scuffle::platform::internal::events::UserDisplayName::decode(message.payload) - .map_err_ignored_gql("failed to decode user display name")?; + .map_err_ignored_gql("failed to decode user display name event")?; let user_id = event.user_id.into_ulid(); @@ -112,7 +112,7 @@ impl UserSubscription { .subscription_manager() .subscribe(SubscriptionTopic::UserDisplayColor(user_id.to_ulid())) .await - .map_err_gql("failed to subscribe to user display name")?; + .map_err_gql("failed to subscribe to user display color")?; Ok(async_stream::stream!({ yield Ok(UserDisplayColorStream { @@ -122,7 +122,7 @@ impl UserSubscription { while let Ok(message) = subscription.recv().await { let event = pb::scuffle::platform::internal::events::UserDisplayColor::decode(message.payload) - .map_err_ignored_gql("failed to decode user display name")?; + .map_err_ignored_gql("failed to decode user display color event")?; let user_id = event.user_id.into_ulid(); @@ -159,7 +159,7 @@ impl UserSubscription { .subscription_manager() .subscribe(SubscriptionTopic::UserProfilePicture(user_id.to_ulid())) .await - .map_err_gql("failed to subscribe to user display name")?; + .map_err_gql("failed to subscribe to user profile picture")?; let profile_picture = if let Some(profile_picture_id) = profile_picture_id { global @@ -182,7 +182,7 @@ impl UserSubscription { while let Ok(message) = subscription.recv().await { let event = pb::scuffle::platform::internal::events::UserProfilePicture::decode(message.payload) - .map_err_ignored_gql("failed to decode user display name")?; + .map_err_ignored_gql("failed to decode user profile picture event")?; let user_id = event.user_id.into_ulid(); let profile_picture_id = event.profile_picture_id.map(|u| u.into_ulid()); @@ -259,7 +259,7 @@ impl UserSubscription { while let Ok(message) = subscription.recv().await { let event = pb::scuffle::platform::internal::events::UserFollowChannel::decode(message.payload) - .map_err_ignored_gql("failed to decode user follow")?; + .map_err_ignored_gql("failed to decode user follow event")?; let user_id = event.user_id.into_ulid(); From 12e5dd7499ddc06857bd8321e49a3bb655f181fb Mon Sep 17 00:00:00 2001 From: Lennart Kloock Date: Sat, 20 Jan 2024 13:06:45 +0100 Subject: [PATCH 06/21] feat(website): offline banner support --- platform/api/src/api/v1/gql/models/channel.rs | 2 +- .../components/channel/offline-banner.svelte | 88 ++++++++++ .../src/components/responsive-image.svelte | 162 ++++++++++++++++++ .../components/user/profile-picture.svelte | 142 +-------------- .../(app)/[username]/(banner)/+layout.svelte | 121 ++++++------- .../src/routes/(app)/[username]/+layout.ts | 12 ++ .../src/routes/(app)/[username]/+page.svelte | 6 - .../website/static/xqc-offline-banner.jpeg | Bin 67495 -> 0 bytes schema.graphql | 8 +- 9 files changed, 329 insertions(+), 212 deletions(-) create mode 100644 platform/website/src/components/channel/offline-banner.svelte create mode 100644 platform/website/src/components/responsive-image.svelte delete mode 100644 platform/website/static/xqc-offline-banner.jpeg diff --git a/platform/api/src/api/v1/gql/models/channel.rs b/platform/api/src/api/v1/gql/models/channel.rs index e8c14f22..a73d1d9f 100644 --- a/platform/api/src/api/v1/gql/models/channel.rs +++ b/platform/api/src/api/v1/gql/models/channel.rs @@ -56,7 +56,7 @@ impl Channel { Ok(category.map(Into::into)) } - async fn offline_banner_id(&self, ctx: &Context<'_>) -> Result>> { + async fn offline_banner(&self, ctx: &Context<'_>) -> Result>> { let Some(offline_banner_id) = self.offline_banner_id_ else { return Ok(None); }; diff --git a/platform/website/src/components/channel/offline-banner.svelte b/platform/website/src/components/channel/offline-banner.svelte new file mode 100644 index 00000000..cfec5331 --- /dev/null +++ b/platform/website/src/components/channel/offline-banner.svelte @@ -0,0 +1,88 @@ + + +
+ {#if offlineBanner} + + {/if} + +
+ + diff --git a/platform/website/src/components/responsive-image.svelte b/platform/website/src/components/responsive-image.svelte new file mode 100644 index 00000000..c3872d4e --- /dev/null +++ b/platform/website/src/components/responsive-image.svelte @@ -0,0 +1,162 @@ + + +{#if preparedVariants && preparedVariants.bestSupported} + + {#each preparedVariants.variants as variant} + + {/each} + {alt} + +{/if} + + diff --git a/platform/website/src/components/user/profile-picture.svelte b/platform/website/src/components/user/profile-picture.svelte index 0bec86ef..15242101 100644 --- a/platform/website/src/components/user/profile-picture.svelte +++ b/platform/website/src/components/user/profile-picture.svelte @@ -1,122 +1,18 @@ -{#if profilePicture && preparedVariants && preparedVariants.bestSupported} - - {#each preparedVariants.variants as variant} - - {/each} - avatar - +{#if profilePicture} + {:else} {/if} - - diff --git a/platform/website/src/routes/(app)/[username]/(banner)/+layout.svelte b/platform/website/src/routes/(app)/[username]/(banner)/+layout.svelte index 40b439e0..f25c2df5 100644 --- a/platform/website/src/routes/(app)/[username]/(banner)/+layout.svelte +++ b/platform/website/src/routes/(app)/[username]/(banner)/+layout.svelte @@ -9,6 +9,7 @@ import BrandIcon from "$/components/icons/brand-icon.svelte"; import { userId } from "$/store/auth"; import ProfilePicture from "$/components/user/profile-picture.svelte"; + import OfflineBanner from "$/components/channel/offline-banner.svelte"; export let data: LayoutData; $: channelId = data.user.id; @@ -37,7 +38,7 @@
-
+
-
+
@@ -104,85 +105,73 @@ } } - .offline-banner { - background: url("/xqc-offline-banner.jpeg"); - background-size: cover; - background-position: center; + .user-card { + background-color: $bgColor; + padding: 1rem; + border-radius: 0.5rem; + border: 2px solid $borderColor; + margin: 1rem; - aspect-ratio: 5 / 1; + max-width: 20rem; display: flex; - align-items: center; + flex-direction: column; + gap: 1rem; + + & > .user-info { + display: grid; + grid-template-areas: "avatar name" "avatar followers"; + justify-content: start; + column-gap: 0.5rem; + row-gap: 0.25rem; + grid-template-rows: 1fr 1fr; + + & > .avatar { + grid-area: avatar; + } - padding: 1rem; + & > .name { + grid-area: name; + align-self: end; - & > .user-card { - background-color: $bgColor; - padding: 1rem; - border-radius: 0.5rem; - border: 2px solid $borderColor; + font-size: 1.5rem; + font-weight: 600; + line-height: 0.9em; - max-width: 20rem; + color: $textColor; + } - display: flex; - flex-direction: column; - gap: 1rem; + & > .followers { + grid-area: followers; + align-self: start; - & > .user-info { - display: grid; - grid-template-areas: "avatar name" "avatar followers"; - justify-content: start; - column-gap: 0.5rem; - row-gap: 0.25rem; - grid-template-rows: 1fr 1fr; + font-weight: 500; + color: $textColorLight; + } + } - & > .avatar { - grid-area: avatar; - } + & > .description { + color: $textColorLighter; + text-wrap: wrap; + } - & > .name { - grid-area: name; - align-self: end; + & > .socials { + list-style: none; + margin: 0; + padding: 0; - font-size: 1.25rem; - font-weight: 600; - line-height: 0.9em; + & > li { + padding: 0.15rem 0; + & > a { color: $textColor; - } - - & > .followers { - grid-area: followers; - align-self: start; - + text-decoration: none; font-weight: 500; - color: $textColorLight; - } - } - - & > .description { - color: $textColorLighter; - text-wrap: wrap; - } - - & > .socials { - list-style: none; - margin: 0; - padding: 0; - - & > li { - padding: 0.15rem 0; - - & > a { - color: $textColor; - text-decoration: none; - font-weight: 500; - &:hover, - &:focus-visible { - & > span { - text-decoration: underline; - } + &:hover, + &:focus-visible { + & > span { + text-decoration: underline; } } } diff --git a/platform/website/src/routes/(app)/[username]/+layout.ts b/platform/website/src/routes/(app)/[username]/+layout.ts index a33c96c4..deabf165 100644 --- a/platform/website/src/routes/(app)/[username]/+layout.ts +++ b/platform/website/src/routes/(app)/[username]/+layout.ts @@ -47,6 +47,18 @@ export async function load({ params, parent }: LayoutLoadEvent) { liveViewerCount } description + offlineBanner { + id + variants { + width + height + scale + url + format + byteSize + } + endpoint + } followersCount links { name diff --git a/platform/website/src/routes/(app)/[username]/+page.svelte b/platform/website/src/routes/(app)/[username]/+page.svelte index 1a03cd3d..0e829582 100644 --- a/platform/website/src/routes/(app)/[username]/+page.svelte +++ b/platform/website/src/routes/(app)/[username]/+page.svelte @@ -78,12 +78,6 @@
{#if data.user.channel.live} - {/if}
diff --git a/platform/website/static/xqc-offline-banner.jpeg b/platform/website/static/xqc-offline-banner.jpeg deleted file mode 100644 index 73e5ff9bc72b8867f1130ba9fec854225f647318..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 67495 zcmb@t2UHZ_w__~$B2tVigJ zh#%uVMt}2v*zSJ=B#$swvA$tqFar-rFfd6l?t3tD0RZD6#=kY-Uk&2{Ce}l2oJY8L zkBNZ?=;uDb!hHA;3k&^AfAl#3i{v5cQ$A_zCmJ7dn4QV^gA#KdvAn77B-fmPvkI8G z1mogSP*PFTu(5MI@beFJj~ODpS7Hny&A?jD|A z-aa8;Lc_u%BBPR$zow+7eM`^C&HI^OP*_x4@~ft{uD+qMsky7Wr?;L3UPgd0bu^SI_S@TSLlDzhXk$911xkm;{2lz#sg2Z zV3J@xe9DJSDy@O@(fJ88f6ybcH;FmbowzIlns9P6mkB%y)>mt6C;zDQZyNpA6bk-- zrO|&V^dI`%F97(M80Z1RBmqFcHAB?1><9lYo%>P1v)unxIDaW5vM6$wFvryJ0T_FV zg}Q7fihulQ-RmMosxjl%J(YmH^;D5O{f~*Pa@0C~SPPgml-}r_qSpRt+3*r01>Fp454-T4(u}s&!^@#P^CywOA?~nK{0I z2};mbGxWG2?R4Kyl?(dfo-giPbvlJoc`|(Q4H>AU9e1oiF(QVPx#yUP1P-nkVyQ)R z8S?_%Fb-?gA2AWT5XtQ8Gccd1#;-*?3+0QP(OC(bQzxlqA4ovSFe+uO5WYPW3~W2nUq?;;t1GyJ=lncNcH2c7#(7|Y3*HI6Ocuj@eiqyH0==r8Hk#94szCkZ_k}X?Bx*+yuGyl5Ad;dgIws;9ndRhi(kI_Bn9?m5KCv2K1yMzS92O zUuf=K6N&MPLH=k@wR;TlOR`JWZFn}3a&D_eGR)z-MaH69$z%U4hch8KSe@5Cf&NB|h^hBBoXvULCg> zUwmF>j|tnLyxO%XoA0-z>);woqR@6&4BC;nRmaKIP$;X&nF!&e#-a?xmC@ zo6K#kt&6j<`u0|QCBs1`lMy}52eFv%MK%ToT3R? zGC)Hw>-mXFL+GpX_N)cvkmp&N%s*U%y*aXfSAbBIZ$ak(82N}1sRH;IlW#%b%}?)v z9RGU&&ICRY_5uPhK@ZAeRGGwnz2@z{?SHEWPh{xtg)SE5U!T$p6s7@9ALm8uh{4lKi0N4yao+) zE7od@S{nLB>%h*R1YV-EhL*fv_)kU0_N?RW<;t3=>RT*K`}zcyCGW+} zY@`!7r}*_>F!U?<>h>O}E;I41NIj(%=(Fp;3mNL^~X*drio|qJ%;*9%bAE zEL(K(8glYiq5e$E^ziO#Pxue4EVUcFi=X$v7ssxZb57}dAduSt>>z{kuPH?_zhFcK zZC>SVFb~E+>Z(*u-6wI?Fvx%->%(3NH#Dvr-nqKG8iWqq15M7mb0ODJtB#*5&i9Ky zSC%j1uGv@8g*;U8apTi+26d&}1E9tmWQXwYtrCP%iOyellTsI&XSod$Ou13dfPa^V zfY0mS@z^$5d?$JAbq;A12JSV+ikQjuI!ojj^nI!d$5A)FdI;V%mch^?|6R%6N!0xe4J)z z^$9V3=qC;L?++5!_gx$Od8SE0YB+okNIZ4^3Nmu?@2s3|B#PNRk)(cKbPsr?-UB14 z85K!tioEG3NbT-gCz3eJw3f9!{}cZjWnv zcjf?={!{^``3s=sQj{P7e4F-Vol;FH&mTupqi4<`4!j;K1`(YLl_-2?CA?9wgXlPbtGfrJjMvygK|agSJL1I0VKOi2ZA|N5-R&pr*4ZAuBb*quY#A5TX4&5L(RLIAGXPu>0eKgvas| zM2HP6Q)yj=g7@=IfkL$8F`slNl#W3bFov6ySK85hocyB_={SV zAXOWZz}U-VjC;!>MB^`^XBCyj)RmYgFB&SrpZDq;<{AtwQx&o(U28r#etozb6etV$ z;B?-GrFEyhde;ID(952)BzRWNy$sttz zx%n%l{U;-Dlz)|$-aQa;^t%rm?Xcu>ispB9;{A)3eiF-HWkPo()T%1pzd@wgRC-0e zGBg&_3bya!{DU%+f^yH(FG(Tsprc<7`eFfK6EzIpO>i4R(fZ1v{7H{T@$IlS$r2#)X>hIMf%CcKOzDY$ z`rNBFQtnZj-SVz6tfOxPwQ+n|fA$etCL||sQ9SS&r0zI&bA4!DLMW}}kPxAS*M>Sa zIK&U^NcrW6RwtqM=FLOQ)*`PM>p2kTqgkawURZG_O^g`QUwDua5;sI#8L0G=;wa&b|tUc*-2^BPdoo~ zW2-x#uIJw10XGjga~e(gm$g!eo+gG6O+rkb2QYXoswf=4s@ZbXcDFN(jo}v+I1lfzxhqbDa>YNwm+dwCDBhI)8l>JRmb(s=_&w&jtJO~G z6|SaCUHdrFENoTDDx$hS;}JFW1r@RgLz2}?MrINz+;F3sM&FvCOe2;f^2|kKaQlbr zY!l-{MBi~&+D~$u&(PJ74uUEBsaS$9T zb4YT$y(+RK{hz70Q;{s1Qb=houU8n$HW?L=vIU-_bdNORh7-$DnW0FtyyUT%1IRo+x~Gb5ph-MXV!6c@tsEy7i1mc z3wSMUU#}941q!v3AOghfh17^us056kkg>+;2#T-ofm6)<%yDi#1|x)pG6UjfcAE(z zrd}!20*$9U6OTa4eQJ9uPAk-9(FVI2fnzL53Wjl#A0DCP0;x|vnl-{Nqrs%dv{k=| zj%q)|LmBg(+wmsBYfLACDVd$tp+xB%;6!Za17XaQ3aCMovDz9z`E+1S*HbBlPW+1Z zpcEa1U_t3$Tbi1jk!MwMzJ5y$J80MLKg}&sJI;@zZ*IZ8_lSjjO_KP8%S-9zboVez zpW$zItTd+G5!REeZt`QhtMF;vXfO7zVa=17-ijPM@)3^1=bExhKZBj6=$0Y=)^{a> z1TM>l8_zA0_}Hgd+^@e~qW=oa*len&ImyT-gGQeZL+FhsHIaEOAg~x@&<=u|BdpGC zg>XC68eO#P2SRHy!xyI(<7R5|{N&*kX`ig3yK-j{6^sW1K}PX%;z7u^9n4y|L?|kK z&5m0_Nhr>%n>5B-N-u$^kiL`ywt|cbVaZLFz#{Pa$@Z@QI8?k;5 z*erHTqsV9dq<=L|wKM9^8ykI(%uXfqRt+%2^cjEP#|}UI;`8BzE`qfsRX$G0@!BGL zb$#g!n(uicgU=LUrqE5_!yGzzAzY!m=7@|;?YNkAyd!L$d=oq(1z03`JqX0(=IZX1 zWSHS42!oU^LY?^EaDs$H*I97j`Sam$P7R?4L#VtqeqLy~B}g1s4jFrp;-{hRBb zFifJJ4=C(F!FE0_2;suO&|{v}&LjR<$fgET;>0oh0Le+8f0;*KTQB=1nzQriw*)OP zrO6C$AJH|%VaNUn_?)6btot4y5L6@9GuTU5S(LSxbkXB;?_@ezDJ%yZ5e+b_(Bhq| z1xbjbCyNnB>aSm+s@dCE+YR}yK9rG1NRc(M#`C+0r4{2ZaUVc4CD}{z9ZbHu**1{J ze%$5^i$$#M2?@$0oARrrLGfcv_ds;UXCcvmWctt~X}iZmjgdmR)eG{f+t`5uNEZ0s zaZhzYL)o&p&J^DV5&lsq2S>K}*qITI_2Gk0v$F>lvCQY-^H-ABf{wop zdQ;2SN?+A|rRI66z#aohr@{}AFA^lIh967K35Lz`s*GLpZS^F5qzj74;upru$tEOq zjLPC|P1&dldt@%9_tVMyyY6%{WURR|oIzlwRYh>Kpmd`;h~llAN?~&FBRU@~Q(_ME zP_3Eg>|9nGO6Gdl$9y=Tdfwjz#bi%gqlmlo5$r4Fx3{|n;#pAo1#)0gwM{AdLo^uk z4EbGfEdt8^IB5%q+|EC`B zEjIgCpR+6ZIf(5A4~z1IIEZMzmVieqB%BXF3A~tag;blYOq5s~3Caz;(0r7ncGb}= z(VT@So*;ci(iat#hoSWW@A{pNXa&2;?bNSD6fUCOwMTDh*&fDrbRKLk>vI**0e>Xk z@~0_j?E?a9m8-Ke4t9AP;d8IpT@0Kb0x3HxxlcyYJ z@=M`>T+3haL4)|mTP@Tu_*Z-cuQ<)}I^lyt;kE~*oo&>&> z((E~#!jzom7v)}0wq8+(Z$BjWlhs$e)(?n9v9BQcO6R%ffm_ z4_gO|SUb>?DHsDwSGs`(yL{uD^~@a_^&pJ&@=U3-6sbh#vKwl{9VA{HeGH^2IVYgd z3ze6}Om(BBC3NP(e)dcjF1U2Fyc&t)b9>XX<4bSG)kqa-WB5hGG$lG$=R*BH-lh04 zt`mr^Yr$0^j@kSB%?h&s>Fn;9q&~6J&vlG6DvktMXET10UJ;EHWct}3NVs;<8|{~Z zB8Qg-HP?c9U{*hYpj|nfd}U{>`UcX4CHBD&iMfd`gmdr1sOg-fa~Bq1ms_-A3I_T| zta@g#J}uRFB@%;70fMWBhK%1&>#ub-^vDKR!z}+e(^MU{G>l&`hWe+&Z!cfMGef_4 zf=TKgXX>iEvnv}aE7 zyTXZ9L7i=OFi13tc$X%>J#phFg$@PD;;5_Z^I&1KGY$FF(0K>*1b_qIsjz@r(AHjw zob=NL9EtPoqb=7oAE&C=tBhM@{6MU))HL#S(O|)z6PX)`JJ3U+EnUr7A04^BzC`E7 zPxS{aE#chV3lGw%*X``BE**I}m_n18h=s46+5$}9(K8>`Wm2q|#*KZludqmBd%=$t z;WXUn#Oq+m#?t|+g7Dj>y&}p=NefuoqjKke=s;% zGK`~@U(0Q7&8gLE-t6puRt*v3_W*uSFdw7(FeJOORh-e26FwL2j#OGPPpoapmNc6^ z6_yp=KONj?D8lFXGBvYIb`wIAHs zl4QQ1?3%2&+f{752f|IyOoL4OZft1OB5Dg_s5Z1_%L!kSH$2RgKJp~E2QC-daqdc< zo5h=s*O8lVsaZdNC)`K+4*hb+XWWsT%%o-7k?(R9z0L6Nw%?F-dcf^)K%qe*QJp-E zhv>cxM0l1Vy5H2%$VRJ_?7p@P7FGag%z6osnTw$!?NSal0pBAe3*pjm?!loo!4pB z)gPodcKh3uUmsLfmVKT@@k}jCNoxCglRY_dalY0)Z&{~{w6L zIzBc{ffD7mrSkr9?27~W!stm!RbRdv?XP$*$T0$2aeMur^l zK4+XQHC$OzW8Y=%1w53{FS>h;YT2le1HihzT!U>PCR(+zGYbv9 zb&koNzjqd|W;(T7-0wrNso|r9BV@QhDRd<5Q<-J73I5mFgv-Zk)?Z#=GuLI!hjQV+ zXM}D+1%F5qequqHr6JHrAvn~tznlyMc4n}+yFzAvU>~Gsf6N1u>gT}F!p}GdM?9hO zqc5&X{`jfOz0c*h`N^)4Y0t`ITcMidAom;AXG^r*7cb|)LNLu}dv<#U^ zUs8V_lW(lJ2YyR3I1+gUP&t_4dh!X3_K)N@zwi%ajI+xhym*DN*?}+4T}-crBF)rb zjeP?C%8Dgku|&aixpAlRQE83=8xcI&TpcAAy!?GN`5q_(^O^iLF+NG{MBa)0gKl^X zz{QBvJpu3oumNyEEDeMt$Vr!#gJmY`3`KsnPkm@g6}CA2GfshH(??`bzN&ciLY%EY z*_A~+jQRCzO-y2t6t|a)lw>W&1_b+vRKoV=Pvl>oqK~_$RnW6a)j9U071@p~ma~34 z?iX_2cy|4Mg2gt=^e0PKuWP>Xe9VCHOYsCH0l&8=+e&Z`E6X)u%WpGqr(CKw^%b&- zm=S8=g58D8^P9MwhZXiSPe$!?+CQigtr>?$O0w_xXxAq0>f(ZZNU0D>r;Q2wd^&T0Ddg7CfogRn%A0)U<9(}A2 za|e7||7@ATmXxn|W;V&vb@9F$e|~T z8ZnYN$+y25emG4c%VK?SMk>T`t7jc=ZW8&?+@?Uk_oMlPZY=IP5?^rSaYnTigS;a= zyyuA#Q^#~U>2u^*#n^9Oe7p-gF@@(mPk+@*B}xUq(wN7H%7_9aN(aT?=k6`^_oRg1 zN-R(&(N|U-@I9k8LxZhDZ4NP6>DicZf5mj4o~RC1AlaTr#Esg&a#`~is7*c`bw4M5 z)JcsUD4y3%va*Lxpz1Pt5BL=lUZ>kiiFgc{k$Lus`OL0#adWq*&|DnZ|BO&}!E@Bi z4>Y2)UT&$=mc@%ABMijHP@XFXuq-~zv8`2xrv}i@TF$_xt$w7q#Zm(cv=wTuaopv( zfz+>FF7?2T-X`@jE$r;!Rh=`s`E9ZNKo|3RPFWVvo^mS}Q)r;bu>7Gj zL9`~@r&cVXcqIsd9TVOIT^f0k-(I{WYO|2%PE4GS@2fA$#)mxqB-8CfN29hNei>0C zaQORqKL_Syr7eZVp3rL=zmcIdzE|&tr?II9O3Bo5=+Rh}k7IbIJBCwCTk zyFQhFY+0KkA@^phxiZWIzh#2L-ds0is5ukMsc)&)W;%ICjm}5?AvCI9&2#N_x&SIt z+?5#AG6cE9%R$Kd%}G)x&D;ZMP5C z?gUIKc9QbsnYr`u`Ktz4#-nn@LD__Ikstf-#0t^qXYwZovJ;}X%D(c_rFT5WHEbh= zX^ekkz~K+t;>N~S)M~?qCM6Pn2Mf=Mbj77S!;sPjfR_LjpgI0utQIns1LPB4uRDFx zqATCLDJS@0e|wwM!4)ryI`j27)@{XspC0!n(GE8xExwDbvc%j<3DCtd+`6x`{E-9t)`|Gaf4-F4xb=bI7X+YnBwZ-iew&eR4N*c(Jb*OdiZflWIPb z9;YAgqE@bG$|EvlUw>h}u6-g<{#rz6TEj}VsP{B}FCGEzWCz>jO+q_C2z3Zb0G%s>SL0V|!|IA3wVJ{p4-pQ% ztnnk6FSe%HELe{?Y2*Tb1qxfKyNJ-uO$N~3l}ZXA+?j(_0v2_{m;;Pe=amkGRxz1V zTyRX9##~f9F2M~P(d+fGyhz-Ix7L203gS0E`` zfx_fF+9xZ>*RLaP{lum~a7*5MfVSl79%!J9*{rvi@*~PK7*kk$-k>>_Z4F7lf-)d| zXTWxApzm;Nne`uu^4}LgNNm#$|1=}Y-uRdRQiMug+fkicvMs-y!SK7-eu&HOz?Yy< z43-yOnA5AeeeG;_#b~XQM3@*jRM@HM>PKHJNe%H`z5UTHc7e0p`s?WQOuf-}__pcD zIyo)XhDP48l#g|efp*`jLGruTJ=vG|>pZ7;8(ape0msEcpycuT58p?Xt+mn7Pjw|yW zs4gvKDxhA)ZX@xg{ly`=G|k8dUNJ*sWQ@C64^HI22&xevWy0m#;9G?b_U@1U6{ikP z`+D$&V8Aw&2o~`pra-&X{aZEVdjN9lc#C)@n)=o2^=08c^K8dH&Yc(%yvlF}i`iSS zbeG%(pW>8xZC|S01HpD)8ZRuOWoF#%R-wO!DO zk2waasa6iloSh<}d7Jw5)5BFGtY_O>-T=_jFfyk9UF_HAd%)J_TmNNjJ&xnc_m&xb zH~a}JUG#(-iF8kBJy?*Zxa8YruA+?v$-nAL{zAWwRU>87PlrjuGKHo88a&bQr?vNn zNFqAAUM~|*xA(jF4X_Oxj$va(N%%iH;>K%_IC&9jzi#}%UQ|8tZ&y#>?+saDx(`>H zkB~$~m01-`8M82D!*3a!xn^-OFSUa;Fr-dlbCyR!0J-exS%gShPd%D{Lv>Xo*_6_MG%`~|>pu?l+VCjHa& zQz~ij$&L<#MFox;J8xUi@j`p>I8O#trm7)PNTGgb^2ObJV%YX}$B02kh@X1@b1QRA zyF)ZX0?w(3iarLJL)R`GOIGiJ?Z3i>*8!o?x3vDMecP(d_#c}iR};AH#q@}seCZhK zwiY@%?_{*68BoSP^NJ!P&`T@Y#e&n<=B^(|v9Voa2)U=9RYHF)?NfhU*{i8TCL`xA zEWGu^?fu$I%fnNC&a|7v%LGlJ$p7p*9b^!2t0&y;zA#JgU}Ahw4u!9)v^*xuFbQ`^ zAN2LL@w_$X$GE5%i(2rLO;-(=)Hc2cDigNj`?MgnDK@6acdPN89R#iNV@=Ix`p(Tf z)!C9cPHL-qvkp0MxruDFYcy%#SHk$+C^ZBo?_@0Z@h7i)!8KZ}oUY?IJ4&zR+S(W1 zt&hAw9{Fv;h(Z@OKs!E1p?2=JO_qrXYK}kFE2gTlB;kxX{ZGn~Sn!{-q_)kF?qLi& zC#N}&og5e6@Q5|FHvjM+u6j2eq33Nnv?26O4b6>_>t~1Ghpe81;u*PP*2D3+5BNJs ztLMGt_^$+Yo$UiCbuL=Y#TZG(YSZSPO%*dV#I^g+pyW3peY~M6@NJET#;L;8b%l;4 zrX(ZBDCxn-i2c4t4(k$~m133U_rNFrS4K$QX_ZN=?`n+=5KMwdtRQOX^Xq|u^U5Ud zTY(S(pQb*&5*Yn91#HDX1MDjKB<5EMwW`%xs7~RlpJ{gIx8oYJkRXJYiJ3%rVU>983jcC?6~lu%%3+zLni?2Y;c% z0}eRaYZ|CCwgm5iAd6Yqm8iUU=Ghs@U7;`zyu#M7i0R>G;*+HxI+pzrwdbFo$MQB; ze8SB8-s4ld;-VGf!#u-T=}z6Lf3(Tv6WmWeItVM=khYbGZa61bRq@pMp3sSN%ZYl0 zF~h|~ZAP@FKI&~weYa60a`apGDeINXHnhdTCoJI}c&cF^6XNB)_V}4f9P6$jI5dTmNk&vJLR({ueb05EePFU{OQp7cA_@ zmKv`NdJ-oL)8T)+u1RdFIWxbgSdIcDYc*s6GK-sxe^D`Wo9gOYzbyV;?0=;)!>T?6 z#?+jUB6zEiXdX#UB1f+E0b}Q(u#@X98uWrma=>+3+c}#Weoa+y#2tAgQET)6Q*X8BxjfLr_(DXt_k#tj=#oYmx zZ0NelG2!ORF~0V$%Gb;>`5d$+gKq8LYn`pg|Kbt!UHRBFuNbS&s?o5ed^Io)Ki{lv z2=Xh6u7?r3{XX!&EOow(xE)My&RmwZN<6iJ z3H7xD{sWPW^3S;kItYwrw~l&H?6LjRD66LY-+%Bkmr2u559UxyR;SM~JumrgJ61D! zksJ)HXy5t?eB7-WPrrXsWy6!bm?a$<2Bw=6XuN@pOqNX+cqr{>HJS-%BAzfc+-AN$ zwL58fGT?F#T%R$vpm1kZ0sDufb^KP4xy->Htkj(+uZ}tw^259b;71R#ckX^ikU(k6$Rz5-s_Eu z_7n%lo+AJ3ca{*_QeGDNw;c?qT^Y@qE!(T-Vy#a^6n>u{6b|h;u11j~X02CIU^-*9 zV!0P7D?D43cDue8)pwqq?aXin`EI?>(NDs^Ypv$7aXDoqFRQftJX-u~+Fr1SC^gc4 zeEHjXsSizh<1$4k3qr4NOm8pipmhsb5$ikaNX~*aA}n+O0>(k;G&*7n+(e3AJSECJ zyb0h)k6l;8`zRtxjm5Ej@T?cquhzKoy=P%*xORn~=#q>_BkAZ!XN7HgWI2DZ49d@V z^zXM(XCQ(-!Pui?+~J-Hz=5^Tq61L$C>#Y3i+@~;NqQmTAdRPdn>F_OqD}(*L>>x3 z`3~L#{uwc6bC7b4zeHEt$Z*6&Gtu?#%d6k_01ac%MaRzTdw>&t`y`1Hu!3KEbenj2 zr6L;CY#d(l(MmKlQdJ0ER(y0&q+*pwi9~|#PVRxZe9h7`d9R`FCj7!P>eBF_(;C-) z@FvGSFh~+}qcA#k519WpFy0iJTgvSaFuWDe*u8_GL%4u=gvMPunm0BsxsJMfjnJ`% zI4{0WM%^Z*LD7+2_7BL_bPDJMa}PX@o+O=U*@&mUW2s3kgS)j~r!8|2o5}Qwb5M&s z^r|rJ^O)`HKa8ch`#F?1f?}sX+c(5k{CQ2Q!2w$K>|amRuA>^ghzvWGZHy&rmNR9g zW5_zsTfOjW&n?IQY|68!N*X)4QhM4n_`~b3JZPx&1E2n9a;2!HJpL^Y6K|nMav0?bWH*#ohQh~NtM6)0RlO#OK;M9`_cx!gjiIcW~ELV zR+ZGH!(<8NIK?@uq>XS^!$~pY=9xBbGl@QvXs7KebFdH3y>!t%H|Q9+^ z4r!Dud`|QBbDKwtm6DPt3EygayLp1qAky{?A3k{EFN#pAT@ z`oMKY=C3HZ>Iz9BFG(wcM{N!L>d707BQzHh9gks>T-uyBVkrBp@cIF6Kdo(?jpOTj zqks5qtKgf*!sr~#6U{X$uJn5k-)xT*oQ?Hs&~?G2<9o<8)Q|W^GVWedgQSG7zukX$ zaigGhVC5tw5lOXS)ijOZs0d@Xx9ZfE-qaq1I`A3miE}(2vn{Mo+O$)2zfG)XBK4517Ubkv4#+U`M*BI zw;x^*kWFrISt=aCF*l`-LUqEsA-DVuV$GLv%RdsP&LlZ{RKbU7V@FNqjw;~QP%;hE zpAbCsI00l@l$79w}4T!EC~ybal&b7)Q3P&9Ps(^pBpz%Oo3V zZ&h_~x3n)1U418yb%sS#H9Tq^j9C5ffw5;C$I}(&^_9H-ON|H5 zsVbNq$ySE&I`QO_`*j56?EcDab4yGeN=Sbu8c@eEm$>^%opgG`-<@U3UQ;2iYtBfq z%i3{NF>y1;^Gk4}iDMod`!Io!*k5rJ_^bwtZZf2}+<_dE_7x8o@BO;l50jNhd&Cv; zS1x4h8^+E{P>Qa<9Qkd~x0~^Ye;z9>Zb7_nj$Tcg(?9WSK^rr{3r@o$uFAQLVCBgC z+fz~rF?H;`m0rpRvQ!7b+5Q?t%tGczcA`;0{X)!7WJL7Mw3@fmI<_WG133X1U~%(r z40v=Pa{hlrEBH_JgSpgaC}dQJ=0oYb{11dHbx&Ew#~)^J6<(HyIlaI@5~w$x$o{k_ zvr7^le1%b`FjB(bgK=yW3XR-${bIHK>qMJTwELV>(5{b=RRav0;lmOV~L1|!GJTyT&wVQFz3hfBSaeS6JO|o2w zaRa4JG`2KA3CLuBiW;oWu{^^mvj*&U--j zg+w6Q>7@AS;{EuNc1nh^TU@%>p#AZh#%uZzx9=3R%HBF7Zb=Hsz+u?|^_3OI9zd7l z-d-+3Hl-M%8fMEUKO#=vbLZX7y+0S+c0vrRn!O$=A+pkZRQ^a@K40mUbcL&PL`Dj5jbHExhJbxj>a;@ktK*K@llhL2=9CM|nwM9F@WNXixSzL9Nj zImX!zcsCf&K*``P<4X8ntF8VwRmNx8Sh=ZD04Jf%r);dP`0tbGgJ+t*oxI;Co`dfE zP;TlSenciB5&|n{y)Nb~pSbD~#wbH{8DDH(a~>ubZCbtONgVWrw+-}HJRXkZ%5)2O z-td>cZHLkAo8j&idXiXGeAYcP@kQ@hS_xKVGM}IORCea=yiAeEr)p?l@w|>UxxM9* zCdz~<*H8&?8OqT%Y8oY82VLt1>5eo0%9b>ZG{m84D40V zaiQxy@Zd)Go&P@aYRqyp=DY7b@b2=2V5m(|@)!8qR}F;`G72jIBT4Z$`KE(~D+w;PA3w&vNT^;Ps#;L1_n3ImcH%MWcimT1^k}mjgE3;!Wbm#!A3Uxz1-keP?u$Q+ zOHA6h)CN(DVDTFX zM6VHKUFBjn-PLyC6|VDhS<+9NY+8xIN)Sh%3*G5jeSX%CYQGrz$(#YC9gz{dFw9+% z=kuKGFMg8_=qm;CNi1rQhh8{rT8x*s>o`6l^%^F*UNJaqb*;wrGc7g^jtO8}%Qz*y z7>*YmmZ%KIr+$9vObEdWuHR>BuCA5GFJzw{)SujQ<8wll2)%7A z(aqxe?!d#OcV{B=JDV_VIoG%J(-;Fxkfr*gksvhUT}$ZluRy|*Ul_YEi@xOSaw-JL4#?pGBKV;1NZ&i%Mm!H>*%i43}JO9AYDmYOYc6PL* zVc2Z91&-NH9J2qAbdy>a$T@4X&DKSiVK=)o9uU-Z50={rIQ^pPzkNh`QQ`vi!}sLuW(9%uOoQamqFRl#F9(oD2K`h3NWe zt|RF@`#U=^$N=NWjJ}_`D=OY9@9&~ujJ~fNB!WH7xAHHOKR||2B(Cf~Zukam-^FE0 z*8@W&6Lky~s~o%)ZtC95T@HCCR9{kO6TF_j&?nq#P6QLz;U}A@K!gtC{4F@Hb?o*M zw>KgnF1D1@M?_0_9p!-SXVBR^sy0j0^E2e5-l4C{pOHd{lJx>QS;~T2&?v zaYQ4cAC9uBLQQ~>m*Cr-aqDj})JAADsi_In9I_{%*hB=c+|)B%xjnp|Kr7k4k_kAZ zo0|+?zb6&an;YV2`0F*f2ZjLPrGNJ+Y*H1F@ zib&<1sgb|YH}=ymtQKkEjz50D9sXMViUdUm=6X0&EzH7*1~X`Y^S5(6n=C`^p^@~M zF*DUgO1jKSXXL&f=)U;rF9jMZla?6a`EJ;UGhvEz`U+zgQ{yX9yQ|_|?twR}KY>#u zjKBoIk8N{C+WbH<7pPIFmZ$UNF~tQ{eZs7#{Fy~%m3BBn1R8776W2C7j7)(+U0T9- zb(!a0wS?4*o$H)`t~^7BC2B=Q(O;`qB+-o*eF3h=FjC=E4uz1L<7{wN;P0z$=qn(<=y>2Pbxt&sG!tfJ}GZb zHUSUi`*`KXB*h$S{U=uHF_lbLdlKy_9gLCZ1(^J53_e@p>!*VK-ht<2j*M!3ZOEF) z8oJ!*?zMs@+O{8NGv`OGaZ2-C?wh0BOGn8DlS|k%F?9GJ8(HAYSz$!7`qtx49)0`CGW_B`AwP%m#W>aJWB3 zzU_sXP0qYz#2l&|sYMtl0tD3UXr55mA-{s6n7TaObo|)?I+Fn6(&aEg+vV)< z>F)tPBUaC=6VeHjB~F$f3M=dSqJ?o6w7736GQOTF5smb{E4lTZGZI%QXvksP)#$k6 z;yru1nL;sX8jpN39R@vS97ZGfa!;mF+MPDb9CH=+q~1qEH7d1=3~r0Q3yJ@)$)1{$CFM zr%ORKcAlo_u!E(x%Lg(P!%>VZjYdc6A^K>DZ43RjQzD=Jr^AT&FX*${AUOI|0@!Ny zsQn5hZ}a5>l)^DU#JYZ3A%G$!uttHuqY$R>pZ=bD?p*G;QW73(uGe_!UWs9fr<0lY zKri_XXy88{Uq`EWEyJjx9CQzee?JrKY%eZW$Y)e<Obz(Xn9xA?p z(jJk44cDR9M6+FKs-vyVjrIUi|3f$b!jhcpd?`58*ULn5Xs^ii>{D43>U?K|C2W=Fg7XNP6ZJwR4p?>sn7<_~t` z-deZ-2ZJcwz~K|7yEJ#@optRg9Lf=7e6hb5WH>>dR3daSj0VWpF;#jo-Oq~U2^efl zUJUy))Pvrvwt6t~j&aWRDyO0gdAn}VDN{mkViivtsz;iSIkC)Zt$>`iK8^kv6$f3Y zf$VY2xdL|O+U;(|=X|Z+-XEo&j?CuVLq}BI7H<27m!`CWJ3{%W@0PCdt!P8QQiZm-rWAK~Nn1*=;%-S>in}|ZP}~X>m*DPF+^rNV zUfiL0@Q{S`^mlDNlfjNGY;l9}naK@BtU#hG@I zSZq3&)%Ca`a&ZT@JUo=d{VTJNVsCG}3A=8!imv|UZ90}513I76!*#jc;TSm;hg0R= zIp%bomqqZK94I@tPV@M^ROi{JjyGjxM4f1+tZh*MgEU}@Zme`U);GMv@=n(2J5!b>;r zbiC`krXt;HAE!D3p7L6$TL$LAfPB6+U|@B3EEhFvgc?`0roLco_SU{oK@QE zNT7X+GsyV@>qUDjRxB`FIOKU^=}k zb!$bH+Hv}Wu2l=7uxT>Z_3$e8ae|LKCH)L(4SUQ{nx7ezp2#!2Yn|p@Lzz@lx@AOP3{!|9p)BNpv-zn)QmJxN{3(5wcC#H#Nz_xK_}$ zDVDVLx8Ecwtd{>3#i!ZJ`L8+6p9o8I?mOWiwRGR$Ez4mCsBOC>lME}o zgADGYoYdp>w=#a+Yc(+9bkM@sqAcd|Q73Era41PWlMI9!WpsMT0uen!zh9JL3!N z#|pX40(=syjNMX2?8|2TW0pu6<4G2_D0p@c1IJZ2K4(P_Vaa-E3K)rP@Sm-IfNseFtr}n0*Tyoo)Dh$IE<&mK`;TB|iIR#OR<9P?G6PG8Zdq&2`b zd>;MVa>&;Jli2X*8K*xBvvsJphG94dJchd{ zaE%W?z7jq48L~HpWo-CGC!+u#fz<&$X@VDRv63}ur=3UKP;3whG1^lIW{H!BUQi}u z{{qqgS6Z;=XMmz;Fzu%dy-3`tLF~s#TqMQ0@@-K$QQAl^b`f_`LUB*ti-Zf(Jgr!C z1u_W2;U;YyM~>-35~yuw$uHt{j_r&?0R9ltO9S5$k( z(c@v}cN^O99k(dNZ#gA7rz|mQ*0xWLZK%IctELmCN{x_uvmKBAA%qqH-02^jT7(I6 z9;ExAgIu0QN$~v2p%Lrq+KXA4`6E*1x&EoiF7pacyG?jKo%o42?5#MeBM>9H`9-oshN38M>kge!s|2$ZoS$Q`JauN4KLr#iFnwG@=FH zhBq>dI_-yd5-^fkSQ>_ku%FiEHQsM@8+Ge~>Kf$|C^lRyUP<7m)bTUV~q!Sk1Oe=7Dq+Wf+jF8R?w;}UWdPspyR|AEZFb0q9! z0T!&DWWbWRlLq#1Hb{+8WGxX|VAt)N?K4_b`MGG)=+Nd^@X#KiNC1?TBMZ;T%R$EW z$xt>^WYNsI(Q!DzR}4&~o=gF|z@99v;+>YTXb-kkcT2FZgBm;*u3hL$Us)sB2{o;L!@X;B5lk5*#7c?|AY^}?Zx=(3$pppUX7mD znmGiNmEi`oVo^H2#>9Rysa`Ghoe5$_ z*=_ICL=jIhk)yd^Z`8t_9J8o;F{2{MaJ{> z$v+)z9=he^I3GY5g&6SUM4=v&yrT0HE{Nbyj??x@JGbc!v+l}=_;&mSxB?FhNRV^d z-|Pa}zGm(6SmMuon&rx~YaNEQh?P>Ta7`UfC%e9bjmzU-96;2ZO7^cUZdp*Th~K-% z{?IWAQ{DYqBdx|Ng!f0UlY~3>u91CWyQUQJ$ec9n6d%&gBB^^uit-(S73~>gpSr%g zRbeETa)rL`%D&^?)befA%W<5MHh#3Z6E(dA&ZJ<)72E`rg;F)y!+fNGGlP%^T^F>( zKNCLBmMRwKW`H00Cu%A;7p#3J%+7v7Wxp1ABBPdlpu;@<$rRo2P#QHO!G<>~RCS$^ zfu6!GZ2vJrC693OKOOncSNu1hIV`z%n&UnMvNO3%Xeg5)AMOiv;_Aq!m&HjwypJIF zGXm(}zofms`LVfDSE8HA>>aN>llfb!jwWR|flx^Yc>QtvhQ(P(uhhVQ3EH$0EKb-7lAn~=ZmyE`Yl(DsFD|g|Rc-k}h4sAXEe@4c?j*x%< z3W%Fb_KAstNhq=5ZFtGc3!QrK(YdHkydoz!3_{B9DqrpSu$4}$-yQYf$1>b#`jx^? zu!&^S4DqsG-dF+ze|qv*>oYcGR#(0{w)S*A{7`6%e%KOLEK=M>3izG-j7RjjPlD$A zxTfzjbmJ9-)=wsZxiLi;PPJYv&5V?WOYQXiijOap1^uQ8tLerYcVCfm9r0e;UM||)g&Fj4P=aGvHxXix{ z2Jaq;VAj0JRVA=*TGi%@x)4aMo;X|W{>&Duny)ga>ePB;_dIY*UL&$77iR0J>ctoh zvAIW~aDShaZu8U_`=&Fp4l7n7BON1=(f&g@(QkkCo7RQJA*t15_gIn+=(Ftn@T~U` z2DixKpFMrrC6Ey)AoHzZ0wa%D#k}+vn-yXB zDkNyMA`>o5C{j1HHKH^isoTKYrH{8Va@sv3 zsKZYD$ve2wOFQuAoWZESZq{yWguLZPB>S)eS>b7ov!i$)kL(B69Jk%$C0o;)BXO8B(1F4E z>d=ct$_3(Gok&628rFkX6fDyQO!?hLygs2ip>#Efy;%!*aeNVfTjE=s>ZtQbL&9>Pyf2?WDnqOn@;dT*~7%1{q zw2ELj%~ZLH?>olLU7-6tvOBfdWnz;g`wk#{i4 z?jdC_cC}Gwqy<|K^JmGmL5E;p{KL{#6Ea1ZDnIwz17K|Eic+x`Q7~#VfK9_~ZH8AW z3SUE}h!Pz!ct(~k2QW-fPaGh7gDor*tjnmweapY)PW~6zMKrjs_Qe)swJE6rRM!=; zu9EK3T~K;4hIcInjP}8-5_UX)JMf_1GS?n1yi^t=t!yf@Z1bbjf)Qs~zmEgSxJh-g zBdJb23E_@@{TDD{1!d0qSf<%`)}z9dG`4JT%fAjJ+V%>OLie+zYheS#@*6+lj?P>7hRN zu3iu-{{;a40*+!iaIHx)L^9Uj0;i%1#C-rFayy>mx{xD(0Z2Y5qDrUhA`gEu_DD(( zgGpWi?h_Kje$nw_@Nv*t%Cv8_t}(>^!ip7PHnQg*P_NA070+ISVcOi52^AYRyxBQk zu#pVs=6PvZ7`!0omqd1$N-LWmbdegfSQ(&94`4CN&|)a)ko1b!V=ph4y|k`f4I59@Nf%f=oCvEJCY7 zZ?1w?cM?0VG%)*PgdY?}ip$RgN{hKhQ~~%UGBaC0^NMe>s>Zc0+GOr!Jx?oAoe10` z&5ggY?l_B{7R`MTr2S2q{cLVHn)HrOR3P}Cn$~1fY_yDw+C8L6{jg-9n~J7LAPGE8 z>@@V-X3nAXkaSt2QB$Au0Yxnnc^r5wYLMn5J~yNHC!);I=BhYAaMPEOELV|CfN`djbkPL9U-%v8P zv2pn8o93FScz*xg-)seg5Uc;d#okeNJ!g>{)TOEj;O~jylvGifq5$=9s%X6sq~-CF z{tLic2_OKw1h79Yqq@V3o5P~c4>|O?ciWvD94H_ia7~-y3D+_@4!I`R=c=Hn3tZJn zPl5FDC?)Nf^^kS=>RZ?QL;9BR0Fr5EV`p~9aN*V0e#)L)9st>oOn+ZH19rF0%x2$0-dewgbMBe`%-!{ddEVv!lUs*j1`tVs?Y(2D4yvi2Q zczBE89Y~7wT0el_U&g=ws^(Z3I`J(db{m@UaDx3YT{z*3n85=`Dhug+0L7@z10n3v23=a7+R)EX)E_o zJ$?#4-(Q5~rfb(gQJJEdki>^^Oztu?J-L+>ii&_P?5Vgz$=1xG+Pqqi2!#ezzcD7ipK_pnt)oJ2e&{w~`&O0(fo-Tb0+Ca;W#ye(@=$6z{K1n%tozwWx%OoCWm$8|> zmwjA9_###VI&^bFa_X-fkwrljmgeD8V1a~nANz3|yG!2R9HlJVPy51!*|>27dxW~` z`-c3ZMX_s^dZxE=)U4`1_jNuW-|r@EqQ&&Wy^Xy;c8Zrl3an%5 zSKAitZt4azwl(eZvnrN5MLe^r(0raY#%(ss+Ex_to5S)glSz8|-I97v()X;BF#__R zyZ<4OLj5{7@8M{{#zS2rFM zr{*I0j!hD04!Md}NJ}3fCnUyBRRJuJu!0C(S&ZG7Uq=-kx*^c-0!?H!CVhn+>{HF- zR@~^uUwLPB`IBwYizBKI(Fhc;CG!CdOY@i{{ zY2!T!>CLYfl8@8v*6zA&z4iL|06jiPN2xc08msZbSw||$Rz^MC_<5P86bzQo378h5 z-&OlOkJs(l`X=>bUJR(R-=+)|-R#|3HcGV(hdENY(ot)d=$2nwXNWWYnQVhEwZCo4 zni@~@G(}6dDvZ)Pu(&fYl_p6$@(HezWwJCEyj@h;Aoi3F&^5CeB@=j^D11r zYn7HzDoJ*lUdBF;ey5`YvHMv{aw9UDe6bt$5W`yW+Zu5*v0vd~=U|`BN2kQ^8V)knG&8@ z+|Rn#{`pXfVExu;uqC)Z-Ke9mu0Brg5j(znj`LfMvZwwEq9hrI-9uRKP1sX*Coo5_ zZC(2OgohpTuz%Z>9hiNuVr9i*+N>_8_nWCww4A0^noF(CveHix>*MRV8*W^hqdVwH zn^u{p!12PyiTb2H^>R<;k1?6OrKPnJtuATrtcD$l#y%+7!FqP>Xol3bol7h52n5}R zR=30++O0W|E`M*|IC(Cd>6Xkfhtwf@wxR`2-nHhe*GR*qujM%#BM2@U^?0;7@|4)wlNrGQ( zWoSU0HsRq<>;x@4=n~(RDvdk09f6kBV7i!MeA9l2NRY}om!0#J>D{1nfBK-MxlD+) zXiNA@y+`z3a_calVX<<&d5gOjG-=o@{94#>=Z0}Jus1g*Fo4@P@|V3#QNe5JEhl(p ztq-2T*~<8ffun4cjx5bizqm_E5LgT(eqS!7kTx zdAu7BfYE-zX2tW!z@DkT4*KSpeQ`!f@vcOo2T}0#_wxRsRrfmN!z_gjbpFhASYp!= zTB3PUm6cEm^iLO;M!g42>B<#pvn$FaRdVtKBYNx|0}-cyYkhNVQ~d1y1BSGhI-7A11>kRp zUv{kcSlO8rRm*Yo)mM`Q45EkTjV`1SUX`L7guTSX7==2U19u~7L#da`H=jsiZ!9b|zZN$pL zB)4eq)vv{cSFO+DKyH+DUt!O#(H;3&oZIIa1DX2hhXNjIy5BF(LAa!wHDi0DFzno@ zl^j^3o`J1MvQ?NVEAG;J{=zV+k|{}1uA=qn?(L@_IJeUv=KckJrjJ&3L*w`Hgr@|} z7EE>g*QP@lD@5Pd!oD%Vc*P;6OQvfUIFx7G?%C~whkCx36zw-&ya$MC4LK15(?F3l zE3#+z3{uwc_N_HrYenqjAcjm#wS#x(Z}QG>F;ro@ zK0y=m>P7p@!JH{uv1oam!D3-mkVEMW2i{)*H`BP&H2#!K!})lBY_wh$bNps6mb}SQ z$R%Yvo{`FKlFqdzwB&zhq!f5LXrxgP}vcrb#r=iR^%KVFxqiJnVeE9_wJIH?!u z+=P9pIW5~j*HmzFs`W!@D{+f-@V@^H1VYQEfxo)jkcU+cI}wB&n&` zFh9;{Tuf*fFU}M`tFTnYu#`{x^z~We1`l)cWGQ80hnZYZ4`KPdn0= z_0qKf~dS%@$*z*9Nfr4cyM?F2xNHi8XHNreIMd zD`ez+?psPYcXzjhrB$fTDq_@s0KER?Egr4K?aG#S2Kr+MAx&hzM0}BM!QoM1F-R6JbDJY1Ml>xx;~5dOJG{?K&naa>}AfYZLX*xuJC z3g$CfJ0ict7>_G@@6pe52BCDycRMVrgomJ}$2o7*qnj5l6nR_5Pn$EPbmwTIuS0!T z`RqPU$nk?T68b+vkfQ6$OAyt(^+%K>j7c?TIBLu99?0;3?QYC##y!I~^*QB6kf^Ob zGfG=cr;7V3Fz;uutyunMBo(4eQ$5K_xGo+x4Sl|ByO5~a?1OqQl+vpf85%GOp(fk7Jl5c-ifcD zP9xUu*XyinX*gv##h68o?l(Qp1?ez4;%2NSKhw3MUWaFWr+c&V@0yJ6>iJUADV%rV14`hS06!sX-Xx$LS7N&x0hp}kGvK&6ejFdr?bQ=B1={|TQuE#r)*Po0_WQ{B>1?g; zp#%a0uMk59`ewXJL8a}28gK@?EUODzjoS)Ql{|zHUI%J)6PP4~KXrEs*1$`Q;JrPw zd4x8E>=R?|Pu!NVV$Ok?00)>gWHT6w-kOXy#K0cnxKWmBPy-a}6^q_aKoosH!xp z7DtH&R{sSwfPOoSr7R=>n<;^YfK`dg(q4xw`3bNUe1SUIX8`w;g5Sd|e}X<+!RiaK z8^7+ITqPAvuZZ4WuooH4G2qh6OC(^6a%5i?f_q&TKo6sf($v}+gz%0!>xyDzI0sK{ zdKZAfZ7O^!x>z;`yVN8Fv+s9>`lkrcDEB!pt9nq4!M61&6%8le!m5Mq49*WWWQplQj$M(|95s~cd=fqnu?DB(pq^hNC2QY!nW|XnwNE-4 zqY`x8>!5Kca_GZ)XAak0PPC#p6ACPIoLPBtO>g=a@PP2M1gz&~t`qTr5GU2rDR%RF z+*fPWS^qSK_3j30^Ih5-mniI&uoDkVm3LW<$;ko5I}{l?w;M=~=}co{#p z)+v_GkBmKfJ7XYt$OP-OZ}ETT6diZO8fcu8$|!s=z*zG4DA(1B1*7^Z_qNFzn8tMZc6Dc35t=8#J!+3S!^^oNIzy{wPamE}N|rMP0GvLv#Wn)y z1E_CFx+`CFm*0M{P+?{Mv=9qi_SU?0 zGGxK#Juw^Q_tF*V80+?vXsOr!{GJ}f$K_lA41rVB8O*O!P;o~Q=gFZtuR3J$cN~jr zh}MxYiw^$_sC*BTs9-588wyE!6@cj#3(*P~KU60`K0QHJg{MX>K<{q005`YWPj5l|<;) z3tJ0HZiXsFq&Qm6b9JqMV-e|mY@()+q&`QjvL_>Z=5HBrlbaClTto%pvp&;3f&`;M zp6qR~)5x9`8qZ#%c9ik5y~@FrC(MUsO@Wdl*!#Sd&L&T=C?xB7(cH@d6_y3R_|A(l z-_k;kED`wh)}}^%0>pk5q*`88!lzG{$;v|&rs#*>?fOn2J`6H_~&5D z{^^3g{=@mhbsT63eA4!~%E{^OT}l)hgY7sH8fvKEFyuMs5H@ zJ4mO6Rd~jH%50=r{9Yd&Ey;6FL^@gao_~(Az-P^B=RAdPtPX<0fRvXEZX2U&W>H** zQ|}t?Nh-sx^~6oFQ_q*OUTt(*wApPq5cz@;iV2)_GnGv7v*5eRcOz@A=wk{Lq+DXh zI-gM8$OpXBw(k5vu04ShyRdemskM z$DhL9q#aXIbzm_gO}&Jc?e%9v%*Akrl&z0yx&8=HD0LquhlRYAmN&Lw{53n&ob+67 z=`rc!o0>a6{mX`lf5dttLse$n=w2R6-U^_6j8eul^^MDj`}8W2%2!`!%ip7$p4=98RI}C9B;Py9>KKz zp3%nr3{?1}_r{S*;RW_(rg10k%5`EI`CX)k?rT?4$-biD)CXI~vb`RJ#RsFVcZ^cP zNMv_-y5rVmP)BcSuM(C1^q+iH1b(`E*piD&Fj0Gsr9WSd1Yv~Gmfv510FQ!X=xN`r z=lI;T-Z<>3|Gk5i=g+Mr_!D#{^WK~(72mCnL4T!}N6W?<>rI`@lJ%Lh=Mfdf-#?bX z?T?{r>SmUSKCU>Oqe>)f!t$S7x2Lsp5t-@TyB&0!KRP?~1yVTj81-G8M8|T!tuG@x z;rVHE#Cxzj1AhX7WR`I5#fHJ~>($75P`h7L+LN-^hH8kokG->2ox+dBF3HXdEAsXY z$u3&Vt4xIprdf$e%R+z~(g%mt`Y5qKE9o=#E%0Gloo+Aq13mTr!L*DHszLqX-H97e z;%os2yXWr9_*S*Jht3zm(us+)IZkw$(dEe`Bi)QKLR=$*)py#HyWn*dN9#C-j8^H& zF-v#g9=!{!V=0)vmvzthcZL})lUM1_Rrr!0F)^aUrf=pp#7J?t?R%7;S?$I0#k;f4 z$DYd2Q}=A4un_-BK_H2^rkGpF+@;k3xB(0 zV21UCA4|5m?}M+%az&>Um*0$|>8oBU9y2t=j--a&4FFJ?vsUBB`vt2vrE%JLHTd~^ zR#e!*e4m497<90r(p0V2_yGc5rQSQOxt<{Fh&8{ zSb;=onMc^xe{q~x;QZ7~M-9d@9{uuf*x$afduMv>8motu-!--x^rWZAvz2c*DeH-c zxs=!?ColV$*!UVF`$eNN{y^xx-cNkp0e@&XPM--iqf0)OuGzMm4IE@mQ$qX7&}WgS z6(|}o-mprI^S8-GCxL)6R>Q~2xyrfF*_?kun!o;A?~ABjUE&wvZwNts%oHwM^6*#k z^Mysn<>4lC!bO6z8mV}fj5*%5)thI&yf_B!3W|E-dU;dH+^DH?ZE-#OT2-i;l;KID zgq1fFsrJX6)iGRr7BCI$+rH^8lWDEAYgI9BEfCc~j{AM&Z2xR#$iaSOckHy=M_Kh^ zD+Cy}+gxIqBWe3~ojNkf`cLtT&Y)Ylg|W`%CTKbq4c*V5Vzp?B>WAf~P6sF*$LOGZ z`(PaH@`eSHlD~WOKh5z9Hd>f>o_!jV*FmWDW|~E%H3r);emxtTt-yC(OHwD?vH3Rr z7hnOsvu`)?37wLlK9zPo!kLOo2_&;9+~FmmCb3n z6Gf2bq`A|N*PJDwaAUm*_y>&uUfx%99lxgiBILwlHCmzSCOA{Z8?D%$R$Z?ssFmN~ zkTwDqV$Cx1VBoe?TACNuck>N9hKt~J!RD#rf_H0hsPFN~DNQ}K2bLfL<#N~vydc39 zza2OTzQKm(9=yGKtcJMEu-N#8SqmAPY)~{wuTED*-}g7?I6G?*O=q? zZfxs#*f(a<0_Sj|-SlY;)kZrIxZqDSZ8sol?_)6_)70;m_HsIzvOQ3BaHMe%8#tY- zq6nVne({x_I9Xf+83HU+u~Q4Psfyu;c}taB1X8^_xwO8t>d5#h2)*Hs4WXiv{#@ajnl%Z+DP(|R~vvdRBAJ_Eh zakXG+JhD~gFt%Z(8Yhg#X=-d>Y60mAG%VNxQ;A5pk*va`+;~S!oDxNPtdE+X%XX^s za1~lY=^oGx)=+|?E%e&b;x&x~PfMqZLQ6PwbPC9)OwuN@b0gSOgGn`u$2>;?%wz?* zNFT#fvbE|7q1MRxaY=?Zzv=TY@R#PwJx#ByXHLjD;iT3zdw=v#z6W^K9vz+g;bI;{ z7^!6Mclt2J<_?LeiEoHIZ#Q|Vz(gh>weC>PrBg%ar~t33f(qO?1BL!lrQX$^v8nUX+QC@*sj8znr^Y?K@><&!uzW1e9c+C7`!{{k-=) zBfZZGhz)QFG#0<%)$kx8Sn%qjdZX26$^;q1<_j|m;(ZlPZrF0$^+>MQdPM|Ii^3EB z6QJ)^LH<+*${*Z{+uKJJdwqo;1QoLGtcwXRdpCEmF$ysX&uFV#vdb)x97yuHhdg@Q%T^HYuyb6zm2K$uL;9-?P)P}otWb$+N8igA#`-Y3q9v~@lYQ4YB zkTpz7%WcDtoJl`BW*^WfQhE;%W4xVANdtX>*G+aq{M7NDjv8Nx+t=~)%w>{n2M65Kj@fW`e4R8r1q`B^lGo+Y__ zYe6dyO=T~#CJKJjkyp%SrcqI+OS;z>eutyS5=aSL7SX*cIL5hsS3A9bg%(*>jzq76 zBEgx32aWv8`_w#n7A>4pXYA7#;hDTvI` z%Mx8s-;VEymQ-3WQZ!gPJNZp><$TK9dr*c`;;398?HWupuf&-nB)??Ho7s!<7z(`N zWy}(s_jZ-8I=VyvHIT{cH-{KWhey@}JJQg1@{WOupqX*N?;_rhxoewlqh+lg4b9CJQw6cT zb!ZCguhk1ygIm~lZV+5Wo1NX3TJ{@J62!4Dw z_3g(y*QohrIPSVNjI%ONqqqY)qZ9k&soa(fl*^(u9zl^PIZ(c~lj26k9Q zoOcqWQ2+JDm?R{zNuYyBz)_l1FFq*hTxIAokEK(o=L6e&D-KFawtm~>%c>HD{)nBg zp9PdelwEpJx^oS7q_^4m^41C(@@RHQzm0=RhTeS2DNB0Jj7uOZ~1TkhG_mxK>hFX*#8sf02|33 z;{D8|c+v%z%fUSm17pRzr*uPH_B!9t&wf;CAsUZxWe#&Z@l#py8vFah-x)J_B>8Y_ zL)Ygj<^m@7a?W+j4LxrCqFWE?w)C(%Rba`H^TJsnD*wX2oltE(uTC1u_+52)+`x^x{AHwf2~ZANo8$ zM1&|x=kt#jYY~@fc)q@;grRNGqJ6I$OoU}ra0<tq|M}g{juVm%; zg~hRy=q5Q06`T6_=HX77|16COOvr87a-k!WLM@M(^<84+tI>?|$EBd{$b{=I>>iW- z1$w0cc3^-aZ_W3FD~0M$RL1X2u6w?hpx^JUq3bR>Ojfn+sbJ1FrX1uZ0yQsHp{3>R z#j4W3ncRG;V7|rGXQ}8h`UYVFNRVb8x0Ydnf9a|jmOkAiH;ze>RLY)C?M>0kq}tyX zB$|412DbCeHtDST1*WN_QG@K4ALc+uM1C&%5H>_;NSlv2%Dl7aOKZ4uJUdhdcom0B z7=_9HTANeTt{kDT+ma_y;gV!Ryo_`NWTI7Fi*N!ghMupS2mGVX=iW3VB23*KF40A z2l@x$A1GVS#?`%k`YEx)!oX!md^cW=`zCI$->~X=h1GO=#`3DWe`B&)$~$s;F=HXd zhfWhi2K?J*Bjt*bw`F3Ka(SVi|A*(SLAeigo^9h zEP8PI=qgm#UjsZ1+~yz*#dQSOr0-|9@e+1ZH)T#qav*hk8^Ob z>cLt);Lc?oT0E?ZDfy29ey|ni{w8HNTR*PSYJ3)65n0A@xwS2Bz35?ggQNC1qW(j& z&^NrS`k)FGJBoBCTBc2((%pT__6D;a9%l`sxUl2=5}eVKW>O{0PR``^ag-^YkK>(TM|s}xHzNB1(4 zIdBKU!BWK25oetXvRXF81i$yxF@a~NvVh+=jG9g{n@s*X6T$hXZh4B5E`t3Vv_F%d zY&V=g7|hg+u#%=*tTi4sGAU@7t=ugx*OQZ#EeX-R2@>L%N0D*xD{t-7T~gv88YS#h z2Z35$DSp_P5FmlaXnHeon+N*~05t-yFFOa?2Yci9v=AVAh=j)XCU)4`9L_%vck(if zkE)n{4(r5ImquEvpu%3z_?Y9Dknpko!I3`DfezlOHnWWi*7s9w^2Li`i{7QiBES5c zGIWpN1F&#iO)9;9J+0^b-{05rZpj<|^p!O56;zu&IFo)X8Y8Zqe1e!U-D4a63s9<) zsY9!r*ztEF7}#Z|aI?@W9L3WWcCZacLZKv}KaxP~oxy)%3sA`Pe|EgZox}-Wd+Kmm zUNzF2*PUlLH{gcoC*Y6~PO?~pi+==a`4ytEpQCXJyU!*g)5lPzu+@vIB+$B_FRo>p zy$@Rxf~{*AVt;|2+ijgQO;_08yFbreYKJ-vl3hPkIsmrE$cSt`w-Pq+I>RBit=Qs@ z1qv1$o0MeqD82mLHY@%?g#P6tDh2J9SSPVwy8K&wQ{nTgM6{~S*TQ9J?{_^Oij!`} zH;Ut$G9RWUTcR1S;t(OZWkj9%5Cg#v`oTtFsLMmAshUcqR1u!6w&V*-6c=%Xv2e6C ze}8e~q~kaL8(+37qOO?GoI}e>Nigy=V$q1XzVn&kYj#GqNg5C7(4W8Wx^(TdDCiX4 z{e5ufeIBJG3jm$ieQOg~@LUTG4W?kW3N#r9qwR#+&my} z`p55yM3JcuGqYQ@t2ixnk!v8$uuq@*qsIAdvHgl`MVS$t?)x*)uvtggFHbdf>2sY; zpwH8;AS87$$a{H&-$^wx`E0+#5O@D9aCLTlVF*{7j#)+jQ~gU$lgc-n0$@kQOG%}< zuX?{=4NvkbKIE(v(`&mdy;(YM4va&wSHbVU?6_tv-hZFHTC_C22_)rn>~@0iJk`~- zh9XI|;VX-{PI9~8+MO04o~NM$C9-tzRdnj}jdvrPrElAsQERk58_lQtHY*@Aw2?;w zq@6!VB8)+AkU{a|E7VsI$L0t>@uQGT{o~}_oJ(Jlc&!au^poUS|E&Tqui_$>4lAP_ z@2gN;L-T2^3Q4b_=G*&^f3_P{dqGQ%1&1!k@~#wjfP2ZdU6(Oj1Dz>1xQC=CLFJx8 z+GPx6dl+tZHyGMQJd)N3g=-gYv#eB=fxcI#NC^D_XNnb1K_8puYwqQqE!%Cq;VS<| zK<iBhw!E~r=!ejs5v%jFxUIr*vjz(k%aMTQ#XT`_N*LLwVQ!3-2k{5x8@(kR z3A>iqC8^onVt%DAV&sFLn~P-27>0>L*i)6tRg>~0Zz*DN0lARpWRj|SfX&BFv8tFk zI+N=_U$0zG}DQJE9Q9)Vc%?4VGG1;K5{b4P|=C+1xX=dXldDThErB`|F;vMmCI- z{e!C@9_EL8eF2d>*iz!FP7o> zUIjbaY?S5rTLzr>n}xq6rv5k;p@rCdLamvREVS*>x&w(-X_v4aW4*dUBqbNaANv|2 zM(mhFvTLn8Na~46I(iJ3Gb!z#)Qv#X3AgamZUDRYI^TA%dB*lasj#Hh9gk*h3#S?^ z_*Lw6nax7>jGQ~Xiy0lI6?Mf}I`B?;LoQ(t_opmdW=aA#NG|mtBSkH6!`t*Aell`M zwa%1Q;ey`spzBFpt@{)%H8!k`Kk{cy=K>M&yf)fId~(KWW&vw|HNVIbVbZY?-_eHo z{ZEhZ|E3o^*l9K%=Wmyy+N^qt1Zq$$FwZ_iur~UeroR5uJNGL_&QE0x6lMtQdm^-A z^`+N^kb*q^>8CIhy5z9;w7V)gS-SKpx?18rZi7-x>tJLuWfz)+DN8l->>DWWJl;%O$Zo0lKmkTEt`Y& zzob}9T1vTmQr(EB?>D4|mOjtGw0pZ%DzEGHxg&$~mAS_-t1EL1|tMM^G4C53lc6;`3-=^c}(Qwpl69T|L-v9_LSHg&r+Gi5zIwzn_} z%0ws5+w)QgkT*1LC5|z<4W0tm=zFsyB5XBG_S_~dCQU*r4uwN`b)TR^uM%&@!lygM)9GrRdV32p zY1ehjUk&B(Q=QM0z|^=rF`1R#-|w~6K9Yme#w@c4q%zlFV>IPB`bjuj_MtiZw@K^oJTs?JO|R zc~>hK%`+xE%5vp#Z*rtc9Z-!UOP*}I+wHIH0F-C>`11R<-C0<^kXxX;dBUzW(pFzSRgez(SNT;nuVow&bnx9QhL~S#iuZ7 zcl$`_flj+Z#N(LDrreQ(Ode`7`uxem)-xql_he<0&mNT(JwfEDRB7`FzZ+g7Sl|Rq zd=q^iCqN&yaK1V&OQi7l4rA+Q(e8P~Qi~XIn(q|c1Dj|_J2CR?amM)9l$BlZ-EY5Y zj^~zsi`$hTO%7m+O7 z;^n|Jdm5XDhq4E%6QFVY3?*}@%50bDvD+1F3>~0N8Q2P_oY44xyiQA?LemUQw511nV+nMMve&*X8G4g4-_iej|2pOJy z+Ag?6!(6C>+*mLVka#cf+MLk3yAlU65aLu*(~A9bde^`1`X#eCyT~?GzX73L`_qaS ze?iyoF(XzoJjGA1W(8NjF8sPLEwAevN*fC#>#h3i8Ik(U%lcm-g7hmr;0+`x@}+t- z$yfUjN@jp}Kpd4$>gn>PP=AtkINr~=0OOv_icanziKR3=YBF?#zj?5PO2zS^RI&J= zBnT2;X2NUIrMh|oPA68%VI#yHn#cg3rYAB2NL4ttBeeWq>Pf(PR8KO^2;Iq&P@m&y zHoa{`!xyzi*F>Xrc5yfUpy;|nUTz-r=z{`faZJyHoBa}C$$a38u|sibe`7F_(RLg1 z`_`Fr$MqO`Q`M#hAWz>ftvZWx+AXoI&AdLvYfxaUy7q9{n5CS>UG{^#fQ9E|pNG=a zog**zDm(3T)0#9)_8O_yDlYvgv5Y{k;WX)6c+xfBX@`*3JXL|$;E%U?7PU3vAj}Rm zY42zv6ZrOQu^!@kiHFpUPKQ}O#f2+G^Pb_*v$*Mo4{~7w3CTBZNV~-8YVRbC&j)qz z#VzVU^lSKN2Ty)<9oWY(w2wT0ezZDPC3Hh8P5wZbdS_;I$$tC0#v=~lI+En6@n~>> zk=fMvKBN|Z&B);gq7t1>6z%Fi8T$2(g)-UCWQ z4&2bQ$G?Lx3*@|9#y=L?KXT^X%SU2Sx^rIw`{0{@L0k^I`6ThNgS8>P1bVZ0s-@5A zVOwsECO;YCMvv-fwHH>PCl(~=GLAz`E7?bb4YRU+50l}75xS_xV)7$cN!0Opcfymb z+w>P?C5$<8#Dm;_HDQlljy&4%|C4M+m+s2iw!d#{8iv%5D3g@enYVKl?FWA)nixy{ zsJ*a!>-$VbfF&2>jxte*YTDoQ&RI&|9Hxc>Het0Jk6azk+h`7nYkI1_l8au?9x z=Jso;mN-S5&CiG&4A=cCY=kTzfp9_HD^qtOM@{_*zivIHw8!5 z*=X77Io&Z=LUqHDxEBzsE8D)>OW=RMy_m=@ZfSLOMuI81Nx9j4WhYpQ#9L!zzoXOr zdeYmpl9f9L@;a)B(TT+%evdh~HfZeA6yV84d$(M+RNjHcg%_ zPm!;KW-Y*??!W_ps$hpYPQ>e;ppNn-d^g=eS>il#lpr(v>NBy>?-A!#f|M;pzcvj6 z98E(I0`Fa|2I|SjfUKYuf5c~>5quQzhUD_|RhgVI>}VUPHEY3oLhclDGjW9`=9t3t3zXcs{W;W+%JDEuiMdH6P>Ll*qDGzRcq`6d!m zn1p#a9urjIQow`$6wz2h4;o zn9vEf*UKm%N4PUdxVXlP*`y~!{Ye|-6uoL(yo$upj^8ArG4gOj$VPYMzE>|jf|=;} zM_{q;fm;skXM`7~ettC*8y0Uop|&ONC}a1kOKd2GZ5sV0PMK-VkDGMm6Mo+y#-XhB z$Zld0|J!ntUO_sm&>+8bjF4gdD`vNLU~&r=tE98=!LH3er(V7G6<&w^)>X#t8#;4+;hty}U;SU-D`Uxi-?% zjn7HtQVA+X>WP_Jp$~ZB(T;Qe5Aa=$A->pPUotE8x=6k zTBo~=C8bYoRZRx#%rw`pJvLA@Gyb#P-#O)Cnsno%WX7E%?qa{C#K{cd-1_#p`FrwL zcx*NDmPmGcFq^SdYAbU|<&HZm>AO(y95%20w|fgD!ha(45B)AbdBN4$P+-O_wa??V+bo$3CgP9FlnCG!7&S4Td;eklL29?s z6c>KyKpfR@a4tw_!>x|Qq2E!Yvgt~s+Xr?yacgNlliT?@wMU-EuNxEnHT@d=EB~C| z9A-(H^zB^`jSakR9k5B`^{Po^`ytRR%&QhsZke!$Sd#@uHnExMI)31o*X*w)1xxY2 zHw@6yzqD|aOZ(;*)kJ9d?A?G!hzI{m9s@iNpxYXDU}+W|5d|x5R~=nY3>?TxBwivA zU=JvL{&E&NaO-%tR|$%bdEbDpJApgl{R4NuhN3c20YJCaewnvC!{}W!jNT(SKE|%f z>{Zi9x*PWj;Xtp5G(t&wUASHcSNpUosg!WA#vTRE6C4dZR|}~Un*`R=vbnMiV_?%U zVIl{%{{?-GKG{72K*9JFfUHqQ^B0uO(Hu6SY08s$?p@|}CvUx_yNx%GGAkwu?S0M$ zUezRD{W0(S9C74$Bq5V50y#Qf4t}vn;xmkJ=Pa_*#E>qQjdI@xDxGN*cH!TtRaC$u zb(#O^@B%ulkv@m$szcz8L94neCC5n4R;2O?*RD=wzPr*6WGy6&#~lJS7Ol$t&INB6 zJu%vt!*b6hbUq=tXUmU{9O}39b#<&UW0lFg8GJ0fUT>v;>Kg7hX7b4nhA+4<={S^; zbix{43z70SRLX5N(E=Aid z!@6>K7pSKn1wrj#9R*uzu9A5fw0%$Wx*oB&$ea3ro6%<(dkT!%vC*~4RVIpNt&ADi zI~fvSJp@{HnuGYmD2d&(b>bLSKsJkNG3XcDYqPmis8;5GbyV>%W_@{VCb2=~0b#X0 z2+?9PtDDP|Zg>06?4HdOTE0L;o(C*2{^S*MTPKs0+GcVH_FDQD!I2EG1t(z?)VN zjf9;I!wQhVg*5`kJ+wl!IlEUez2{I~chcZ%sZAkws~D2@i6*z95gO5I$ufd#ssdc^ z*We0OqvkH@8^zu$KPFRMB$6dkI(4V{p_0AdAhMv^X==&+RISUfvC67JNa^wc%>%lbEl-^|^w zHJ3b_67DkS8a7%}Z2M*yvzGw-aD2#RWJ6n4_RK@;F#zhmtu`-wccSopjdlc_-i08h z`~_j*a$XAM3?dfWh<@plszB8O4PKLDajkH!yI!_DA5}S{ORTV6R`h4|i`+$*-rlKq z_q(FsVkVDWO2#H8!J1&-DI&7{nX3(|3_XrNqnr~XDQ_-F zey)x!*h7Snkqg#$qf7PhZn_ma3danr@`mPTlBsGp8X!V-`1k2u*jA*2|VF4USD~0WK z_a>EP)>_RasJv0O&pQ0pyi9@o|D(RkKL7TTRbC?Jp3tDmO%mT2$xo~y(JL!s%t>j_b3AQWZ~ZlZbY=7!~hvMb<8Qz?VGlPnzO9 zI8+J2=tC1KqHDxL%uDy{1<{+rOVA$Wm04JFf4qg_PZ7~ejnN*_biFr*fr(S~$;;v) zHQ$n&;4|wi$1s;1t7_9S7d23eHsyc$)6d1T&im znKYAUk7-l?rK(KNNs9w)9@)0sk8g&_$WKq46<~GI32t)vqC3uSisZfKu3D>}lZob& zht$6fnQ9Qn!CI(b$|QZ_T+kv)Rt-PgYm<{;w0y3(R+U_~1B~zYak>7coV1pf+uAN= z-wyUEjkt(Kid63pJEm>%fjtBR9|1Z5>*YQ^@CjaD%M>MNDM>H%YYEG$S5ba5*Gv`M zNqlAB{QkEhZXIhrIUM*9Ket$2|DGROduN3;ee1Ypb(D=0)e^rUv|PNOF`S*W6#ptt zUET0wkK4l|>4Suy_UiN#-320;KfxEZyObvL?z)QQ>eg^0_O^?7F%802q?yA}-k>*= zJ{_(M0#0E`)@^QYiF zd*15|NDT)U;Sh(xezuJ@j0Dn)3o#E#(qV~y(HW{_h7Jvk;4CRSyL zw4fkvOuP+R+B$&V4>dsb6^O=r?2TDO#H_VMzqz3)Ddm(WulHrBe3?mqL%~DYuXQmm zcULWaGV?CS21axb5iKBbAy;QZF((hM1qQVz=lln@DA zuz#}4@Z576GP-|Bt5^ZHgv_wTbYqNOM>8?AM16dZtc^? zy2CIh$FVe(YWp`h*7Yk&qFwy@yu7EysJ7q+9p6DM-U8+F2Io2=)MCFwtvFMA2iSI_!)>!nz)FdALTL1&{Pz$ofqn;p` z0!1p$%V?)B!xJt{POT^!9!y1_*l7&gj!9c6i=4l7oS1V7n{~hC6~*hQz3rBQOGdHj zW4~k5L=eh(3Zif_aJ$6*QHqbx9>fc;asTLZhV`rQc2-@{0asXk@2-djhbo6E8~Lpx$!|Yq&s)MTwJ7Z+Lc(OolCXl?EWWgyMC?9FM) zyTixBjSHBqU$=cdbUk9_^|wQnh58SXZvnFNK^J)-o;xd&SaR2x%*eD3b5)gYGcniU zpIAfita9HZ+=c2FyvQ{Waxgp|wmN8lNg)-!RZAl;`$j%Y%^H)k=#_n#z!&}&JI+?z z$Z^1PuiIK=wPUP(v>lpIWeIy-47fUeiv?>5r2f-EfUy30YJ4;$G|E5^BHM8y^3DBSwah7dSdb89b!=7#&yqg z5%PDQul2YAtSX*0_y#N-!R#E&v*wxyzp9?rS%Fl(vhDE9#%X=cbDZk}P8BBb#dfy4 zQB8UW6VrVR$sq|Wr6AFqNG`~nD$xMpsO(TXSDOIqwOd=J7JG0j_DaC0Xyx<-0vome zf|H!7ArCwKGt>M5`_(*QVDTl+!TZ^?+Q7URukHYy>mx;Qev$OPkDE8dw@O^!@WZf` zH@ufLR&bI;m+s@ql3>xzqG#KZ=(74Tmhl~)3KGSgjto-IavAn z{J%8pN4-dp#InTR0NuGaq0t1(xyKQ1}qfK{B-nKOxQ^yL$OM>EsYvXx`nW| zf{XqFA|`)9iISc9-=fb9PoxA)qP1v2$N+L`payaqO0UCz>{uMKZ0LD5^eC(dp>PUS41O&Z#kGGYF1oamZ`kzJ9TRc2Qa+GbSTKe4;|t4 zF|QT<-fNP25)Y@gVK*`dk9^U5RY?@M=V@=-<)zJubNBksM(!>EAsRSSfMqc$s(pPU zFTYf{bL5`2h5EI^xMI4?nCN7ZyYbrk&kdorm=zxgtOPpQ+IF9p=KN@@o&^8nl}XM%E;;8%xN^?Ae7-)~A)f zH;)p=W0Vwq7IcEo9yHPQRes8Ha=+tc%-!*P{P(h4roSuWaa_?5s@GZqf7ZTKu=*x@ zXUwv)Rx5s(psjo4gasS~)|fil{)^y_p^OebuEI{>il}yW!;{nAI9yu&gM^6j{NPbC z8LtJ14`H(t(glZ`w6)ADug{+A>3>&ud#FqY&J8p`v&_%F1v2V-7LcD_uk?(&VAld~ zEHd(k;SawLWpjMbAJ)>DlrR~a+|50ja14T!kOGN_EFRTwIg_70ufuPoa0ktllRo&` zb-2DiMRn-t3ibBBsjjJhlzjBl=Nuhvl+8$j!uTB>KI3ab?ei-*T3`qzd(AiM3qg%P zyrWs;t1ZiG%1Og~1 zp{RO#_s=H%!D7E|O{zqDq6$d))P-ui#aDUlYzFUj1N&^nq39{yG;sAL18DNpuRwvt zxhtK`J!22)=$qA}3E2^}X#f9abd9!A1{G`-yHwNXZxuV+ z{>U`G6yKGbip-);^fs~Wx|;FnW$A>K-(pRp_<}H^bMo!*8wM5aRCW6A3p_9rn>J*L z|J+-Qomq}=wJ2xzin_$y#9-|}kP?=Y$xZ&4b*o+{F8u!V6DAH+8N(FRGMl9SMJ1o z-5L$F@#M}{dZ2mawKyqB8B)TmDSz<)#FxoZGkp7cO+IyTrgh5=N@|`$Kn9bI0rnMr z@wi9ovH`n?%s23nN$sNMQi<8|VOdhglHj6kzp)7-vKu^P#&p@11X2BMJCby-X?8z_ zn0qRk6!Gw!@z9A|-e@;}Mgr6T>Jg-)5-k+pfYN5m7cniml^q?uxN-A&Gn{L{``f)< z`2!lv-r=31p1dav1Nq&wjShd5UPI#_ET`W5N;AB3x*DB(jpK7TeV0Nt#&!b`3WXB)hsF&rQo(25g zQw@{`;gkme2XR0wmyU*z^X;v|Ul1KIHjWI9iqRYi>^1NhtL5o?pJ8*$%MklgXo@6o z_K2u$-obI;nr)c?vQv4*00@9-gowzl<`><$=axOKgGGhfZKN}t&Ps4~3m9g}9rkJ^ zX={J&E$A$=w>Ve_{6>9DA%od!3d%_!uc0FzxW|v0`bX1GPf^<%|9CiX0Lx(V+C2m( z1;Q3~Zx9pPhEGlz%ROKFoVWcz(ZC+3eBGQjsqoc4N<_Xg4wm>e<;w=|eIS2qe}-s* zF2LlYewMQstp{xGAjZF*p_z!C34bc|`mVJb?GhehWd51_B%}HQGCVpZBBRy3lt-2g zi|AE>no-H}+j0A8XH!QD^;6**+ap`0Omygl5*YDwXRE0TXv1>_vl6Nd@Ax8n(WBe! z$RAwtqWpmHEXmQpFl%m&oIP`H1~p2 zxkve@*b(Dqme_Ir=WcWbf;#m1zbk3aEFC-S;9uSaJ?w&TXs6vxOuAI!sr75^2r)iC zfasXLf=45r`y(&_`PJwIOeOLX>*ao&vd=xb$Sp$7RMOA+IZS-5HYO~8`#jGVjrzn0 z00cp`H76wBg#Ui@GfL`JgkeVPX{Z9&T`vayTP_s;q$zoY9-K!A+Z?%O9dS*fEbQ0S z;mWDyKszcqp${hn6~;R+8sCQ9X*k z1Id&V%|_qp#I62-H@Nz+jbSn)7E1;ssN4;yQ4r!9X>+M8GOaT&WXbuaNwag!(Xsun z+T&+<=t2gC^@wFJC~fCv%Y3WCdtRpxS<9d2R8n~nE#ZMFSGsf?!6_Svr@lPapFA=i zTZ~D+oZLu^);zTezTBV&{=&Miu?xs24XDe9OGaa+s%o~d{N0K6{N%ZYQv)_H7B+3- z@)E_owHq*Xp~2rR(Ki|BQz_926r4?gg$|;5$N4VHrrD)mqh@>DduM6Jf)k9Lt!q+k z0mheJ5R^Xt%csZ7E;2|uekI!n?3;7zR^j(}JuZT^ei$byKc77CmMix&y6myi`AGVP zLz@KPy3s2FNc#lq-FdI+IQ{7Qz1s>V9zDrNEIc5I0Tk0Oa}+ua3hTxnx$f?$i^`h% znk#BR=Whe32i3faaV%9w31>JIDw3oq+kc-+2(u$i6yP=lmLR%?NI+Hyu}AlsZvf>&9M~T3gm6Ase*8A>-Mz)cK7x+Lr1tIw01X*g z&gJA1Ist;g!V)#o5p5Yn$SiQ0^G=v04UzyZ>@R449)XvE5z3xU&&|3HeCQ`1kv0%j z;*kA4nBOoZcNum-3xI~SkM|)&Llhn0=ko8v%@vs1%OaXNMsJTvnP$%JPRSpZ8h&*IVY9Zaq%9kG|WaV~>y1i7en4arXV}Vc$FSC`zCrfm za@*~_u=x*IZx6|l0E$`B8?Q|@k8X7N!LzLSrd97_0N=_P{HOj1rJS#?OgraX+WX0M z=f?_k{y~{IIcw{NG0}^$I9}#V`?KA=ThX0u`J-i%!d1?Al;a;Ijjt$X-$=vpC^PdYEqtcG`b=qRFUHpSoKu+0bApc`4}A?KBJM_ z)bg0$bx5|CbPuM%*~~C7WAzre5*6K*x~Pp}u}{FD!~;Q+s}(I(~CEFF`Y-Sl7kbJ`lO}sOtHxmv5I`>x`E+oU{HlGa7-#V zlRoT>U$DKqA)>G&_c53O_@B~{O2^wZ&DP7wL6PYN&{<+Kaukud+5N40TH+^z)i!4h5#QEn(x4irPkO2BYt+vF(fL(054mv(!_R9%BS*_{PBAb-Ww>4RgGE+i) zzK0yayDwVC@q9)C)*{yqe^PE+$yq+eAF@7i!LW{UgPE6oopxyrX`^8cMcIg6VfojM z!xd-smPFqVKiD^)mO+VA_yrPQNlk6;RD6!3u9Y`3`65ynJh-P(qv5!JiA=|2&6eg^ zBAX3)zu$*q{b;jcOZ++M=Di2cw$^bBjlNfcUv#e5`qbLnSQl{hLiNqq)Q#t|(4s zP<{?$N$er_Axv*5P$=Ib-PyIRX4XW0-zn*n`?x<1a-iAEozsGHv$l|goN@GJx6Xjf zeeG*#(*oclK=ybOd|e;*fTT@i%F{t}`LtZm<%{y^eidhR_o)|BUa_e%Y>IWBF*dfR z=b>RJlfL!9HFoWrjQ-G3FO3x?owiiu#l_|#WZVWJVNAjr+FLji)NY?$%{mhJ`@gcH z{%@Z@ICoI+wI<<_cb@m5GUzmOgzAbxfMJ5fvS7h$We|J6xvBHgUyvDpT07A&99VcT zEk+GO47#KfbFXyTH7$|zkV#J7G{71>U%$^D&4|6vup z69@U?HE9>JeOv!-k$fs<^%MUkm6DNh4F`tgRPJ7(C-di?Z-d28Gpne&OoSKxXi0km0 zZ)Z>NJ70ZVG^tIqUgpI8P+pIk$FX(?OLgG?o?4BVAtS!LKloQk>tNUfrpkbzJ_aykH-*x-KDd2$PD!}k;8;~38y=dZjb6miY zc_CUElPJy)iTsr!-0QVns6zpN!B9KAjy^Xh75(;@zifCc%cr5PZAze4b<8!BWa@I{hcV`=2fn}?T|Go5R3KT!h|D-ao z;FF9Cr`_4iwKZk`@!WSk1hy1k_1=adM61fRC?(ZyxnL2w=nbzMzw>s5PP!ey$|!gy zZn5k=_hmfwPcOw?MLjS1&^x?!!8tCJ#no|;H|stc87xFZVI`}4B*{o9-Eu|9dnl28 zV|5do#oDDQ)U(Z7A=$uLDbAa?p(7~-GYajo2U4|mwHwIHq@~VG-ai>SH^PCTD`Zct z3DI@%=b*ycO4WT=Q;l^4objK-^z#lroPQFcsh7W7S(^7$T0nUXL~jrEd?%^-(?zN% z`JO!p7v^n@GzeE&YkIUpmxl-VDrx(L=M~4KzaNxe8hwc>*jVwKwMiH`stfWnM9etD zMx7{jLz7I~LJMa#&j^fBp3mL;zKf+w9xRlfjOW@nOW{#bHn6Rr;GSIssbU|5=)4Iy z$r04uwJM~X*e@D$U68xe=ud(~6qMXuq#Oi)O(afa&L^Oev2bj^NAIpVK(U$;MgLa3 zp3p@-)BWD=#84e|&gOc*aE-zR3qYIGtZM=g#sZZIiCD_bu&o;ZrRfKBy7fO5K`W~ox0aL9zu$H zLUlfZu*DZet=B7*K>J3rpKHu>}ZDi6&Y=Y)5s3n5_jvM)IEL%`tcW)@*CV)#Mz zU}j%cx7069F+@F6PZrhmp-R%q`Q~(iT=NK4!_wmM7Xnt_$tZ%7fL&(EmNjGjxJQ3p=ml>yq8<-s_O5MuBBFR z87{j-WMidsetpT|Oaa9|3f64rG>lt#YBw&XqgbRRYTE4$-wP;fIm_hpyrhGk#@=Vl z$~&5U=RLWU5&Wj18gjB^2i(>$;LOWn4p3}F)Wh(sp~i;@yPIB5>}+AZaz(3)hz{^6 zXSFNoqT|T~e%s=#QpYd*_&y}g%(c>birzqCBb#@;=g50IFY>qIe|^VnZ#1w_IQ<3f z0u%^>G+~!?fA>A)K;ChVcy$EPHb4Zb6xHMsV960Sq?C?Rd?|P52%HtD)AL2gSQ74g zts`Q;3rC><EG1Oq@Z)v;fuxx8wE@?4L;%aIC*DiMv%!%L{P z{zr11gJ~n>t!BmQH!^cd)rGVv*U@b+-ZtM2c`z^Nw*fi=JvYAE@{%VTEn2@?rW#xE z^wA%5?6+}ppf=tvC~VC^(ozlGfeKHlyzqixRnaV=)cq$m*g3=m8B3x|KrATMkfa6s z7vRF2;OT+MK+WW5mO~u|@SYrqaU7awEEZ|Lf?1Hl^OKpp0apR>Y|1q#HDd4>2=@-- zP$@Dw`RCLB#2iKn1A3wZpuu?s>DzeApUb8a@naLmO_HvP!8H+rKJ^~U*ZW1$)r@Ya zeEYsa?8is2bBN2kp>HO%Btf2zw$r{xwOpxT?65~fW z_L+!Qhzl4DKZU;Ei<^ZY`%dYFGvD~T-FWM}?S=d)f=g#V{R>ES8w{*sMgO?NhQ!=?CUoLK`ZD+T7$D;ljg4>kTwd$yQb-~Y-g?_nI-YA{|;F`F`OP+V``M&nP| zvRRL0Nw+Xem^HOpz;DLWFdGkjdneu&*)rDpaT&Zdj9s^$JMGjs0nZ0(0^=}QiOm@F zG4!8}NR~$Wj)l&>yZ62jW}cx|V2%izHD!&klAl{TpJ)zvnTewSZKXC=)n?%!{JieW z_YMqk$FF#fx%AUt5SU2k1)*L=%-SS17Ib6C_lSvGK8XEoQ8M1Kzd592M&pl#{rEn= z&d`)1Q{SaCm@N34&t=M^52raADprjx2oLoS7(I;*K@(=9 z=I^ionUs(^SK+a{h5IWpr0eK*gGU7UxM#0kjEJ>@LrMXsbecu}$U2||CN`XnkC_;o z2Q5W5<2yX-33c z6`iDO6(<5lpTsa|h)uL2+wP}nhZ4nxU!!7nvro2yy%yCULS+FEw`A8)w^%77 zn71))eddH`#h5K8Yccw)o%k=LKgW8uMHkTVj7UcN`2&`JBF@0||+E5kkH@%zmK z@E1piX7H$2vg<}BEPX`*_e|*^;SZyr`{QlYfDA%a+SXyIb#Gi!xf9y3(uS$ z4-5^yVWIl)VnO+C`_-p;e;&|D1PulOe}iLOqVR2Y)*7gVWitStzD}*d4tzfE({WY1 zg(Ba!rs0$qB%!uw(CU2X#&+AoYghfL%#WA?mb%al1PWHG;J8FTHhlou>YGPQ zFcA#ZDwtG>dgq;4bA(NX4{~LSC~BbB`sU{cZFb65bYyzToxr&81FO?yslOpZ{n;R` zzo3ol+T7>7&o?V+9xxB-?&VOnZ>w`X8>H4(XUhGbM>cFN{rZ>yHP;fD_PN177N_t_ z|AD1MkbU>zw`h?YEMWhO$2s3VTI#v^k8bmrjvNRPtCjDbYFp%+vLwi~ZVsLz^?aot zMe7I%DaYg*zj<9~=&b3OHiTZr6$EpTtbPRsfd;D=81lYPU-*<1x5~K#Xzl2kp055l zI^fIH)-;|aOpKY_5qJ;s9J=*>@}!3fBR3jGz4xs(4lt1_e7*7ooMqAp zrXRj41Di$zWY;?=C|LAaj_Qa#hjP#;a|<};(xIY9^Q*BMid#>{Zb+TnW`+Q1 zb2$v=p?1!rH{W6W-ep6SSX?Ks6;Smfg@U~ZuQ7QlF(t5d38C|qbqO@_xSkGxabnRv z>dO9Wzy1W(H!Eu!$ND`L>D!Zj7&g4yP@5o`|1J7d68{#s?Wo}aQO@YN#aTnnc(FHL za)K?(%rH}u3}Ggv1I0k)DbZPE4ibsKz*xbUn6-70MD{rbJb{NeS)HnBXg>7yEAVHrRLgt*l^7g zdDbcVDCgfAK_AdL8)9hmEV#|@G~lQ#ye#qy`wtznQq(fy`SQuwHR(3h8{zqAEp1Kj zJ-~n4g0wx>&z3z`z7}L(>N$0pNlQ!_Sc!l~hJSL%HH15xnw3r#W0Zmk0Kl&hbEfrk znYYWBjxCBEw-ACGON$9$@2Qq+kQYj}FPw9kM)=rzFP*jB4~~6EzIUcGN2UR`aLf>p z|HxU4a;2+jj>2?H^mA?0PL{G_cr|2CzCp&tm@b|)a{!||RESm_(!;xQ=wmt5le-bNrONADpV0zLM$odB?1zB2xZb*GnRV%*C zsSW83+pOH>bZ_nSTgAedEr^~iOka!NkDJ#B$6M^ObzM%d*`Qk-oeDt;fNd)f9d1_6*^qVAYm5{UZ)rZtUBl;mv{n)4EyN?Q--%O=X z$h5P&8vYKJqOxr0@kS%Grhhdm>`@7Cd_v|!7hUls!mU{QN;(wN4JjcI1M`lAuHdQviMK+&YN5G{RMeTk_!HUUZT#j z_e*ZYGZwE>sOb*TwovAFRKeZ15|noL3}^6{C-t@y8=p_dM$ChYgJY}|?9I*bHc7aGz-KYp4h}!7aAyNLAdlbg-q--N1A_%7 zLj1o{D>uh=-}H-lRmB|Ur89`Ds}%x?qWx@xs6$Lzno<=|;ub<^D6ANAl8q!vWF|`a zW1_KPTguga?)W$D<&(d&7%kON`y=2Mpuf8p#YCzyByw!s!E@)z&J3GTvC}TooDz&} zi0=9FREtqmyMIWfeN!UtSHJ^;2<0?@`_FxxZ&H-b0@VChABnvgxj6m%e^2jF-@ z6n4@VO6Fg(hB4{iTHVhBiiw)01IY2AJx$~u?l^Hj+wRQaWZWI7>nD%DLyk`z%J6>< z&j;4sBu}sm(y^`}cllQ}J_R@fm|jsia8>~UDB>}Sa%vt51#K{|dH}?-yND!^M<(

~Wy(3dXlQp4;!5UojPMZ?RgW|H7zs{G8cZ40MlVc?(n6nBty86kl9aZg%DvmkY)==lW!%Jl6G{s z4GyQ_Pp}sjN(h-}?;$0!y*1cVpv7y(0yTE3CyQst&ebN>y}k?YwpkmH&o(>-cuiKu#qxIakC71?}m4_9J! z9YES37l8}7mZ*QE!M~iu86X2ujRv7txc?;fD)(E!%|6X{&82|92$@$rKjb3kQ)jmS z{*(5Q?tF=q?}lK*Vb=K}KzRS3Bc@k}Mb}MgjcG>qAu9e{`Gv(P=?+w ze~GUGkF@l??N8emQaY?;7geIuyuXSjm6(4^U7~}_TPJpD{Ba*V?u04Axo^X##fEpG&se#JHcck$xpLcU0;aL(1KK7!oZTBm97 zonnLw9CmO6koC7j0$mlp&~1CJ&JZdUj0q$3i032=(eC~QdANw56>lF`ZqM-ps46NS zfoB1r-Zy>x_UCKEKafn=3f*7<0u3u^}$q_Qz4csW5$6N>k zml)=N4npDp5WzF|#bzT>=~MNhZrit_+$&LR#UE5Vtgro21%ke7W-bR-Xa{D+o)$hw zeLdeSpJ+OotIApbWh&dv2>p_2UdPCRJ?F(RC-44MPPB$6Mn4^Y=F!LLorjTJ73aR?t|Bm- zr-1I>qcE3ma`TViXdq6!G*L-LAM&M>A#KSe+;V#&T&7^` zokqpGu_HKvR;WLGPqy7RzSH#CC-F+eVio2f2bX<^j8>8%2w4$r^sg@cAH{ulSX5t@ z?Ij3;NRpgN1p$eYlw6>aML?ot1(6_l4A)-5Re=sOU_ABk&B`z z-?gW|p8oZl?l;rZ^X3mes?MpK?>T4hwbx#I^^(AgtaUBPLeHiu!`8DOGai?Dv{7G~ z41=Za`Jx?GqX$#gd>AforJSaZLYcgC8FUNCge6rhOpLJUb4w~M8t?qNgc^I*R#?6z zHqnM)M_}O{h_DnnRBDdgx5J`lAA{o+R*swuwg8X)RZXEK?FtIPo$2DqHIu!p*K!#G zc0-Tw&q(1Gk#j+t=G^C*xW}JJLllB9Unh!VS9veO-e}bfRW!Hyv}xQ}?JVTs|M2Ir z_AL&d$DQoXVnYQxOH6c$8{b>BTlpVFs;r%M(5_D4Y7f}%fAYDrQ>t31?YvB6F{Pr&L`#kf~s7VSjAL(37T2y!)8X-w8GZy;+X>R^hO(hv}^3DxI zc{FUaQImA(HRox4?OA)Es5>Yry?yD%CVWZ)e}C-IMTPw0qLMxva6$S%R&6?s{3~WM z;U7K&T=NtoMO9GkV9365~Uvj-8qq|7JS*fMK?Pq|_sRxpr#;DTD zM@laAv8LRW-B62@fx}H>hGWC=do=g;wfr27>lwqFPfLWL#E_BiBnq)~OvEVmFw)IO zD)em~j@2arQJ?W1-#c=D+zYPx+2-YzQp z&4Xkb1dyhscGsYP*T6S1biwt!w%SlWIFpTC}qP$**)Q|$mb z&xOU|Unh&w`UGeYo?Q6g(tGn!&IF$u;}I?_fmCZLz~Z?2uoWl6&oFyyWqb*{IU%W8CgUe`J2CF(l~X+CWTmk{tgsigS&MCJgI#C=q%M$=At-sCdAYqQ5%8IuxCY(ZB|S&;`d&1mwu~aJ`HH7sPd9}h^uc%I6(hNsY~w=k zok%J%d`BfIUe*%yWZf6CCLpISA^7viL#GO3k27(Lljgijz8+^~%JQ$l$CIHt+_LQ` z&*4u;{>s+a5Qh<7g||0|YOqTAycy#L@$SfoLw}L>%V|~{-vFP1V~zv_2CdcC)c%o! zbx3V>Rwn~%>o7zalEd~3$Xvn`+l9BLm{W3koXiac%I@N6K0xr^ozU%%pS(EetMmnu zAGHJc8x&&ZNwX|vKzWL`d}i$j^|3LB1Ujl~F%L8F)4n>%9WOh+L!rW^8*>izEbr8g{Q+Dxue?cPBhASte+Wkg6|=gLmcr9`JZc9rrH)VB;KoZ*rji7?gAmOe{EwHw|a zCl5pq1^Y=00FM&d$aZg-KKG}GH>)eW#p^0vRz>{dM`ztIAOflO9#hcfSBp(dYgFxq*`B(1psT4O9=q37|Y{BsFV??T3abkL4{()a>LgakL)Em5;p} zU@zsduJq{lx(-?HIOI5bsK%izwA0&Bz{B;ODEIxU>kwOdBKu#4(BA=f{^@?p^kC4! zvVF1kjzJZ#f7bgDz$P>oUb;{v`SkwwMliL%Z^lp{|2+rs`FF{DO+E4;wj-v#I0uCBIhHZ3p5wY`%#fi;xi`-2SyXm$#)}5NvsNx7*FiW7^_@mVv(?fFVXT= zhlho8#=vqhj%-|*u+cikI`dDTlKp6N5AOdx@|Lc*k ze`=dx1!f+=Kp)@W^s=NZ?wjIiWiyg=lO?|*`Wp?03p>1i*HpQ<*x0V~S#5%QHf{X# zRjZ%=^3@5Y-d`%KV17Z<87Wqs;M$HocSuaX%beE`pKh@B1S$fiWgp|=>Hw=bacfRLb?8r=bNZkn!rvtkAT8u?Dv3iBpr23EuB)Q%KYx>=k?#l%9vqp49@}b{y0PT2Uu2nA)uF;U2kQ4Q5cGMT^&R6SFzXnCJ+4ryft6m8oVu z=P9Ef+`u#6C)5}QXL4uv;(W(>lxE!he7R6Hh0C$L3yN&2pR$ldkskl~nToVm?@vg# zfnYI3R$qLL%H|9L$v&{5K-20Zuw5h0b4orKHpv=5C-CMuYdsZm_ejEia>yj<_HjI2 zbbNf;yr+Qg*mi941?1({PH1#&hN1%9iz+*t>n1gPtZ$&N&r&8(DAhnzHG)|Z@ZiCY zj2)jaKDy|bX=5W7rdBepfZl>Vjf#pZbyZ<+@s~SaC#*p%@IKN1JksdZ?pM-tbW{?} zha^V=BmfaWZv6jgIQ@Cp=4#3s48FH<{&W*{hi0;ESk;+y(=@iB&jhh^-i*#P zmP%KF;V6TeG@?09@nx1!ud~)Tj=#@ifLilY*RhG_BE`vkqfCY(UQ>7E$hX1Xb-(j^ z5T(Ax6Kz*JN)iYhxagW3E4tv^J99wW=5XlpP9MS62px3dEAQ z%6a6grGGqVPD-JdPu&kldrp6SyEO)4evqnfxQz@RLa^A{kXb$;{<7|SslsUeo)sZm2xl-po7u{y^*N5w%eRd96~ zUHNA+OV>y6nhH!{7{brvoR`6I8lPBIgQ65o$`1OC6dx5Zw48_SRvsPc(-DLpGsuzQ z^3N_&W{;ix5N=uv!X?H1m|Z(p!-WEtsoYy5{$W}=CUX-fr;AjBw$h$izW{j*jv)|) z?aRAjGZVJlVB~XWB-v8?aHagop*=+_YiB904h5zrtK`WPM;l11iM(E3h^-rbQ_ot+ zCxmq3H66iQL2j^2ytLPkJUp00rEBlLkNDGtWy=6xUn4N|z_V1T4m6@a=(!Z0=5*{m z4IIRw{i)D76A&AhBW5H;ZuJK(Y$ZmQ;t^&3T;dBKd+#e$Z8Vzusb@Xl56OX!%<8`K zm!4SxR|k)EK%!;>2#-hdTWx`{8QJ8+tgU?STY0OzL>!T$w{=jsa*MCwnC~wbt9a0> zfui}v7G)GJm*8{a3pS*DT~GuyP-XUCQVkeH(qOn33aYfTylNywCHJZx+Uk zuDf5p4ym`vJ73fiCure%@neEx(VGcWHRi~33)6W1bw?TWA$mQ>zW)LWP!Z4>1wM6P z)lv4wwQHr^N|2d{T5BerDP}QA6x5enF{ud#>;KRJ_ENP{_cLEF;#}@c!o)d{@c80MY~s6k$xrXeDwW>i}vzHRvbj@(GnHyn|#nVfPQU zvo&WhRH_?!8|Q2wpAk`#L&K>QD?tv5B(f1G&~LeKYDz21oEca$$p1UFU;ko8 z{fqmR9X~x3(@+G54!gNt*sR0msd_6Rqt!S7Bi{q%#)BR0_i`@0AzozaI?KXo`fh*A zsUH1OgSu4|{~(8*$+;UAx%Bc2XRfW6@{;PHF6RZdLiL`*nK)je-mR)R8q?+|2S?Gn zYBJp~NF@h)VaJ7o7=w*Y-=0@9b8gAd9M3x%pB}im9?TzG+A|$)7Q}J}Kj%5iw)#D|LXt0GH83 zX|G@wv0KY(#<7DNC~dPI5Xt|20o{+F4Rq{@?>CDmblEU|@mS*d_J{M>C&+;24Uh+EGkT1wYxzn1N>(Xz2e7xOjglHxF`-g)TjSUoc11!ihlXZqOqDoSV?m$W8Z-^|!4H z1!DWtxpLpf9eG|!yn@y_cwwa=y?W1EQcl<7;ByNKY!*09x3#FZU_4V3e6qTTQ(pxB zRAPdPY;z6&`1gHTP?zN?fe-LPchN{nVLVNjTZm`INmmy5NyQ|;iQ1t?i^M_Hb$?3f z$Q2$}pxcZZz}G(th0Z8DtwjR!1L4!~o@sb+3UmPi#x{lH5g0PCz;&}V&Fath?8o0i zuqki7$%pa5V&JhZDR8))zXAi;2+(rhO;B|QgT#m6!Ac)!5!xwG-CkVj-2F_oVZe#D zc@=0wW)!;$78wG3?APP1QWG+)^I{k%+yq|5Ht`ZRJdu_7*b!3)yE4r62L9o3l*aIr zuaPYmI2)Ta0UudoiAEB^iGyJQc#cIi`lxs99P*Gy)>6C#-wEUnF`N7eIZqF@f8losWeYLz)%GwrGcCjHSkqThTn?aXTv9EvFY5ce-c9t z;|qHDubHUiUsMO_YmRnj+QFR;ypk2U6De#n>NL)9JDb^AWc9YZ(KtIP z(YnavNoe@d0KIuz((y3Uhd{e-2;U91E9q&JtwRlU?D8G0lhhRz(Oe9JY0uwu)XHb4 z01Gdbxz1#5sd*Pv>?hFekwSTlx_LWKWQlN&`c)tJ93LH_Xc=X)ofOSRsFfUPzY-GP zX9Bc|*8e*`&p+BWw@bi9=+yeY>5@P*1z!B;ACauzTV~~Pr-HS!KQ6|bY27Wlc+N{O z@>j$bcG!Ou%l_Ng_W$7M-jPFdB}?J2IjVOjFc!~2Qi4s@w9V2RTT z*MRjB*2M?KLgfxGfKx$?e9k)%zkppXvSGD-`m%tf02|0lX#6*;xu~B(Z#wJ9W22B| zn)n^1Cpb~7^Hf;v>HQ_5vQr1EcGn_#=0*Xs*?-ht}7xh{2MCKKPr1K zYrXBp`OoZE*2>6At958GU^{3UJx5#hN_W|4rdx68njXzjpY@EN`p8PR(GNvR7z4L5 z?lY3=e&&}6S@@#HV}dU=K|y%tYd23>rphpgoQIzF48z7?fzw^a!?i;sWnE<0(Eb?y zLT$z8SliUBYvAm*sW8`3VkPQ{c#! zj$D$3F8Ukl>^;Ex_r7+Fc*{pjWHa1OWl`c#1@ZQG=<~Y<#Oc1NKL_@y;4Zbi` zk>baV-F#E!eZIJlR|-YeTI3%m+0 zjt-{0xL7jIiJm;^8_KKTSw*EfL$=BW4oK?$gR%QhzOMh*A4!8iM}}CZ&C3S^six$ybhzRpW+9Vc9L^0M&uB`yTQw zfztN;{qCi&jnllkJ)XXq>~x;uB)2-@K*9_&Rj#W?3A|)yBQYSo3f>wI`pYvb#nG4D zd|cQ>gbmz+Og&5YuJd+9Dc2mx_zXKY1s_3U0MA3i_ex6$y2&2;ZyQt;;m#_ru6pc- zAjTR<)ka?&rE)b^_!T;L2khVRkUu1FjqKYaL2(4-rEUn$J~dU&&tv8pIc75ykmpIm zP2OP!d3L`5P}XUBZw?p52;J=fFNM4iy@Xew0&8js<3gW$M|CiyjKvFmpntU|8~Skd z*;UK6>U#@trb%0AH>1Ai;=Rx8?r;LP6qYh_0GK{~2USFhr-GyCI+agAg00sJ7atZ~EI*_@rnXJB(Q)6be(_mC9709p)|m*}zW!wR**5@;xV=1Be=gjYX$i|LG8ONSK; zl-;{FQycc|ZTga&d~OwvbRkvB(a@yvmHO6Nr@`4LQr7b6HOZ32z*~a2%L|3hwdHn^ zX{(RP2F9!HCpTb@afCxHvjSd5&Cn0V^Arhz{K4b+tJD4;i^Zu^>9!BLcZA>*9O;Jy z(*uqdrfvA&(ON$VE~yW~ZKjWWA;>fj^VNIP|433KEf+om9r{c%QayF9a898P^29wZo;U@`sGEGfa5W50N()UReTx!LRBQ!&|)L1lF9@XD40l|n?p-%j3pId#VaJ3bBtY0|aI8n0A18n(GPE;&%u zfn2=6PXWNxorvtzmnw<;1)RC6;Fv!YRByTbsNLaG(EfuQwGKXCnjI=}0+p7!ALHWh zc8p3h7STj}4(hFV;|T z&$uEI8(*=c#3#ev_TM1{JvcmzOi8g9wN-WTzDfbbEx7Ta@qqEhBG50%l!uvKi|Ybwe9CI?=2O0)ayta*);)AcSBY% zWkL+hLPMI3pC5zSExp6PfOSR>5Zx*UJoWzN-5t1=ou=JtBxQHx3MVH1V+GNThH1y? z7oHbO*tE6^Im7T0s`Ejsrr-m;&=a)IajMhl zX*eEF4!nu9S7XcgxeN-489`Pa{!n>*NW0$t&4yu=0_wH!$E06_{%?^L|MAedOp-qeo37;%8)jtmVrj?`(dROK5J zk_wy%LW35aP7gt^t=SVCbkIL+(g2}*8YsJjXCRGeS{nkz0SKmWwtu_Hp`T79aPY^y zXZ>LlhrvB%aNzuL-zj+cJ(&W1RHz!;E!0z$HS>f>Q*F|e+YL*bp){FhF>RJPB|~sF zT@+4rM?$#EVaOF`jd#}uiz#8^HwpqYl);33j+Er(>J9OKZ`5RQYU3ZbV=o}OXggwy>NVtG3}kK+kz&TP2NgJMCoS6eMJo#DMEXg(TJi7m z{(J=U1SP-+eExqrOx0gCIyZ9E@0E?sqedCFrfMEEx?9k;$C!Cn>q<&$Zbjy3aS1(195*FVCG zpFv1$VV)m3vK;6#Rcn>(z|`w8lr54<922WLo_TeNp$pe1^HrW9#7NX0mcm#QQZjKinXmM0JJne~$gU{x(|V;Swq)wEPAL3}(5 znmS_%qB}ktc+*d@;mwl88K=y}VjVqUZ8ByajxYE`H^o{s$p@Amu`a>xwD&?+c`}am ztjSliYNbnxUtm*{X=QcP+vXFQOcOR^k7k=%V}Npg!O386LIsyokleipW$@P3EAzcc z>zfeW-o-H!^CK9hPZj2>b)Mx#Tj`?=^fsVgGC|F&8%SH{C|jnoO(N=?_vfj4=o_Bp zP1u#4r?*c^a`t;7fBI_`1M;hrv-_kq^+7ix6~k1RNh-E+kcE1e>m|vo5z18WG_0DK zWdrCdl>isKFnT9EZ=I3Rz}Wg$Z&9H18mEBRY#|16ckoD#c}MsVlNa~!Cv#;QAX%Kj z4sv_T-S9X#miki@C43oNzK-_6Hth?iUziu(XNmj*o~dxaBX5Hm41F%<+~}N+uo($K z`Gv{30Q!UQh!r-cTMp6a z;|8w<){UyZN3&X2QM;TF*lqkiHUvBF`*E|z_&m#OB7%-{%flr&k9k`kr?!v^rB=98 z7Pcy~uNfOM*r2kHylmp|PWY-us-1yRHZzzPs^rGOhjv_T%D=J2xR?35#mDvLHJ53t zvDyJ*bMwcT81WxFC`C?b)lU;DS0V2L%p8XEiMsq07c`nEzfqv>Xm|5GMPB7(V%~l9 z*xT)!h_O<^##1dJ>jS#4w^=!s#_Avuy-m-ChdI68t#SNd8^5>Ubjm2ci=D3`5DNAX z%bsGle7ftjD;9mUB)J=HQ)>}1e+qN?+P)!m(p<7yS$)pI?xp#r-IU~PPzq1VPIZW7 z0m56{CI=R#uGltzy3c7TBYD=0hxS)*_K3^bpM9N78-u-~S`i`iuI!Zi;1AC)RUY>E zAZCc@9o`vwB){+e3>(r1Jj3kcO(0aAWp9dIHwm6hXz?5}6*Yy!*e=<7)W1&5@!}_V zazf|9Ih~Fltz2T~Sb98|a!Iky)>J(rqbYjX-`OE__>1l$J8fUp=cnY4RUegT{{=y8Q-g!11v%5*LZ;^S3m^H3jU)~v78q9?3lx#zcnJsEiXSqXn29p$zvlM2owgJ$d?Znj zYw_4yrKLtIKbeYGh(`JG`^FamMn%P93t!4-xl(erL>2PfxE}YM(Za}oRu>a#jTbM~ ze4#A7@-ik%lD}O9L|mg5}oc`E91Y3Ez2@_oeAG%4CjD>As3%dJz*zLNau7Pf@=vzd?(FV%(^UnvxCnPuvxModwe_ZEJOY`UhWSXVJ z-scSXy5-MtF~{f-K^pqC_UVWF_J~@Y<0}^J+|E0-AnE+c=#G%9<5kB^DR|Y_Z`^3ynik%6x-0W)%O_fIlG~qz!`fcJ!*;6&XgX=vVu@ z{sK&lF7JQ5-L(>?*RFD=G9G#8GGBT*r@lD_7UuDcR*d1b!)}SdJK1$iPss1$>tKDj81605tSeTA_y*Q0MUW>UAUq0&`y$M8*|N z;N!n7kyrQr=Q>ILd`wC|3{@*BbX^F#T?vl?&nVOl`TYFZ+W9$T!3>Ptv_o6@35!>L zqOLWC^JY^Og9d%5C461UX+jrO{Q|J$pi<1-C6)g|r*rrM2%#W5ahBj?8nr_A zJlf6&<%0yxBS5nTWH1P7;e&5yob3!ZY12hk+55lbn})ng)W`bIQ{Ipi@o-S!JhrTPqIDL zy9rRB!8gsTaO7Xj!~4(g?=kiqS7#IB)ydDL>O3UhSHdxqhzqT$brk`iBS@noY%5%{U1FEx8o(|y-^D*{OKM)3OUo(2Y3 z{~`l}@~uev$*j`N>F>mcV06I}v%roM^D0-H)QOb=TDi+)uPmBD)P}69M})q-C+?kp z!Q*_@y<@1p;A7+}BPw0ujTBvBq5IV%`W|SjBTZUj-4_Kda^{C}b=ns+brd-@fB|I_WW3-3hdJ)p|2^?ucm)H?lrfI6LC+s3ppFi3!=0bEp6Z z9H<+?V25X@%}^SwvYsN7neqWTRTfn@X3I*d=jgrjt3T9r50){9P2BnnYFM4%G`+kU zX}(uqIw&wRu2gRm3tZ+0YrmNDW`+`Lx0h!|U(g1K)_I9i(TG=8gIBguAI*>2C8cPH zet`TRGYL8{Np%nU9vs6bT==JTT4eTP*pe6;R7BPpuMx-yxErf1!?GsN6SBI|uhLVa zm_E^2yi2?huKE3`=uKMF47nh-=<~r-s{`a^EclvJG~Db8o5$5NZqDp?HzD912PLf< zdj_FJ)Ny8?)Rp~e!`g9TUvYf>1t0B_<5YB%d|nUv`DDMbJbO9Xfh|CVV4h?3@*ZI#W@UD9Bp@$|Bg7y6Ja;O! z@a$FCRfXxId<($sx{|?(G{%0HZkEXWkqmt zUdS0t(5$PPY7-;9{9}h*3=>fOWB7U)6QebCwnN$i9q+&bi0hR387?I5U9PfWrsdD4 z>fBDFm(yE1DE`Sy^tJ}$l@-Ob_(fEr(^R%xO4~$E0h*{N+$txRdduF6w=p_$u9SX4 zM}f^9N^!XI^!Z4MHPbxbrvvQhI*wqWikyXPnLj$|9d6F_cQEwcg%3H!pQ}(cv64V@ zjGiN|e#~+Am7kp7HrJ#pXKj~l#67Hw3HWw^OYKF{u% ziK<>pOk57TW81G?5v+IiJV*Y|EWI3%0rB=9PVGcDg2|d6FQ@@h1S8Z`rQ-h}ALwsU MmH& Date: Sat, 20 Jan 2024 17:40:02 +0100 Subject: [PATCH 07/21] feat(website): add offline banner setting --- .../v1/upload/image_upload/offline_banner.rs | 2 +- .../components/channel/offline-banner.svelte | 2 +- .../src/components/responsive-image.svelte | 31 ++-- .../settings/file-upload-button.svelte | 132 ++++++++++++++++ .../components/user/profile-picture.svelte | 2 +- platform/website/src/lib/auth.ts | 13 ++ .../(app)/settings/profile/+page.svelte | 149 +++++------------- 7 files changed, 204 insertions(+), 127 deletions(-) create mode 100644 platform/website/src/components/settings/file-upload-button.svelte diff --git a/platform/api/src/api/v1/upload/image_upload/offline_banner.rs b/platform/api/src/api/v1/upload/image_upload/offline_banner.rs index 6c81cde1..0d2029a6 100644 --- a/platform/api/src/api/v1/upload/image_upload/offline_banner.rs +++ b/platform/api/src/api/v1/upload/image_upload/offline_banner.rs @@ -70,7 +70,7 @@ impl ImageUploadRequest for OfflineBanner { async fn process(&self, auth: &AuthData, tx: &Transaction<'_>, file_id: ulid::Ulid) -> Result<(), RouteError> { if self.set_active { - common::database::query("UPDATE users SET channel_offline_banner_id = $1 WHERE id = $2") + common::database::query("UPDATE users SET channel_pending_offline_banner_id = $1 WHERE id = $2") .bind(file_id) .bind(auth.session.user_id) .build() diff --git a/platform/website/src/components/channel/offline-banner.svelte b/platform/website/src/components/channel/offline-banner.svelte index cfec5331..eacda0e3 100644 --- a/platform/website/src/components/channel/offline-banner.svelte +++ b/platform/website/src/components/channel/offline-banner.svelte @@ -61,7 +61,7 @@

{#if offlineBanner} - + {/if}
diff --git a/platform/website/src/components/responsive-image.svelte b/platform/website/src/components/responsive-image.svelte index c3872d4e..f28a6576 100644 --- a/platform/website/src/components/responsive-image.svelte +++ b/platform/website/src/components/responsive-image.svelte @@ -1,9 +1,11 @@ {#if preparedVariants && preparedVariants.bestSupported} @@ -115,17 +117,21 @@ {/each} {alt} @@ -153,10 +159,7 @@ bottom: 0; right: 0; - width: 100%; - height: 100%; z-index: -1; - object-fit: cover; } } diff --git a/platform/website/src/components/settings/file-upload-button.svelte b/platform/website/src/components/settings/file-upload-button.svelte new file mode 100644 index 00000000..de4d3d18 --- /dev/null +++ b/platform/website/src/components/settings/file-upload-button.svelte @@ -0,0 +1,132 @@ + + +{#if fileError} + (fileError = null)} + /> +{/if} + + + + + + + + + diff --git a/platform/website/src/components/user/profile-picture.svelte b/platform/website/src/components/user/profile-picture.svelte index 15242101..22c996c9 100644 --- a/platform/website/src/components/user/profile-picture.svelte +++ b/platform/website/src/components/user/profile-picture.svelte @@ -63,7 +63,7 @@ {#if profilePicture} - + {:else} {/if} diff --git a/platform/website/src/lib/auth.ts b/platform/website/src/lib/auth.ts index b488a333..ea897f16 100644 --- a/platform/website/src/lib/auth.ts +++ b/platform/website/src/lib/auth.ts @@ -38,6 +38,19 @@ export function getUser(client: Client) { lastLoginAt channel { id + pendingOfflineBannerId + offlineBanner { + id + variants { + width + height + scale + url + format + byteSize + } + endpoint + } live { roomId } diff --git a/platform/website/src/routes/(app)/settings/profile/+page.svelte b/platform/website/src/routes/(app)/settings/profile/+page.svelte index 24e6a25c..113dc33c 100644 --- a/platform/website/src/routes/(app)/settings/profile/+page.svelte +++ b/platform/website/src/routes/(app)/settings/profile/+page.svelte @@ -1,7 +1,7 @@ {#if $user} - {#if fileError} - (fileError = null)} - /> - {/if}
- -
{#if $user.pendingProfilePictureId} -
+
{:else} @@ -258,27 +173,36 @@ /> {/if}
- + (status = Status.Saving)}>Upload Picture - +
+
+
+
+
+ {#if $user.channel.pendingOfflineBannerId} +
+ +
+ {:else} + {#if $user.channel.offlineBanner} + + {:else} + Not Set + {/if} + {/if} +
+ (status = Status.Saving)}>Upload Picture
--> - + + What is Scuffle? From 2a3e2c726f4f070835ef2647da89afcd9450d113 Mon Sep 17 00:00:00 2001 From: Lennart Kloock Date: Sat, 20 Jan 2024 20:51:08 +0100 Subject: [PATCH 10/21] chore(platform): fmt --- .../src/api/v1/gql/subscription/channel.rs | 6 +- .../api/src/api/v1/upload/image_upload.rs | 88 ++++++----- .../v1/upload/image_upload/offline_banner.rs | 141 +++++++++-------- .../v1/upload/image_upload/profile_picture.rs | 148 ++++++++++-------- platform/api/src/config.rs | 2 +- .../components/channel/offline-banner.svelte | 28 ++-- .../src/components/responsive-image.svelte | 102 ++++++------ .../settings/file-upload-button.svelte | 90 +++++------ .../(app)/settings/profile/+page.svelte | 21 ++- 9 files changed, 335 insertions(+), 291 deletions(-) diff --git a/platform/api/src/api/v1/gql/subscription/channel.rs b/platform/api/src/api/v1/gql/subscription/channel.rs index 2826e172..735dc147 100644 --- a/platform/api/src/api/v1/gql/subscription/channel.rs +++ b/platform/api/src/api/v1/gql/subscription/channel.rs @@ -42,7 +42,11 @@ struct ChannelOfflineBannerStream { #[Subscription] impl ChannelSubscription { - async fn channel_offline_banner<'ctx>(&self, ctx: &'ctx Context<'ctx>, channel_id: GqlUlid) -> Result>> + 'ctx> { + async fn channel_offline_banner<'ctx>( + &self, + ctx: &'ctx Context<'ctx>, + channel_id: GqlUlid, + ) -> Result>> + 'ctx> { let global = ctx.get_global::(); let Some(offline_banner_id) = global diff --git a/platform/api/src/api/v1/upload/image_upload.rs b/platform/api/src/api/v1/upload/image_upload.rs index 2bf7d55e..e395eed6 100644 --- a/platform/api/src/api/v1/upload/image_upload.rs +++ b/platform/api/src/api/v1/upload/image_upload.rs @@ -2,24 +2,26 @@ use std::sync::Arc; use aws_sdk_s3::types::ObjectCannedAcl; use bytes::Bytes; -use common::{database::deadpool_postgres::Transaction, http::{RouteError, ext::ResultExt}, s3::PutObjectOptions, make_response}; -use pb::scuffle::platform::internal::{image_processor, types::{UploadedFileMetadata, uploaded_file_metadata}}; +use common::database::deadpool_postgres::Transaction; +use common::http::ext::ResultExt; +use common::http::RouteError; +use common::make_response; +use common::s3::PutObjectOptions; +use hyper::{Response, StatusCode}; +use pb::scuffle::platform::internal::image_processor; +use pb::scuffle::platform::internal::types::{uploaded_file_metadata, UploadedFileMetadata}; use serde_json::json; use ulid::Ulid; -use hyper::{Response, StatusCode}; - -use crate::{global::ApiGlobal, api::{auth::AuthData, Body, error::ApiError}, database::FileType}; use super::UploadType; use crate::api::auth::AuthData; use crate::api::error::ApiError; use crate::api::Body; -use crate::config::{ApiConfig, ImageUploaderConfig}; -use crate::database::{FileType, RolePermission, UploadedFileStatus}; +use crate::database::{FileType, UploadedFileStatus}; use crate::global::ApiGlobal; -pub(crate) mod profile_picture; pub(crate) mod offline_banner; +pub(crate) mod profile_picture; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub(super) enum AcceptedFormats { @@ -105,52 +107,52 @@ impl AcceptedFormats { } pub(super) trait ImageUploadRequest { - fn create_task(global: &Arc, auth: &AuthData, format: AcceptedFormats, file_id: Ulid, owner_id: Ulid) -> image_processor::Task; + fn create_task( + global: &Arc, + auth: &AuthData, + format: AcceptedFormats, + file_id: Ulid, + owner_id: Ulid, + ) -> image_processor::Task; - fn task_priority(global: &Arc) -> i64; + fn task_priority(global: &Arc) -> i64; - fn get_max_size(global: &Arc) -> usize; + fn get_max_size(global: &Arc) -> usize; - fn validate_permissions(auth: &AuthData) -> bool; + fn validate_permissions(auth: &AuthData) -> bool; - fn file_type(global: &Arc) -> FileType; + fn file_type(global: &Arc) -> FileType; - async fn process(&self, auth: &AuthData, tx: &Transaction, file_id: Ulid) -> Result<(), RouteError>; + async fn process(&self, auth: &AuthData, tx: &Transaction, file_id: Ulid) -> Result<(), RouteError>; } impl UploadType for T { - fn validate_format(_global: &Arc, _auth: &AuthData, content_type: &str) -> bool { - AcceptedFormats::from_content_type(content_type).is_some() - } - - fn validate_permissions(&self, auth: &AuthData) -> bool { - T::validate_permissions(auth) - } - - fn get_max_size(global: &Arc) -> usize { - T::get_max_size(global) - } - - async fn handle( - self, - global: &Arc, - auth: AuthData, - name: Option, - file: Bytes, - content_type: &str, - ) -> Result, RouteError> { - let image_format = AcceptedFormats::from_content_type(content_type) + fn validate_format(_global: &Arc, _auth: &AuthData, content_type: &str) -> bool { + AcceptedFormats::from_content_type(content_type).is_some() + } + + fn validate_permissions(&self, auth: &AuthData) -> bool { + T::validate_permissions(auth) + } + + fn get_max_size(global: &Arc) -> usize { + T::get_max_size(global) + } + + async fn handle( + self, + global: &Arc, + auth: AuthData, + name: Option, + file: Bytes, + content_type: &str, + ) -> Result, RouteError> { + let image_format = AcceptedFormats::from_content_type(content_type) .ok_or((StatusCode::BAD_REQUEST, "invalid content-type header"))?; let file_id = Ulid::new(); - let task = T::create_task( - global, - &auth, - image_format, - file_id, - auth.session.user_id, - ); + let task = T::create_task(global, &auth, image_format, file_id, auth.session.user_id); let input_path = task.input_path.clone(); @@ -225,5 +227,5 @@ impl UploadType f "file_id": file_id.to_string(), }) )) - } + } } diff --git a/platform/api/src/api/v1/upload/image_upload/offline_banner.rs b/platform/api/src/api/v1/upload/image_upload/offline_banner.rs index 0d2029a6..2fcb9a01 100644 --- a/platform/api/src/api/v1/upload/image_upload/offline_banner.rs +++ b/platform/api/src/api/v1/upload/image_upload/offline_banner.rs @@ -1,83 +1,94 @@ use std::sync::Arc; -use common::{http::{RouteError, ext::ResultExt}, database::deadpool_postgres::Transaction}; -use pb::scuffle::platform::internal::{image_processor, types::ImageFormat}; +use common::database::deadpool_postgres::Transaction; +use common::http::ext::ResultExt; +use common::http::RouteError; use hyper::StatusCode; +use pb::scuffle::platform::internal::image_processor; +use pb::scuffle::platform::internal::types::ImageFormat; -use crate::{api::{auth::AuthData, error::ApiError}, database::{FileType, RolePermission}, global::ApiGlobal, config::{ImageUploaderConfig, ApiConfig}}; - -use super::{ImageUploadRequest, AcceptedFormats}; - +use super::{AcceptedFormats, ImageUploadRequest}; +use crate::api::auth::AuthData; +use crate::api::error::ApiError; +use crate::config::{ApiConfig, ImageUploaderConfig}; +use crate::database::{FileType, RolePermission}; +use crate::global::ApiGlobal; #[derive(Default, serde::Deserialize)] #[serde(default)] pub(crate) struct OfflineBanner { - set_active: bool, + set_active: bool, } impl ImageUploadRequest for OfflineBanner { - fn create_task(global: &Arc, auth: &AuthData, format: AcceptedFormats, file_id: ulid::Ulid, owner_id: ulid::Ulid) -> image_processor::Task { - let config = global.config::(); + fn create_task( + global: &Arc, + auth: &AuthData, + format: AcceptedFormats, + file_id: ulid::Ulid, + owner_id: ulid::Ulid, + ) -> image_processor::Task { + let config = global.config::(); - image_processor::Task { - input_path: format!( - "{}/offliner_banners/{}/source.{}", - auth.session.user_id, - file_id, - format.ext() - ), - base_height: 128, // 128, 256, 384, 512 - base_width: 128, // 128, 256, 384, 512 - formats: vec![ - ImageFormat::PngStatic as i32, - ImageFormat::AvifStatic as i32, - ImageFormat::WebpStatic as i32, - ImageFormat::Gif as i32, - ImageFormat::Webp as i32, - ImageFormat::Avif as i32, - ], - callback_subject: format!("{}.{}", config.callback_subject, config.offline_banner_suffix), - limits: Some(image_processor::task::Limits { - max_input_duration_ms: 10 * 1000, // 10 seconds - max_input_frame_count: 300, - max_input_height: 1000, - max_input_width: 2000, - max_processing_time_ms: 60 * 1000, // 60 seconds - }), - resize_algorithm: image_processor::task::ResizeAlgorithm::Lanczos3 as i32, - upscale: true, // For profile pictures we want to have a consistent size - scales: vec![1, 2, 3, 4], - resize_method: image_processor::task::ResizeMethod::PadCenter as i32, - output_prefix: format!("{owner_id}/{file_id}"), - } - } + image_processor::Task { + input_path: format!( + "{}/offliner_banners/{}/source.{}", + auth.session.user_id, + file_id, + format.ext() + ), + base_height: 128, // 128, 256, 384, 512 + base_width: 128, // 128, 256, 384, 512 + formats: vec![ + ImageFormat::PngStatic as i32, + ImageFormat::AvifStatic as i32, + ImageFormat::WebpStatic as i32, + ImageFormat::Gif as i32, + ImageFormat::Webp as i32, + ImageFormat::Avif as i32, + ], + callback_subject: format!("{}.{}", config.callback_subject, config.offline_banner_suffix), + limits: Some(image_processor::task::Limits { + max_input_duration_ms: 10 * 1000, // 10 seconds + max_input_frame_count: 300, + max_input_height: 1000, + max_input_width: 2000, + max_processing_time_ms: 60 * 1000, // 60 seconds + }), + resize_algorithm: image_processor::task::ResizeAlgorithm::Lanczos3 as i32, + upscale: true, // For profile pictures we want to have a consistent size + scales: vec![1, 2, 3, 4], + resize_method: image_processor::task::ResizeMethod::PadCenter as i32, + output_prefix: format!("{owner_id}/{file_id}"), + } + } - fn task_priority(global: &Arc) -> i64 { - global.config::().offline_banner_task_priority - } + fn task_priority(global: &Arc) -> i64 { + global.config::().offline_banner_task_priority + } - fn get_max_size(global: &Arc) -> usize { - global.config::().max_offline_banner_size - } + fn get_max_size(global: &Arc) -> usize { + global.config::().max_offline_banner_size + } - fn validate_permissions(auth: &AuthData) -> bool { - auth.user_permissions.has_permission(RolePermission::UploadOfflineBanner) - } + fn validate_permissions(auth: &AuthData) -> bool { + auth.user_permissions.has_permission(RolePermission::UploadOfflineBanner) + } - fn file_type(_global: &Arc) -> FileType { - FileType::OfflineBanner - } + fn file_type(_global: &Arc) -> FileType { + FileType::OfflineBanner + } - async fn process(&self, auth: &AuthData, tx: &Transaction<'_>, file_id: ulid::Ulid) -> Result<(), RouteError> { - if self.set_active { - common::database::query("UPDATE users SET channel_pending_offline_banner_id = $1 WHERE id = $2") - .bind(file_id) - .bind(auth.session.user_id) - .build() - .execute(&tx) - .await - .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to update user"))?; - } - Ok(()) - } + async fn process(&self, auth: &AuthData, tx: &Transaction<'_>, file_id: ulid::Ulid) -> Result<(), RouteError> { + if self.set_active { + common::database::query("UPDATE users SET channel_pending_offline_banner_id = $1 WHERE id = $2") + .bind(file_id) + .bind(auth.session.user_id) + .build() + .execute(&tx) + .await + .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to update user"))?; + } + Ok(()) + } } diff --git a/platform/api/src/api/v1/upload/image_upload/profile_picture.rs b/platform/api/src/api/v1/upload/image_upload/profile_picture.rs index 84b384ba..fd0b1662 100644 --- a/platform/api/src/api/v1/upload/image_upload/profile_picture.rs +++ b/platform/api/src/api/v1/upload/image_upload/profile_picture.rs @@ -1,83 +1,95 @@ use std::sync::Arc; -use common::{database::deadpool_postgres::Transaction, http::{ext::ResultExt, RouteError}}; -use pb::scuffle::platform::internal::{image_processor, types::ImageFormat}; -use ulid::Ulid; +use common::database::deadpool_postgres::Transaction; +use common::http::ext::ResultExt; +use common::http::RouteError; use hyper::StatusCode; +use pb::scuffle::platform::internal::image_processor; +use pb::scuffle::platform::internal::types::ImageFormat; +use ulid::Ulid; -use crate::{global::ApiGlobal, config::{ImageUploaderConfig, ApiConfig}, database::{FileType, RolePermission}, api::{auth::AuthData, error::ApiError}}; - -use super::{ImageUploadRequest, AcceptedFormats}; +use super::{AcceptedFormats, ImageUploadRequest}; +use crate::api::auth::AuthData; +use crate::api::error::ApiError; +use crate::config::{ApiConfig, ImageUploaderConfig}; +use crate::database::{FileType, RolePermission}; +use crate::global::ApiGlobal; #[derive(Default, serde::Deserialize)] #[serde(default)] pub struct ProfilePicture { - set_active: bool, + set_active: bool, } impl ImageUploadRequest for ProfilePicture { - fn create_task(global: &Arc, auth: &AuthData, format: AcceptedFormats, file_id: Ulid, owner_id: Ulid) -> image_processor::Task { - let config = global.config::(); + fn create_task( + global: &Arc, + auth: &AuthData, + format: AcceptedFormats, + file_id: Ulid, + owner_id: Ulid, + ) -> image_processor::Task { + let config = global.config::(); + + image_processor::Task { + input_path: format!( + "{}/profile_pictures/{}/source.{}", + auth.session.user_id, + file_id, + format.ext() + ), + base_height: 128, // 128, 256, 384, 512 + base_width: 128, // 128, 256, 384, 512 + formats: vec![ + ImageFormat::PngStatic as i32, + ImageFormat::AvifStatic as i32, + ImageFormat::WebpStatic as i32, + ImageFormat::Gif as i32, + ImageFormat::Webp as i32, + ImageFormat::Avif as i32, + ], + callback_subject: format!("{}.{}", config.callback_subject, config.profile_picture_suffix), + limits: Some(image_processor::task::Limits { + max_input_duration_ms: 10 * 1000, // 10 seconds + max_input_frame_count: 300, + max_input_height: 1000, + max_input_width: 1000, + max_processing_time_ms: 60 * 1000, // 60 seconds + }), + resize_algorithm: image_processor::task::ResizeAlgorithm::Lanczos3 as i32, + upscale: true, // For profile pictures we want to have a consistent size + scales: vec![1, 2, 3, 4], + resize_method: image_processor::task::ResizeMethod::PadCenter as i32, + output_prefix: format!("{owner_id}/{file_id}"), + } + } + + fn task_priority(global: &std::sync::Arc) -> i64 { + global.config::().profile_picture_task_priority + } + + fn get_max_size(global: &Arc) -> usize { + global.config::().max_profile_picture_size + } - image_processor::Task { - input_path: format!( - "{}/profile_pictures/{}/source.{}", - auth.session.user_id, - file_id, - format.ext() - ), - base_height: 128, // 128, 256, 384, 512 - base_width: 128, // 128, 256, 384, 512 - formats: vec![ - ImageFormat::PngStatic as i32, - ImageFormat::AvifStatic as i32, - ImageFormat::WebpStatic as i32, - ImageFormat::Gif as i32, - ImageFormat::Webp as i32, - ImageFormat::Avif as i32, - ], - callback_subject: format!("{}.{}", config.callback_subject, config.profile_picture_suffix), - limits: Some(image_processor::task::Limits { - max_input_duration_ms: 10 * 1000, // 10 seconds - max_input_frame_count: 300, - max_input_height: 1000, - max_input_width: 1000, - max_processing_time_ms: 60 * 1000, // 60 seconds - }), - resize_algorithm: image_processor::task::ResizeAlgorithm::Lanczos3 as i32, - upscale: true, // For profile pictures we want to have a consistent size - scales: vec![1, 2, 3, 4], - resize_method: image_processor::task::ResizeMethod::PadCenter as i32, - output_prefix: format!("{owner_id}/{file_id}"), - } - } + fn validate_permissions(auth: &AuthData) -> bool { + auth.user_permissions.has_permission(RolePermission::UploadProfilePicture) + } - fn task_priority(global: &std::sync::Arc) -> i64 { - global.config::().profile_picture_task_priority - } - - fn get_max_size(global: &Arc) -> usize { - global.config::().max_profile_picture_size - } - - fn validate_permissions(auth: &AuthData) -> bool { - auth.user_permissions.has_permission(RolePermission::UploadProfilePicture) - } + fn file_type(_global: &std::sync::Arc) -> FileType { + FileType::ProfilePicture + } - fn file_type(_global: &std::sync::Arc) -> FileType { - FileType::ProfilePicture - } - - async fn process(&self, auth: &AuthData, tx: &Transaction<'_>, file_id: Ulid) -> Result<(), RouteError> { - if self.set_active { - common::database::query("UPDATE users SET pending_profile_picture_id = $1 WHERE id = $2") - .bind(file_id) - .bind(auth.session.user_id) - .build() - .execute(&tx) - .await - .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to update user"))?; - } - Ok(()) - } + async fn process(&self, auth: &AuthData, tx: &Transaction<'_>, file_id: Ulid) -> Result<(), RouteError> { + if self.set_active { + common::database::query("UPDATE users SET pending_profile_picture_id = $1 WHERE id = $2") + .bind(file_id) + .bind(auth.session.user_id) + .build() + .execute(&tx) + .await + .map_err_route((StatusCode::INTERNAL_SERVER_ERROR, "failed to update user"))?; + } + Ok(()) + } } diff --git a/platform/api/src/config.rs b/platform/api/src/config.rs index 39806842..c08a67d2 100644 --- a/platform/api/src/config.rs +++ b/platform/api/src/config.rs @@ -26,7 +26,7 @@ impl Default for ApiConfig { bind_address: "[::]:4000".parse().expect("failed to parse bind address"), tls: None, max_profile_picture_size: 5 * 1024 * 1024, // 5 MB - max_offline_banner_size: 10 * 1024 * 1024, // 10 MB + max_offline_banner_size: 10 * 1024 * 1024, // 10 MB } } } diff --git a/platform/website/src/components/channel/offline-banner.svelte b/platform/website/src/components/channel/offline-banner.svelte index b25f7724..af936d5c 100644 --- a/platform/website/src/components/channel/offline-banner.svelte +++ b/platform/website/src/components/channel/offline-banner.svelte @@ -1,5 +1,5 @@ {#if preparedVariants && preparedVariants.bestSupported} - - {#each preparedVariants.variants as variant} - - {/each} - + {#each preparedVariants.variants as variant} + + {/each} + {alt} - + {alt} + /> + {/if} diff --git a/platform/website/src/components/settings/file-upload-button.svelte b/platform/website/src/components/settings/file-upload-button.svelte index de4d3d18..e9bc05d1 100644 --- a/platform/website/src/components/settings/file-upload-button.svelte +++ b/platform/website/src/components/settings/file-upload-button.svelte @@ -11,18 +11,18 @@ import { pipe, subscribe, type Subscription } from "wonka"; import ErrorDialog from "../error-dialog.svelte"; - export let endpoint: string; - export let disabled: boolean = false; - export let pendingFileId: string | null = null; + export let endpoint: string; + export let disabled: boolean = false; + export let pendingFileId: string | null = null; - const dispatch = createEventDispatcher(); - const client = getContextClient(); + const dispatch = createEventDispatcher(); + const client = getContextClient(); - let files: FileList; - let input: HTMLInputElement; - let turnstileToken: string | null = null; + let files: FileList; + let input: HTMLInputElement; + let turnstileToken: string | null = null; - let fileSub: Subscription; + let fileSub: Subscription; let fileError: string | null = null; function subToFileStatus(fileId?: string | null) { @@ -44,14 +44,14 @@ ), subscribe(({ data }) => { if (data) { - pendingFileId = null; + pendingFileId = null; if (data.fileStatus.status === FileStatus.Failure) { console.error("file upload failed: ", data.fileStatus.reason); fileError = data.fileStatus.friendlyMessage ?? data.fileStatus.reason ?? null; - dispatch("error"); + dispatch("error"); } else { - dispatch("success"); - } + dispatch("success"); + } } }), ); @@ -59,7 +59,7 @@ $: subToFileStatus(pendingFileId); - function uploadProfilePicture() { + function uploadProfilePicture() { if (turnstileToken) { uploadFile( `${PUBLIC_UPLOAD_ENDPOINT}/${endpoint}`, @@ -70,63 +70,59 @@ .then((res) => res.json()) .then((res) => { if (res.success) { - pendingFileId = res.file_id ?? null; - dispatch("pending"); + pendingFileId = res.file_id ?? null; + dispatch("pending"); } else { - fileError = res.message ?? null; - pendingFileId = null; - dispatch("error"); + fileError = res.message ?? null; + pendingFileId = null; + dispatch("error"); } }) .catch((err) => { fileError = err; - pendingFileId = null; - dispatch("error"); + pendingFileId = null; + dispatch("error"); }); } } - $: if (files && files[0]) { - dispatch("uploading"); - uploadProfilePicture(); - } + $: if (files && files[0]) { + dispatch("uploading"); + uploadProfilePicture(); + } {#if fileError} - (fileError = null)} - /> + (fileError = null)} /> {/if} diff --git a/platform/website/src/routes/(app)/settings/profile/+page.svelte b/platform/website/src/routes/(app)/settings/profile/+page.svelte index a5a5ef44..1d0018be 100644 --- a/platform/website/src/routes/(app)/settings/profile/+page.svelte +++ b/platform/website/src/routes/(app)/settings/profile/+page.svelte @@ -174,7 +174,14 @@ /> {/if}
- (status = Status.Saving)}>Upload Picture + (status = Status.Saving)}>Upload Picture From 70ca6291722eff761983607c70c6f2fbb96b888a Mon Sep 17 00:00:00 2001 From: Lennart Kloock Date: Sat, 20 Jan 2024 22:15:25 +0100 Subject: [PATCH 11/21] fix(website): responsive image --- .../src/components/channel/offline-banner.svelte | 5 +++-- .../src/components/responsive-image.svelte | 16 +++++++++++++--- .../(app)/[username]/(banner)/+layout.svelte | 7 ++++++- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/platform/website/src/components/channel/offline-banner.svelte b/platform/website/src/components/channel/offline-banner.svelte index af936d5c..c8508400 100644 --- a/platform/website/src/components/channel/offline-banner.svelte +++ b/platform/website/src/components/channel/offline-banner.svelte @@ -9,6 +9,8 @@ export let channelId: string; export let offlineBanner: ImageUpload | null | undefined; + // The estimated width that the banner will have on the full page. + export let fullPageWidth: string | null = null; const client = getContextClient(); @@ -65,10 +67,10 @@ image={offlineBanner} alt="offline banner" background - aspectRatio="5/1" width="100%" height="100%" fitMode="cover" + {fullPageWidth} /> {/if} @@ -79,7 +81,6 @@ .wrapper { display: inline-block; - overflow: hidden; position: relative; width: 100%; diff --git a/platform/website/src/components/responsive-image.svelte b/platform/website/src/components/responsive-image.svelte index 634d11a2..cf6d6f67 100644 --- a/platform/website/src/components/responsive-image.svelte +++ b/platform/website/src/components/responsive-image.svelte @@ -1,14 +1,19 @@