diff --git a/.env.example b/.env.example index c13a21e..a23abe3 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,4 @@ SURREALDB_ROOT_PASS= AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= AWS_DEFAULT_REGION= +APP_BASE_URL= diff --git a/Cargo.lock b/Cargo.lock index 58b02c0..da7adac 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4572,6 +4572,7 @@ dependencies = [ "bl", "bytes", "cfg-if", + "chrono", "core_types", "http 1.0.0", "leptos", @@ -4581,6 +4582,7 @@ dependencies = [ "serde", "server_fn", "thiserror", + "timeago", "tracing", "validation", "validator", @@ -5092,6 +5094,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "timeago" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1710e589de0a76aaf295cd47a6699f6405737dbfd3cf2b75c92d000b548d0e6" +dependencies = [ + "chrono", +] + [[package]] name = "tiny-keccak" version = "2.0.2" diff --git a/Cargo.toml b/Cargo.toml index 34a3382..e02b1cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ simple_logger = "4.2.0" surrealdb = { version = "1" } thiserror = "1" chrono = { version = "0.4", features = [ "serde" ] } +timeago = { version = "0.4", default-features = false, features = [ "chrono" ] } tokio = { version = "1", features = ["full"] } tower = { version = "0.4", features = ["full"] } tower-http = { version = "0.5", features = ["full"] } diff --git a/crates/bl/src/fetch.rs b/crates/bl/src/fetch.rs index e957f3a..88141b3 100644 --- a/crates/bl/src/fetch.rs +++ b/crates/bl/src/fetch.rs @@ -35,6 +35,26 @@ pub async fn fetch_user_owned_photo_groups( Ok(groups) } +#[instrument] +pub async fn fetch_photo_group( + photo_group_id: core_types::PhotoGroupRecordId, +) -> Result> { + let client = SurrealRootClient::new() + .await + .wrap_err("Failed to create surreal client")?; + client + .use_ns("main") + .use_db("main") + .await + .wrap_err("Failed to use surreal namespace/database")?; + + let group = core_types::PhotoGroup::fetch(photo_group_id, &client) + .await + .wrap_err("Failed to fetch photo group")?; + + Ok(group) +} + #[instrument] pub async fn fetch_user( user_id: core_types::UserRecordId, diff --git a/crates/core_types/src/lib.rs b/crates/core_types/src/lib.rs index 49e9197..1b47bf9 100644 --- a/crates/core_types/src/lib.rs +++ b/crates/core_types/src/lib.rs @@ -72,7 +72,6 @@ mod price; #[cfg(feature = "ssr")] pub(crate) mod ssr; -#[cfg(feature = "ssr")] pub use ulid::Ulid; #[cfg(feature = "ssr")] diff --git a/crates/site-app/Cargo.toml b/crates/site-app/Cargo.toml index cf536d7..866e75e 100644 --- a/crates/site-app/Cargo.toml +++ b/crates/site-app/Cargo.toml @@ -17,6 +17,8 @@ cfg-if.workspace = true thiserror.workspace = true serde.workspace = true bytes = { workspace = true, optional = true } +chrono = { workspace = true } +timeago = { workspace = true } tracing = { workspace = true, optional = true } validator = "0.16.1" diff --git a/crates/site-app/src/components/gallery.rs b/crates/site-app/src/components/gallery.rs index 28124ea..9dbf7cf 100644 --- a/crates/site-app/src/components/gallery.rs +++ b/crates/site-app/src/components/gallery.rs @@ -1,6 +1,6 @@ use leptos::*; -use crate::components::user::UserName; +use crate::components::photo_group::PhotoGroup; #[component] pub fn Gallery() -> impl IntoView { @@ -47,7 +47,7 @@ fn PhotoGroupList(groups: Vec) -> impl IntoView { } view! { -
+
{ groups.into_iter().map(|group| { view! { @@ -59,60 +59,6 @@ fn PhotoGroupList(groups: Vec) -> impl IntoView { .into_view() } -#[component] -fn PhotoGroup(group: core_types::PhotoGroup) -> impl IntoView { - let status_element = match group.status { - core_types::PhotoGroupStatus::OwnershipForSale { digital_price } => view! { -

- "For Sale: " - - { format!("${:.2}", digital_price.0) } - -

- } - .into_view(), - _ => view! { -

- "Not For Sale" -

- } - .into_view(), - }; - - view! { -
- { group.photos.into_iter().map(|photo_id| { - view! { - - } - .into_view() - }).collect::>() } -
- { status_element } -
-
-

- "Owned by " -

-

- "Photographed by " -

-
-
-
- // context menu ellipsis - - -
-
- } -} - #[cfg_attr(feature = "ssr", tracing::instrument)] #[server] pub async fn fetch_user_photo_groups( diff --git a/crates/site-app/src/components/mod.rs b/crates/site-app/src/components/mod.rs index 6e99f82..b949543 100644 --- a/crates/site-app/src/components/mod.rs +++ b/crates/site-app/src/components/mod.rs @@ -2,6 +2,7 @@ pub mod form; pub mod gallery; pub mod navigation; pub mod photo; +pub mod photo_group; pub mod photo_upload; pub mod user; @@ -33,4 +34,14 @@ pub mod basic { } } + + #[component] + pub fn TimeAgo(time: chrono::DateTime) -> impl IntoView { + let formatter = timeago::Formatter::new(); + let formatted = formatter.convert_chrono(time, chrono::Utc::now()); + + view! { + { formatted } + } + } } diff --git a/crates/site-app/src/components/photo_group.rs b/crates/site-app/src/components/photo_group.rs new file mode 100644 index 0000000..cf7013d --- /dev/null +++ b/crates/site-app/src/components/photo_group.rs @@ -0,0 +1,112 @@ +use leptos::*; + +use crate::components::user::UserName; + +#[component] +pub fn EllipsisButton() -> impl IntoView { + view! { + + } +} + +#[component] +pub fn PhotoGroup( + group: core_types::PhotoGroup, + #[prop(default = false)] read_only: bool, + #[prop(default = "")] extra_class: &'static str, +) -> impl IntoView { + let status_element = match group.status { + core_types::PhotoGroupStatus::OwnershipForSale { digital_price } => view! { +

+ "For Sale: " + + { format!("${:.2}", digital_price.0) } + +

+ } + .into_view(), + _ => view! { +

+ "Not For Sale" +

+ } + .into_view(), + }; + + let share_url = format!( + "{}/photo/{}", + std::env::var("APP_BASE_URL").expect("APP_BASE_URL not set"), + group.id.0 + ); + + let photos_element = view! { + { group.photos.clone().into_iter().map(|photo_id| { + view! { + + } + .into_view() + }).collect::>() } + } + .into_view(); + + let owned_by_element = view! { + "Owned by " + } + .into_view(); + + let uploaded_by_element = view! { + "Uploaded by " + } + .into_view(); + + let created_at_element = view! { + + } + .into_view(); + + view! { +
+
+ { photos_element } +
+
+ { status_element } + +
+
+
+

+ { owned_by_element } +

+

+ { uploaded_by_element } + ", " + { created_at_element } +

+
+ { match read_only { + false => view! { + + "Share" + + }.into_view(), + true => ().into_view() + } } +
+
+ } +} diff --git a/crates/site-app/src/lib.rs b/crates/site-app/src/lib.rs index 4d93d47..0abe007 100644 --- a/crates/site-app/src/lib.rs +++ b/crates/site-app/src/lib.rs @@ -9,8 +9,9 @@ use leptos_meta::*; use leptos_router::*; use crate::{ - components::navigation::navigate_to, + components::navigation::reload, error_template::{AppError, ErrorTemplate}, + pages::Footer, utils::authenticated_user, }; @@ -39,7 +40,10 @@ pub fn App() -> impl IntoView { + + +
} @@ -88,7 +92,7 @@ pub fn LogoutButton(class: Option) -> impl IntoView { create_effect(move |_| { if matches!(logout_value(), Some(Ok(_))) { - navigate_to("/"); + reload(); } }); diff --git a/crates/site-app/src/pages/dashboard/mod.rs b/crates/site-app/src/pages/dashboard/mod.rs index 5f84d7a..f3ffb4a 100644 --- a/crates/site-app/src/pages/dashboard/mod.rs +++ b/crates/site-app/src/pages/dashboard/mod.rs @@ -18,11 +18,11 @@ pub fn DashboardPage() -> impl IntoView { } view! { - +

"Marketplace Photos"

-
- +
+
} diff --git a/crates/site-app/src/pages/mod.rs b/crates/site-app/src/pages/mod.rs index 1ad5499..ae46015 100644 --- a/crates/site-app/src/pages/mod.rs +++ b/crates/site-app/src/pages/mod.rs @@ -1,9 +1,12 @@ pub mod auth; pub mod dashboard; pub mod home_page; +pub mod purchase; use leptos::*; +use crate::components::basic::Link; + #[component] pub fn SmallPageWrapper(children: Children) -> impl IntoView { view! { @@ -15,15 +18,41 @@ pub fn SmallPageWrapper(children: Children) -> impl IntoView { } } +/// A wrapper for all pages in the app. +/// +/// It acts as a responsive container, and if `backed` is true, it will look +/// like a card with a shadow, a background color, and a border radius. #[component] pub fn PageWrapper( children: Children, - #[prop(default = "bg-base-100")] bg_color: &'static str, - #[prop(default = "shadow")] shadow: &'static str, + /// Whether the wrapper should appear "backed" like a card. + #[prop(default = true)] + backed: bool, ) -> impl IntoView { view! { -
+
{children()}
} } + +#[component] +pub fn Footer() -> impl IntoView { + view! { +
+
+

" 2024 PicturePro"

+ Terms of Service + Privacy + Security + Contact +
+
+ } +} diff --git a/crates/site-app/src/pages/purchase.rs b/crates/site-app/src/pages/purchase.rs new file mode 100644 index 0000000..cec3555 --- /dev/null +++ b/crates/site-app/src/pages/purchase.rs @@ -0,0 +1,233 @@ +pub mod error; + +use leptos::*; +use leptos_router::use_params_map; + +use crate::{ + components::photo_group::PhotoGroup, pages::PageWrapper, + server_fns::photo_group::fetch_photo_group, +}; + +#[component] +pub fn PurchasePage() -> impl IntoView { + let params = use_params_map(); + let id = params().get("id").cloned(); + + // fail out if there's no id + let Some(id) = id else { + return error::PurchasePageNoId().into_view(); + }; + + // parse to a `PhotoGroupRecordId` + let Ok(ulid) = core_types::Ulid::from_string(&id) else { + return error::PurchasePageInvalidId().into_view(); + }; + let photo_group_id = core_types::PhotoGroupRecordId(ulid); + + // create a resource for fetching the photo group + let photo_group = create_resource(move || photo_group_id, fetch_photo_group); + + // render the page, using InnerPurchasePage if everything works + view! { + + { move || photo_group.map(|group| { + match group { + Ok(Some(group)) => { + view! { + + } + } + Ok(None) => { + view! { + + } + } + Err(e) => { + view! { + + } + } + } + }) } + + } +} + +#[component] +fn AboutThePhotographer(user_id: core_types::UserRecordId) -> impl IntoView { + let user = create_resource( + move || (), + move |_| crate::server_fns::user::fetch_user(user_id), + ); + + view! { + + { move || match user() { + Some(Ok(Some(user))) => { + Some(view! { + + } + .into_view()) + } + Some(Ok(None)) => { + Some(view! { +

{ "User not found" }

+ } + .into_view()) + } + Some(Err(e)) => { + Some(view! { +

{ format!("Failed to load user: {e}") }

+ } + .into_view()) + } + None => None, + } } +
+ } +} + +#[component] +fn InnerAboutThePhotographer(user: core_types::PublicUser) -> impl IntoView { + let user_initials = user + .name + .split_whitespace() + .filter_map(|s| s.chars().next()) + .collect::() + .to_uppercase(); + + view! { +
+

About the Photographer

+
+
+
+ {user_initials} +
+
+
+

{user.name}

+

"58 photos sold on-platform"

+
+
+

+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat." +

+
+ } +} + +#[component] +fn InnerPurchasePage(group: core_types::PhotoGroup) -> impl IntoView { + let title_element = format!( + "Photo{} for Sale", + if group.photos.len() > 1 { "s" } else { "" } + ); + + view! { + +

+ { title_element } +

+
+
+ + +
+ +
+
+ } +} + +struct PurchaseOption { + title: &'static str, + desc: &'static str, + price: Option, +} + +#[component] +fn PhotoPurchaseOptions(group: core_types::PhotoGroup) -> impl IntoView { + let user = crate::authenticated_user(); + let user_id = user.as_ref().map(|u| u.id); + + let options = match group.status { + core_types::PhotoGroupStatus::OwnershipForSale { .. } + if Some(group.owner) == user_id => + { + vec![PurchaseOption { + title: "You Own This", + desc: "You own the digital rights to this photo at this moment, but \ + it is still available for purchase.", + price: None, + }] + } + core_types::PhotoGroupStatus::OwnershipForSale { digital_price } => { + vec![ + PurchaseOption { + title: "Ownership", + desc: "Own the digital rights to this photo. You'll recieve an \ + email with a link to download the full resolution photo.", + price: Some(digital_price.0), + }, + PurchaseOption { + title: "Prints", + desc: "Order physical prints of this photo. Includes digital \ + rights.", + price: Some(digital_price.0 + 5.0), + }, + ] + } + core_types::PhotoGroupStatus::OwnershipPurchased { owner } + if Some(owner) == user_id => + { + vec![PurchaseOption { + title: "You Own This", + desc: "You own the digital rights to this photo, and it is no longer \ + available for purchase.", + price: None, + }] + } + core_types::PhotoGroupStatus::OwnershipPurchased { .. } => { + vec![PurchaseOption { + title: "Already Owned", + desc: "This photo has already been purchased by someone else", + price: None, + }] + } + core_types::PhotoGroupStatus::UsageRightsForSale { .. } => { + todo!() + } + }; + + view! { +
+ { options.into_iter().map(|option| { + view! { +
+

+ { option.title } + { option.price.map(|p| view! { + ": "{ format!("${p:.2}") } + })} +

+

{ option.desc }

+
+ { option.price.map(|_| view! { +
+
+
+ +
+
+
+ })} +
+ } + }).collect::>() } +
+ } +} diff --git a/crates/site-app/src/pages/purchase/error.rs b/crates/site-app/src/pages/purchase/error.rs new file mode 100644 index 0000000..9b743e0 --- /dev/null +++ b/crates/site-app/src/pages/purchase/error.rs @@ -0,0 +1,51 @@ +use leptos::*; + +use crate::pages::PageWrapper; + +#[component] +pub fn PurchasePageNoId() -> impl IntoView { + view! { + + } +} + +#[component] +pub fn PurchasePageInvalidId() -> impl IntoView { + view! { + + } +} + +#[component] +pub fn PurchasePageMissing() -> impl IntoView { + view! { + + } +} + +#[component] +pub fn PurchasePageInternalError(error: String) -> impl IntoView { + tracing::error!("Internal error: {}", error); + + view! { + + } +} + +#[component] +pub fn PurchasePageError(error: String) -> impl IntoView { + view! { + +

Purchase

+

{ error }

+
+ } +} diff --git a/crates/site-app/src/server_fns/mod.rs b/crates/site-app/src/server_fns/mod.rs index 22d12a3..4a0a462 100644 --- a/crates/site-app/src/server_fns/mod.rs +++ b/crates/site-app/src/server_fns/mod.rs @@ -1 +1,2 @@ +pub mod photo_group; pub mod user; diff --git a/crates/site-app/src/server_fns/photo_group.rs b/crates/site-app/src/server_fns/photo_group.rs new file mode 100644 index 0000000..fe170d1 --- /dev/null +++ b/crates/site-app/src/server_fns/photo_group.rs @@ -0,0 +1,15 @@ +use leptos::*; + +#[cfg_attr(feature = "ssr", tracing::instrument)] +#[server] +pub async fn fetch_photo_group( + photo_group_id: core_types::PhotoGroupRecordId, +) -> Result, ServerFnError> { + bl::fetch::fetch_photo_group(photo_group_id) + .await + .map_err(|e| { + let error = format!("Failed to fetch photo group: {:?}", e); + tracing::error!("{error}"); + ServerFnError::new(error) + }) +} diff --git a/crates/site-app/style/main.scss b/crates/site-app/style/main.scss index bd6213e..db583de 100644 --- a/crates/site-app/style/main.scss +++ b/crates/site-app/style/main.scss @@ -1,3 +1,59 @@ @tailwind base; @tailwind components; -@tailwind utilities; \ No newline at end of file +@tailwind utilities; + +@layer components { + @supports (-webkit-appearance: -apple-pay-button) { + .apple-pay-button { + display: inline-block; + -webkit-appearance: -apple-pay-button; + text-indent: -9999px; + } + + .apple-pay-button-black { + -apple-pay-button-style: black; + + } + + .apple-pay-button-white { + -apple-pay-button-style: white; + } + + .apple-pay-button-white-with-line { + -apple-pay-button-style: white-outline; + } + } + + @supports not (-webkit-appearance: -apple-pay-button) { + .apple-pay-button { + display: inline-block; + background-size: 100% 60%; + background-repeat: no-repeat; + background-position: 50% 50%; + border-radius: 5px; + padding: 0px; + box-sizing: border-box; + min-width: 200px; + min-height: 32px; + max-height: 64px; + border: 0; + } + + .apple-pay-button-black { + background-image: -webkit-named-image(apple-pay-logo-white); + background-color: black; + color: white; + } + + .apple-pay-button-white { + background-image: -webkit-named-image(apple-pay-logo-black); + background-color: white; + } + + .apple-pay-button-white-with-line { + background-image: -webkit-named-image(apple-pay-logo-black); + background-color: white; + border: .5px solid black; + } + } +} \ No newline at end of file diff --git a/crates/site-app/style/tailwind/tailwind.config.js b/crates/site-app/style/tailwind/tailwind.config.js index be48bd5..d247215 100644 --- a/crates/site-app/style/tailwind/tailwind.config.js +++ b/crates/site-app/style/tailwind/tailwind.config.js @@ -7,6 +7,7 @@ module.exports = { 'sans': ['inter', 'ui-sans-serif', 'system-ui', 'sans-serif', "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"], }, screens: { + 'xs': '480px', 'sm': '640px', 'md': '768px', 'lg': '1024px', diff --git a/crates/site-server/src/fileserv.rs b/crates/site-server/src/fileserv.rs index b4855ec..75bd91f 100644 --- a/crates/site-server/src/fileserv.rs +++ b/crates/site-server/src/fileserv.rs @@ -9,19 +9,27 @@ use site_app::App; use tower::ServiceExt; use tower_http::services::ServeDir; +use crate::AppState; + pub async fn file_and_error_handler( uri: Uri, - State(options): State, + auth_session: auth::AuthSession, + State(app_state): State, req: Request, ) -> AxumResponse { - let root = options.site_root.clone(); + let root = app_state.leptos_options.site_root.clone(); let res = get_static_file(uri.clone(), &root).await.unwrap(); if res.status() == StatusCode::OK { res.into_response() } else { - let handler = leptos_axum::render_app_to_stream( - options.to_owned(), + let handler = leptos_axum::render_app_to_stream_with_context( + app_state.leptos_options.to_owned(), + move || { + provide_context(core_types::LoggedInUser( + auth_session.user.clone().map(core_types::PublicUser::from), + )) + }, move || view! { }, ); handler(req).await.into_response()